Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-08-31 12:13:01 +00:00
parent 6170bdc060
commit 3034c7e6aa
112 changed files with 3056 additions and 697 deletions

View File

@ -385,7 +385,9 @@ group :development, :test do
gem 'haml_lint', '~> 0.40.0', require: false
gem 'bundler-audit', '~> 0.7.0.1', require: false
# Benchmarking & profiling
gem 'benchmark-ips', '~> 2.3.0', require: false
gem 'benchmark-memory', '~> 0.1', require: false
gem 'knapsack', '~> 1.21.1'
gem 'crystalball', '~> 0.7.0', require: false
@ -460,7 +462,6 @@ gem 'ruby-prof', '~> 1.3.0'
gem 'stackprof', '~> 0.2.21', require: false
gem 'rbtrace', '~> 0.4', require: false
gem 'memory_profiler', '~> 0.9', require: false
gem 'benchmark-memory', '~> 0.1', require: false
gem 'activerecord-explain-analyze', '~> 0.1', require: false
# OAuth

View File

@ -1,7 +1,7 @@
<script>
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
import { TYPE_ISSUE } from '~/graphql_shared/constants';
import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { truncate } from '~/lib/utils/text_utility';
import { __, n__, s__, sprintf } from '~/locale';
@ -10,10 +10,12 @@ export default {
i18n: {
issuableType: {
[issuableTypes.issue]: __('issue'),
[issuableTypes.epic]: __('epic'),
},
},
graphQLIdType: {
[issuableTypes.issue]: TYPE_ISSUE,
[issuableTypes.epic]: TYPE_EPIC,
},
referenceFormatter: {
[issuableTypes.issue]: (r) => r.split('/')[1],
@ -40,7 +42,7 @@ export default {
type: String,
required: true,
validator(value) {
return [issuableTypes.issue].includes(value);
return [issuableTypes.issue, issuableTypes.epic].includes(value);
},
},
},
@ -53,14 +55,21 @@ export default {
return blockingIssuablesQueries[this.issuableType].query;
},
variables() {
if (this.isEpic) {
return {
fullPath: this.item.group.fullPath,
iid: Number(this.item.iid),
};
}
return {
id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id),
};
},
update(data) {
this.skip = true;
const issuable = this.isEpic ? data?.group?.issuable : data?.issuable;
return data?.issuable?.blockingIssuables?.nodes || [];
return issuable?.blockingIssuables?.nodes || [];
},
error(error) {
const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), {
@ -77,13 +86,16 @@ export default {
};
},
computed: {
isEpic() {
return this.issuableType === issuableTypes.epic;
},
displayedIssuables() {
const { defaultDisplayLimit, referenceFormatter } = this.$options;
return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => {
return {
...i,
title: truncate(i.title, this.$options.textTruncateWidth),
reference: referenceFormatter[this.issuableType](i.reference),
reference: this.isEpic ? i.reference : referenceFormatter[this.issuableType](i.reference),
};
});
},
@ -106,6 +118,9 @@ export default {
},
);
},
blockIcon() {
return this.issuableType === issuableTypes.issue ? 'issue-block' : 'entity-blocked';
},
glIconId() {
return `blocked-icon-${this.uniqueId}`;
},
@ -153,7 +168,7 @@ export default {
<gl-icon
:id="glIconId"
ref="icon"
name="issue-block"
:name="blockIcon"
class="issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500"
data-testid="issue-blocked-icon"
@mouseenter="handleMouseEnter"

View File

@ -274,16 +274,16 @@ export default {
class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden"
>
<gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" />
<work-item-type-icon
v-if="showWorkItemTypeIcon"
:work-item-type="item.type"
show-tooltip-on-hover
/>
<span
v-if="item.referencePath"
class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-text-secondary"
:class="{ 'gl-font-base': isEpicBoard }"
>
<work-item-type-icon
v-if="showWorkItemTypeIcon"
:work-item-type="item.type"
show-tooltip-on-hover
/>
<tooltip-on-truncate
v-if="showReferencePath"
:title="itemReferencePath"

View File

@ -3,6 +3,7 @@ import { __ } from '~/locale';
import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql';
import boardBlockingEpicsQuery from './graphql/board_blocking_epics.query.graphql';
import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from './graphql/board_list_update.mutation.graphql';
@ -70,6 +71,9 @@ export const blockingIssuablesQueries = {
[issuableTypes.issue]: {
query: boardBlockingIssuesQuery,
},
[issuableTypes.epic]: {
query: boardBlockingEpicsQuery,
},
};
export const updateListQueries = {

View File

@ -0,0 +1,17 @@
query BoardBlockingEpics($fullPath: ID!, $iid: ID) {
group(fullPath: $fullPath) {
id
issuable: epic(iid: $iid) {
id
blockingIssuables: blockedByEpics {
nodes {
id
iid
title
reference(full: true)
webUrl
}
}
}
}
}

View File

@ -2,7 +2,7 @@ import { ContentEditor } from './index';
export default {
component: ContentEditor,
title: 'content_editor/components/content_editor',
title: 'content_editor/content_editor',
};
const Template = (_, { argTypes }) => ({

View File

@ -3,15 +3,12 @@ import {
GlIcon,
GlLink,
GlForm,
GlFormInputGroup,
GlInputGroupText,
GlFormInput,
GlFormGroup,
GlFormTextarea,
GlButton,
GlFormRadio,
GlFormRadioGroup,
GlFormSelect,
} from '@gitlab/ui';
import { kebabCase } from 'lodash';
import { buildApiUrl } from '~/api/api_utils';
@ -21,6 +18,7 @@ import csrf from '~/lib/utils/csrf';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
import ProjectNamespace from './project_namespace.vue';
const PRIVATE_VISIBILITY = 'private';
const INTERNAL_VISIBILITY = 'internal';
@ -39,28 +37,18 @@ const initFormField = ({ value, required = true, skipValidation = false }) => ({
feedback: null,
});
function sortNamespaces(namespaces) {
if (!namespaces || !namespaces?.length) {
return namespaces;
}
return namespaces.sort((a, b) => a.full_name.localeCompare(b.full_name));
}
export default {
components: {
GlForm,
GlIcon,
GlLink,
GlButton,
GlFormInputGroup,
GlInputGroupText,
GlFormInput,
GlFormTextarea,
GlFormGroup,
GlFormRadio,
GlFormRadioGroup,
GlFormSelect,
ProjectNamespace,
},
directives: {
validation: validation(),
@ -72,9 +60,6 @@ export default {
visibilityHelpPath: {
default: '',
},
endpoint: {
default: '',
},
projectFullPath: {
default: '',
},
@ -96,6 +81,9 @@ export default {
restrictedVisibilityLevels: {
default: [],
},
namespaceId: {
default: '',
},
},
data() {
const form = {
@ -117,14 +105,10 @@ export default {
};
return {
isSaving: false,
namespaces: [],
form,
};
},
computed: {
projectUrl() {
return `${gon.gitlab_url}/`;
},
projectVisibilityLevel() {
return VISIBILITY_LEVEL[this.projectVisibility];
},
@ -187,33 +171,31 @@ export default {
},
},
watch: {
// eslint-disable-next-line func-names
'form.fields.namespace.value': function () {
this.form.fields.visibility.value =
this.restrictedVisibilityLevels.length !== 0 ? null : PRIVATE_VISIBILITY;
},
// eslint-disable-next-line func-names
'form.fields.name.value': function (newVal) {
this.form.fields.slug.value = kebabCase(newVal);
},
},
mounted() {
this.fetchNamespaces();
},
methods: {
async fetchNamespaces() {
const { data } = await axios.get(this.endpoint);
this.namespaces = sortNamespaces(data.namespaces);
},
isVisibilityLevelDisabled(visibility) {
return !this.allowedVisibilityLevels.includes(visibility);
},
getInitialVisibilityValue() {
return this.restrictedVisibilityLevels.length !== 0 ? null : this.projectVisibility;
},
setNamespace(namespace) {
this.form.fields.visibility.value =
this.restrictedVisibilityLevels.length !== 0 ? null : PRIVATE_VISIBILITY;
this.form.fields.namespace.value = namespace;
this.form.fields.namespace.state = true;
},
async onSubmit() {
this.form.showValidation = true;
if (!this.form.fields.namespace.value) {
this.form.fields.namespace.state = false;
}
if (!this.form.state) {
return;
}
@ -282,30 +264,7 @@ export default {
:state="form.fields.namespace.state"
:invalid-feedback="s__('ForkProject|Please select a namespace')"
>
<gl-form-input-group>
<template #prepend>
<gl-input-group-text>
{{ projectUrl }}
</gl-input-group-text>
</template>
<gl-form-select
id="fork-url"
v-model="form.fields.namespace.value"
v-validation:[form.showValidation]
name="namespace"
data-testid="fork-url-input"
data-qa-selector="fork_namespace_dropdown"
:state="form.fields.namespace.state"
required
>
<template #first>
<option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option>
</template>
<option v-for="namespace in namespaces" :key="namespace.id" :value="namespace">
{{ namespace.full_name }}
</option>
</gl-form-select>
</gl-form-input-group>
<project-namespace @select="setNamespace" />
</gl-form-group>
</div>
<div class="gl-flex-basis-half">

View File

@ -0,0 +1,136 @@
<script>
import {
GlButton,
GlButtonGroup,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlTruncate,
} from '@gitlab/ui';
import createFlash from '~/flash';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
import searchForkableNamespaces from '../queries/search_forkable_namespaces.query.graphql';
export default {
components: {
GlButton,
GlButtonGroup,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlTruncate,
},
apollo: {
project: {
query: searchForkableNamespaces,
variables() {
return {
projectPath: this.projectFullPath,
search: this.search,
};
},
skip() {
const { length } = this.search;
return length > 0 && length < MINIMUM_SEARCH_LENGTH;
},
error(error) {
createFlash({
message: s__(
'ForkProject|Something went wrong while loading data. Please refresh the page to try again.',
),
captureError: true,
error,
});
},
debounce: DEBOUNCE_DELAY,
},
},
inject: ['projectFullPath'],
data() {
return {
project: {},
search: '',
selectedNamespace: null,
};
},
computed: {
rootUrl() {
return `${gon.gitlab_url}/`;
},
namespaces() {
return this.project.forkTargets?.nodes || [];
},
hasMatches() {
return this.namespaces.length;
},
dropdownText() {
return this.selectedNamespace?.fullPath || s__('ForkProject|Select a namespace');
},
},
methods: {
handleDropdownShown() {
this.$refs.search.focusInput();
},
setNamespace(namespace) {
const id = getIdFromGraphQLId(namespace.id);
this.$emit('select', {
id,
name: namespace.name,
visibility: namespace.visibility,
});
this.selectedNamespace = { id, fullPath: namespace.fullPath };
},
},
};
</script>
<template>
<gl-button-group class="gl-w-full">
<gl-button class="gl-text-truncate gl-flex-grow-0! gl-max-w-34" label :title="rootUrl">{{
rootUrl
}}</gl-button>
<gl-dropdown
class="gl-flex-grow-1"
toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20"
data-qa-selector="select_namespace_dropdown"
data-testid="select_namespace_dropdown"
no-flip
@shown="handleDropdownShown"
>
<template #button-text>
<gl-truncate :text="dropdownText" position="start" with-tooltip />
</template>
<gl-search-box-by-type
ref="search"
v-model.trim="search"
:is-loading="$apollo.queries.project.loading"
data-qa-selector="select_namespace_dropdown_search_field"
data-testid="select_namespace_dropdown_search_field"
/>
<template v-if="!$apollo.queries.project.loading">
<template v-if="hasMatches">
<gl-dropdown-section-header>{{ __('Namespaces') }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="namespace of namespaces"
:key="namespace.id"
data-qa-selector="select_namespace_dropdown_item"
@click="setNamespace(namespace)"
>
{{ namespace.fullPath }}
</gl-dropdown-item>
</template>
<gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text>
</template>
</gl-dropdown>
</gl-button-group>
</template>

View File

@ -1,4 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import App from './components/app.vue';
const mountElement = document.getElementById('fork-groups-mount-element');
@ -17,9 +19,14 @@ const {
restrictedVisibilityLevels,
} = mountElement.dataset;
Vue.use(VueApollo);
// eslint-disable-next-line no-new
new Vue({
el: mountElement,
apolloProvider: new VueApollo({
defaultClient: createDefaultClient(),
}),
provide: {
newGroupPath,
visibilityHelpPath,

View File

@ -0,0 +1,13 @@
query searchForkableNamespaces($projectPath: ID!, $search: String) {
project(fullPath: $projectPath) {
id
forkTargets(search: $search) {
nodes {
id
fullPath
name
visibility
}
}
}
}

View File

@ -141,7 +141,7 @@ export default class Project {
if (doesPathContainRef) {
// We are ignoring the url containing the ref portion
// and plucking the thereafter portion to reconstructure the url that is correct
const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0];
const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0].split('?')[0];
selectedUrl.searchParams.set('path', targetPath);
selectedUrl.hash = window.location.hash;
}

View File

@ -0,0 +1,112 @@
<script>
import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerName from '../runner_name.vue';
import RunnerTags from '../runner_tags.vue';
import RunnerTypeBadge from '../runner_type_badge.vue';
import { formatJobCount } from '../../utils';
import {
I18N_LOCKED_RUNNER_DESCRIPTION,
I18N_VERSION_LABEL,
I18N_LAST_CONTACT_LABEL,
I18N_CREATED_AT_LABEL,
} from '../../constants';
import RunnerSummaryField from './runner_summary_field.vue';
export default {
components: {
GlIcon,
GlSprintf,
TimeAgo,
RunnerSummaryField,
RunnerName,
RunnerTags,
RunnerTypeBadge,
RunnerUpgradeStatusIcon: () =>
import('ee_component/runner/components/runner_upgrade_status_icon.vue'),
TooltipOnTruncate,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
runner: {
type: Object,
required: true,
},
},
computed: {
jobCount() {
return formatJobCount(this.runner.jobCount);
},
},
i18n: {
I18N_LOCKED_RUNNER_DESCRIPTION,
I18N_VERSION_LABEL,
I18N_LAST_CONTACT_LABEL,
I18N_CREATED_AT_LABEL,
},
};
</script>
<template>
<div>
<div>
<slot :runner="runner" name="runner-name">
<runner-name :runner="runner" />
</slot>
<gl-icon
v-if="runner.locked"
v-gl-tooltip
:title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION"
name="lock"
/>
<runner-type-badge :type="runner.runnerType" size="sm" />
</div>
<div class="gl-ml-auto gl-display-inline-flex gl-max-w-full gl-py-2">
<div class="gl-flex-shrink-0">
<runner-upgrade-status-icon :runner="runner" />
<gl-sprintf v-if="runner.version" :message="$options.i18n.I18N_VERSION_LABEL">
<template #version>{{ runner.version }}</template>
</gl-sprintf>
</div>
<div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div>
<tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description">
{{ runner.description }}
</tooltip-on-truncate>
</div>
<div>
<runner-summary-field icon="clock">
<gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL">
<template #timeAgo>
<time-ago v-if="runner.contactedAt" :time="runner.contactedAt" />
<template v-else>{{ __('Never') }}</template>
</template>
</gl-sprintf>
</runner-summary-field>
<runner-summary-field v-if="runner.ipAddress" icon="disk" :tooltip="__('IP Address')">
{{ runner.ipAddress }}
</runner-summary-field>
<runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')">
{{ jobCount }}
</runner-summary-field>
<runner-summary-field icon="calendar">
<gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL">
<template #timeAgo>
<time-ago v-if="runner.createdAt" :time="runner.createdAt" />
</template>
</gl-sprintf>
</runner-summary-field>
</div>
<runner-tags class="gl-display-block gl-pt-2" :tag-list="runner.tagList" size="sm" />
</div>
</template>

View File

@ -0,0 +1,33 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
icon: {
type: String,
required: false,
default: '',
},
tooltip: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2">
<gl-icon v-if="icon" :name="icon" />
<!-- display tooltip as a label for screen readers -->
<span class="gl-sr-only">{{ tooltip }}</span>
<slot></slot>
</div>
</template>

View File

@ -2,11 +2,13 @@
import { GlFormCheckbox, GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
import { formatJobCount, tableField } from '../utils';
import RunnerSummaryCell from './cells/runner_summary_cell.vue';
import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue';
import RunnerStatusPopover from './runner_status_popover.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
@ -19,6 +21,12 @@ const defaultFields = [
tableField({ key: 'actions', label: '' }),
];
const stackedLayoutFields = [
tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }),
tableField({ key: 'summary', label: s__('Runners|Runner') }),
tableField({ key: 'actions', label: '', thClasses: ['gl-w-15p'] }),
];
export default {
components: {
GlFormCheckbox,
@ -28,11 +36,13 @@ export default {
TimeAgo,
RunnerStatusPopover,
RunnerSummaryCell,
RunnerStackedSummaryCell,
RunnerStatusCell,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagMixin()],
apollo: {
checkedRunnerIds: {
query: checkedRunnerIdsQuery,
@ -62,6 +72,11 @@ export default {
return { checkedRunnerIds: [] };
},
computed: {
stackedLayout() {
// runner_list_stacked_layout_admin or runner_list_stacked_layout
const { runnerListStackedLayoutAdmin, runnerListStackedLayout } = this.glFeatures || {};
return runnerListStackedLayoutAdmin || runnerListStackedLayout;
},
tableClass() {
// <gl-table-lite> does not provide a busy state, add
// simple support for it.
@ -71,6 +86,8 @@ export default {
};
},
fields() {
const fields = this.stackedLayout ? stackedLayoutFields : defaultFields;
if (this.checkable) {
const checkboxField = tableField({
key: 'checkbox',
@ -78,9 +95,9 @@ export default {
thClasses: ['gl-w-9'],
tdClass: ['gl-text-center'],
});
return [checkboxField, ...defaultFields];
return [checkboxField, ...fields];
}
return defaultFields;
return fields;
},
},
methods: {
@ -138,24 +155,30 @@ export default {
</template>
<template #cell(summary)="{ item, index }">
<runner-summary-cell :runner="item">
<runner-stacked-summary-cell v-if="stackedLayout" :runner="item">
<template #runner-name="{ runner }">
<slot name="runner-name" :runner="runner" :index="index"></slot>
</template>
</runner-stacked-summary-cell>
<runner-summary-cell v-else :runner="item">
<template #runner-name="{ runner }">
<slot name="runner-name" :runner="runner" :index="index"></slot>
</template>
</runner-summary-cell>
</template>
<template #cell(version)="{ item: { version } }">
<template v-if="!stackedLayout" #cell(version)="{ item: { version } }">
<tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="version">
{{ version }}
</tooltip-on-truncate>
</template>
<template #cell(jobCount)="{ item: { jobCount } }">
{{ formatJobCount(jobCount) }}
<template v-if="!stackedLayout" #cell(jobCount)="{ item: { jobCount } }">
<span data-testid="job-count">{{ formatJobCount(jobCount) }}</span>
</template>
<template #cell(contactedAt)="{ item: { contactedAt } }">
<template v-if="!stackedLayout" #cell(contactedAt)="{ item: { contactedAt } }">
<time-ago v-if="contactedAt" :time="contactedAt" />
<template v-else>{{ __('Never') }}</template>
</template>

View File

@ -77,9 +77,13 @@ export const I18N_DELETE_DISABLED_UNKNOWN_REASON = s__(
);
export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
// List
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.',
);
export const I18N_VERSION_LABEL = s__('Runners|Version %{version}');
export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}');
export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}');
// Runner details

View File

@ -9,6 +9,7 @@ fragment ListItemShared on CiRunner {
locked
jobCount
tagList
createdAt
contactedAt
status(legacyMode: null)
userPermissions {

View File

@ -0,0 +1,243 @@
<script>
import {
GlButton,
GlTooltipDirective,
GlIcon,
GlFormCheckbox,
GlFormInput,
GlFormInputGroup,
GlDropdown,
GlDropdownItem,
GlSprintf,
GlSafeHtmlDirective,
} from '@gitlab/ui';
import $ from 'jquery';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import * as Emoji from '~/emoji';
import { __, s__ } from '~/locale';
import { timeRanges } from '~/vue_shared/constants';
export const AVAILABILITY_STATUS = {
BUSY: 'busy',
NOT_SET: 'not_set',
};
const statusTimeRanges = [
{
label: __('Never'),
name: 'never',
},
...timeRanges,
];
export default {
components: {
GlButton,
GlIcon,
GlFormCheckbox,
GlFormInput,
GlFormInputGroup,
GlDropdown,
GlDropdownItem,
GlSprintf,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
props: {
defaultEmoji: {
type: String,
required: false,
default: '',
},
emoji: {
type: String,
required: true,
},
message: {
type: String,
required: true,
},
availability: {
type: Boolean,
required: true,
},
clearStatusAfter: {
type: Object,
required: false,
default: () => ({}),
},
currentClearStatusAfter: {
type: String,
required: false,
default: '',
},
},
data() {
return {
defaultEmojiTag: '',
emojiTag: '',
};
},
computed: {
isCustomEmoji() {
return this.emoji !== this.defaultEmoji;
},
isDirty() {
return Boolean(this.message.length || this.isCustomEmoji);
},
noEmoji() {
return this.emojiTag === '';
},
},
mounted() {
this.setupEmojiListAndAutocomplete();
},
methods: {
async setupEmojiListAndAutocomplete() {
const emojiAutocomplete = new GfmAutoComplete();
emojiAutocomplete.setup($(this.$refs.statusMessageField.$el), { emojis: true });
if (this.emoji) {
this.emojiTag = Emoji.glEmojiTag(this.emoji);
}
this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji);
this.setDefaultEmoji();
},
setDefaultEmoji() {
const { emojiTag } = this;
const hasStatusMessage = Boolean(this.message.length);
if (hasStatusMessage && emojiTag) {
return;
}
if (hasStatusMessage) {
this.emojiTag = this.defaultEmojiTag;
} else if (emojiTag === this.defaultEmojiTag) {
this.clearEmoji();
}
},
handleEmojiClick(emoji) {
this.$emit('emoji-click', emoji);
this.emojiTag = Emoji.glEmojiTag(emoji);
},
clearEmoji() {
if (this.emojiTag) {
this.emojiTag = '';
}
},
clearStatusInputs() {
this.$emit('emoji-click', '');
this.$emit('message-input', '');
this.clearEmoji();
},
},
statusTimeRanges,
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
i18n: {
statusMessagePlaceholder: s__(`SetStatusModal|What's your status?`),
clearStatusButtonLabel: s__('SetStatusModal|Clear status'),
availabilityCheckboxLabel: s__('SetStatusModal|Busy'),
availabilityCheckboxHelpText: s__(
'SetStatusModal|An indicator appears next to your name and avatar',
),
clearStatusAfterDropdownLabel: s__('SetStatusModal|Clear status after'),
clearStatusAfterMessage: s__('SetStatusModal|Your status resets on %{date}.'),
},
};
</script>
<template>
<div>
<input :value="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" />
<gl-form-input-group class="gl-mb-5">
<gl-form-input
ref="statusMessageField"
:value="message"
:placeholder="$options.i18n.statusMessagePlaceholder"
class="js-status-message-field"
name="user[status][message]"
@keyup="setDefaultEmoji"
@input="$emit('message-input', $event)"
@keyup.enter.prevent
/>
<template #prepend>
<emoji-picker
dropdown-class="gl-h-full"
toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
boundary="viewport"
:right="false"
@click="handleEmojiClick"
>
<template #button-content>
<span
v-if="noEmoji"
class="no-emoji-placeholder position-relative"
data-testid="no-emoji-placeholder"
>
<gl-icon name="slight-smile" class="award-control-icon-neutral" />
<gl-icon name="smiley" class="award-control-icon-positive" />
<gl-icon name="smile" class="award-control-icon-super-positive" />
</span>
<span v-else>
<span
v-safe-html:[$options.safeHtmlConfig]="emojiTag"
data-testid="selected-emoji"
></span>
</span>
</template>
</emoji-picker>
</template>
<template v-if="isDirty" #append>
<gl-button
v-gl-tooltip.bottom
:title="$options.i18n.clearStatusButtonLabel"
:aria-label="$options.i18n.clearStatusButtonLabel"
icon="close"
class="js-clear-user-status-button"
@click="clearStatusInputs"
/>
</template>
</gl-form-input-group>
<gl-form-checkbox
:checked="availability"
class="gl-mb-5"
data-testid="user-availability-checkbox"
@input="$emit('availability-input', $event)"
>
{{ $options.i18n.availabilityCheckboxLabel }}
<template #help>
{{ $options.i18n.availabilityCheckboxHelpText }}
</template>
</gl-form-checkbox>
<div class="form-group">
<div class="gl-display-flex gl-align-items-baseline">
<span class="gl-mr-3">{{ $options.i18n.clearStatusAfterDropdownLabel }}</span>
<gl-dropdown :text="clearStatusAfter.label" data-testid="clear-status-at-dropdown">
<gl-dropdown-item
v-for="after in $options.statusTimeRanges"
:key="after.name"
:data-testid="after.name"
@click="$emit('clear-status-after-click', after)"
>{{ after.label }}</gl-dropdown-item
>
</gl-dropdown>
</div>
<p
v-if="currentClearStatusAfter.length"
class="gl-mt-3 gl-text-gray-400 gl-font-sm"
data-testid="clear-status-at-message"
>
<gl-sprintf :message="$options.i18n.clearStatusAfterMessage">
<template #date>{{ currentClearStatusAfter }}</template>
</gl-sprintf>
</p>
</div>
</div>
</template>

View File

@ -1,28 +1,14 @@
<script>
import {
GlButton,
GlToast,
GlModal,
GlTooltipDirective,
GlIcon,
GlFormCheckbox,
GlFormInput,
GlFormInputGroup,
GlDropdown,
GlDropdownItem,
GlSafeHtmlDirective,
} from '@gitlab/ui';
import $ from 'jquery';
import { GlToast, GlTooltipDirective, GlSafeHtmlDirective, GlModal } from '@gitlab/ui';
import Vue from 'vue';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import * as Emoji from '~/emoji';
import createFlash from '~/flash';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { __, s__, sprintf } from '~/locale';
import { __, s__ } from '~/locale';
import { updateUserStatus } from '~/rest_api';
import { timeRanges } from '~/vue_shared/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isUserBusy } from './utils';
import SetStatusForm from './set_status_form.vue';
export const AVAILABILITY_STATUS = {
BUSY: 'busy',
@ -41,15 +27,8 @@ const statusTimeRanges = [
export default {
components: {
GlButton,
GlIcon,
GlModal,
GlFormCheckbox,
GlFormInput,
GlFormInputGroup,
GlDropdown,
GlDropdownItem,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
SetStatusForm,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -85,26 +64,12 @@ export default {
return {
defaultEmojiTag: '',
emoji: this.currentEmoji,
emojiMenu: null,
emojiTag: '',
message: this.currentMessage,
modalId: 'set-user-status-modal',
noEmoji: true,
availability: isUserBusy(this.currentAvailability),
clearStatusAfter: statusTimeRanges[0],
clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), {
date: this.currentClearStatusAfter,
}),
};
},
computed: {
isCustomEmoji() {
return this.emoji !== this.defaultEmoji;
},
isDirty() {
return Boolean(this.message.length || this.isCustomEmoji);
},
},
mounted() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@ -112,62 +77,10 @@ export default {
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
setupEmojiListAndAutocomplete() {
const emojiAutocomplete = new GfmAutoComplete();
emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true });
Emoji.initEmojiMap()
.then(() => {
if (this.emoji) {
this.emojiTag = Emoji.glEmojiTag(this.emoji);
}
this.noEmoji = this.emoji === '';
this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji);
this.setDefaultEmoji();
})
.catch(() =>
createFlash({
message: __('Failed to load emoji list.'),
}),
);
},
setDefaultEmoji() {
const { emojiTag } = this;
const hasStatusMessage = Boolean(this.message.length);
if (hasStatusMessage && emojiTag) {
return;
}
if (hasStatusMessage) {
this.noEmoji = false;
this.emojiTag = this.defaultEmojiTag;
} else if (emojiTag === this.defaultEmojiTag) {
this.noEmoji = true;
this.clearEmoji();
}
},
setEmoji(emoji) {
this.emoji = emoji;
this.noEmoji = false;
this.clearEmoji();
this.emojiTag = Emoji.glEmojiTag(this.emoji);
},
clearEmoji() {
if (this.emojiTag) {
this.emojiTag = '';
}
},
clearStatusInputs() {
this.emoji = '';
this.message = '';
this.noEmoji = true;
this.clearEmoji();
},
removeStatus() {
this.availability = false;
this.clearStatusInputs();
this.emoji = '';
this.message = '';
this.setStatus();
},
setStatus() {
@ -197,9 +110,18 @@ export default {
this.closeModal();
},
setClearStatusAfter(after) {
handleMessageInput(value) {
this.message = value;
},
handleEmojiClick(emoji) {
this.emoji = emoji;
},
handleClearStatusAfterClick(after) {
this.clearStatusAfter = after;
},
handleAvailabilityInput(value) {
this.availability = value;
},
},
statusTimeRanges,
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
@ -215,85 +137,20 @@ export default {
:action-primary="$options.actionPrimary"
:action-secondary="$options.actionSecondary"
modal-class="set-user-status-modal"
@shown="setupEmojiListAndAutocomplete"
@primary="setStatus"
@secondary="removeStatus"
>
<input v-model="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" />
<gl-form-input-group class="gl-mb-5">
<gl-form-input
ref="statusMessageField"
v-model="message"
:placeholder="s__(`SetStatusModal|What's your status?`)"
class="js-status-message-field"
name="user[status][message]"
@keyup="setDefaultEmoji"
@keyup.enter.prevent
/>
<template #prepend>
<emoji-picker
dropdown-class="gl-h-full"
toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
boundary="viewport"
:right="false"
@click="setEmoji"
>
<template #button-content>
<span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
<span
v-show="noEmoji"
class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
>
<gl-icon name="slight-smile" class="award-control-icon-neutral" />
<gl-icon name="smiley" class="award-control-icon-positive" />
<gl-icon name="smile" class="award-control-icon-super-positive" />
</span>
</template>
</emoji-picker>
</template>
<template v-if="isDirty" #append>
<gl-button
v-gl-tooltip.bottom
:title="s__('SetStatusModal|Clear status')"
:aria-label="s__('SetStatusModal|Clear status')"
icon="close"
class="js-clear-user-status-button"
@click="clearStatusInputs"
/>
</template>
</gl-form-input-group>
<gl-form-checkbox
v-model="availability"
class="gl-mb-5"
data-testid="user-availability-checkbox"
>
{{ s__('SetStatusModal|Busy') }}
<template #help>
{{ s__('SetStatusModal|An indicator appears next to your name and avatar') }}
</template>
</gl-form-checkbox>
<div class="form-group">
<div class="gl-display-flex gl-align-items-baseline">
<span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
<gl-dropdown :text="clearStatusAfter.label" data-testid="clear-status-at-dropdown">
<gl-dropdown-item
v-for="after in $options.statusTimeRanges"
:key="after.name"
:data-testid="after.name"
@click="setClearStatusAfter(after)"
>{{ after.label }}</gl-dropdown-item
>
</gl-dropdown>
</div>
<div
v-if="currentClearStatusAfter.length"
class="gl-mt-3 gl-text-gray-400 gl-font-sm"
data-testid="clear-status-at-message"
>
{{ clearStatusAfterMessage }}
</div>
</div>
<set-status-form
:default-emoji="defaultEmoji"
:emoji="emoji"
:message="message"
:availability="availability"
:clear-status-after="clearStatusAfter"
:current-clear-status-after="currentClearStatusAfter"
@message-input="handleMessageInput"
@emoji-click="handleEmojiClick"
@clear-status-after-click="handleClearStatusAfterClick"
@availability-input="handleAvailabilityInput"
/>
</gl-modal>
</template>

View File

@ -19,6 +19,8 @@ const steps = [
},
];
const MR_RENDER_LS_KEY = 'mr_survey_rendered';
export default {
name: 'MergeRequestExperienceSurveyApp',
components: {
@ -68,9 +70,20 @@ export default {
onQueryLoaded({ shouldShowCallout }) {
this.visible = shouldShowCallout;
if (!this.visible) this.$emit('close');
else if (!localStorage?.getItem(MR_RENDER_LS_KEY)) {
this.track('survey:mr_experience', {
label: 'render',
extra: {
accountAge: this.accountAge,
},
});
localStorage?.setItem(MR_RENDER_LS_KEY, '1');
}
},
onRate(event) {
this.$refs.dismisser?.dismiss();
this.$emit('rate');
localStorage?.removeItem(MR_RENDER_LS_KEY);
this.track('survey:mr_experience', {
label: this.step.label,
value: event,
@ -87,21 +100,18 @@ export default {
},
handleKeyup(e) {
if (e.key !== 'Escape') return;
this.$emit('close');
this.dismiss();
},
dismiss() {
this.$refs.dismisser?.dismiss();
this.trackDismissal();
},
close() {
this.trackDismissal();
this.$emit('close');
},
trackDismissal() {
this.track('survey:mr_experience', {
label: 'dismiss',
extra: {
accountAge: this.accountAge,
},
});
localStorage?.removeItem(MR_RENDER_LS_KEY);
},
},
};
@ -113,79 +123,71 @@ export default {
feature-name="mr_experience_survey"
@queryResult.once="onQueryLoaded"
>
<template #default="{ dismiss }">
<aside
class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5"
:aria-label="$options.i18n.survey"
>
<transition name="survey-slide-up">
<aside
class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5"
:aria-label="$options.i18n.survey"
>
<transition name="survey-slide-up">
<div
v-if="visible"
class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base"
>
<gl-button
v-tooltip="$options.i18n.close"
:aria-label="$options.i18n.close"
variant="default"
category="tertiary"
class="gl-top-4 gl-right-3 gl-absolute"
icon="close"
@click="dismiss"
/>
<div
v-if="visible"
class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base"
v-if="stepIndex === 0"
class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm"
role="note"
>
<gl-button
v-tooltip="$options.i18n.close"
:aria-label="$options.i18n.close"
variant="default"
category="tertiary"
class="gl-top-4 gl-right-3 gl-absolute"
icon="close"
@click="
dismiss();
close();
"
/>
<div
v-if="stepIndex === 0"
class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm"
role="note"
>
<p class="gl-m-0">
<gl-sprintf :message="$options.i18n.legal">
<template #link="{ content }">
<a
class="gl-text-decoration-underline gl-text-gray-500"
href="https://about.gitlab.com/privacy/"
target="_blank"
rel="noreferrer nofollow"
v-text="content"
></a>
</template>
</gl-sprintf>
</p>
</div>
<div class="gl-relative">
<div class="gl-absolute">
<div
v-safe-html="$options.gitlabLogo"
aria-hidden="true"
class="mr-experience-survey-logo"
></div>
</div>
</div>
<section v-if="step">
<p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7">
<gl-sprintf :message="step.question">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<satisfaction-rate
aria-labelledby="mr_survey_question"
class="gl-mt-5"
@rate="
dismiss();
onRate($event);
"
/>
</section>
<section v-else class="gl-px-7">
{{ $options.i18n.thanks }}
</section>
<p class="gl-m-0">
<gl-sprintf :message="$options.i18n.legal">
<template #link="{ content }">
<a
class="gl-text-decoration-underline gl-text-gray-500"
href="https://about.gitlab.com/privacy/"
target="_blank"
rel="noreferrer nofollow"
v-text="content"
></a>
</template>
</gl-sprintf>
</p>
</div>
</transition>
</aside>
</template>
<div class="gl-relative">
<div class="gl-absolute">
<div
v-safe-html="$options.gitlabLogo"
aria-hidden="true"
class="mr-experience-survey-logo"
></div>
</div>
</div>
<section v-if="step">
<p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7">
<gl-sprintf :message="step.question">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<satisfaction-rate
aria-labelledby="mr_survey_question"
class="gl-mt-5"
@rate="onRate"
/>
</section>
<section v-else class="gl-px-7">
{{ $options.i18n.thanks }}
</section>
</div>
</transition>
</aside>
</user-callout-dismisser>
</template>

View File

@ -1,5 +1,5 @@
<script>
import { GlSprintf } from '@gitlab/ui';
import { GlSprintf, GlLink } from '@gitlab/ui';
import { escape } from 'lodash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { n__, s__, sprintf } from '~/locale';
@ -9,6 +9,7 @@ const mergeCommitCount = s__('mrWidgetCommitsAdded|%{strongStart}1%{strongEnd} m
export default {
components: {
GlSprintf,
GlLink,
},
mixins: [glFeatureFlagMixin()],
props: {
@ -40,6 +41,11 @@ export default {
required: false,
default: '',
},
mergeCommitPath: {
type: String,
required: false,
default: '',
},
},
computed: {
isMerged() {
@ -124,7 +130,9 @@ export default {
</template>
</template>
<template #mergeCommitSha>
<span class="label-branch">{{ mergeCommitSha }}</span>
<gl-link :href="mergeCommitPath" class="label-branch" data-testid="merge-commit-sha">{{
mergeCommitSha
}}</gl-link>
</template>
</gl-sprintf>
</span>

View File

@ -680,6 +680,7 @@ export default {
:is-fast-forward-enabled="!shouldShowMergeEdit"
:commits-count="commitsCount"
:target-branch="stateData.targetBranch"
:merge-commit-path="mr.mergeCommitPath"
/>
</li>
<li v-if="mr.state !== 'closed'" class="gl-line-height-normal">

View File

@ -2,7 +2,7 @@ import CodeBlock from './code_block.vue';
export default {
component: CodeBlock,
title: 'vue_shared/components/code_block',
title: 'vue_shared/code_block',
};
const Template = (args, { argTypes }) => ({

View File

@ -2,7 +2,7 @@ import CodeBlockHighlighted from './code_block_highlighted.vue';
export default {
component: CodeBlockHighlighted,
title: 'vue_shared/components/code_block_highlighted',
title: 'vue_shared/code_block_highlighted',
};
const Template = (args, { argTypes }) => ({

View File

@ -3,7 +3,7 @@ import ConfirmDanger from './confirm_danger.vue';
export default {
component: ConfirmDanger,
title: 'vue_shared/components/modals/confirm_danger_modal',
title: 'vue_shared/modals/confirm_danger_modal',
};
const Template = (args, { argTypes }) => ({

View File

@ -5,7 +5,7 @@ import DropdownWidget from './dropdown_widget.vue';
export default {
component: DropdownWidget,
title: 'vue_shared/components/dropdown/dropdown_widget/dropdown_widget',
title: 'vue_shared/dropdown/dropdown_widget/dropdown_widget',
};
const Template = (args, { argTypes }) => ({

View File

@ -2,7 +2,7 @@ import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue';
export default {
component: InputCopyToggleVisibility,
title: 'vue_shared/components/form/input_copy_toggle_visibility',
title: 'vue_shared/form/input_copy_toggle_visibility',
};
const defaultProps = {

View File

@ -3,7 +3,7 @@ import PaginationBar from './pagination_bar.vue';
export default {
component: PaginationBar,
title: 'vue_shared/components/pagination_bar/pagination_bar',
title: 'vue_shared/pagination_bar/pagination_bar',
};
const Template = (args, { argTypes }) => ({

View File

@ -2,7 +2,7 @@ import ProjectAvatar from './project_avatar.vue';
export default {
component: ProjectAvatar,
title: 'vue_shared/components/project_avatar',
title: 'vue_shared/project_avatar',
};
const Template = (args, { argTypes }) => ({

View File

@ -2,7 +2,7 @@ import ProjectListItem from './project_list_item.vue';
export default {
component: ProjectListItem,
title: 'vue_shared/components/project_selector/project_list_item',
title: 'vue_shared/project_selector/project_list_item',
};
const Template = (args, { argTypes }) => ({

View File

@ -2,7 +2,7 @@ import SettingsBlock from './settings_block.vue';
export default {
component: SettingsBlock,
title: 'vue_shared/components/settings/settings_block',
title: 'vue_shared/settings/settings_block',
};
const Template = (args, { argTypes }) => ({

View File

@ -4,7 +4,7 @@ import TodoButton from './todo_button.vue';
export default {
component: TodoButton,
title: 'vue_shared/components/sidebar/todo_toggle/todo_button',
title: 'vue_shared/sidebar/todo_toggle/todo_button',
};
const Template = (args, { argTypes }) => ({

View File

@ -5,7 +5,7 @@ const defaultWidth = '250px';
export default {
component: TooltipOnTruncate,
title: 'vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue',
title: 'vue_shared/tooltip_on_truncate/tooltip_on_truncate.vue',
};
const createStory = ({ ...options }) => {

View File

@ -5,7 +5,7 @@ import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue';
export default {
component: UserDeletionObstaclesList,
title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list',
title: 'vue_shared/user_deletion_obstacles/user_deletion_obstacles_list',
};
const Template = (args, { argTypes }) => ({

View File

@ -1,34 +1,17 @@
# frozen_string_literal: true
class Admin::HookLogsController < Admin::ApplicationController
include ::WebHooks::HookExecutionNotice
module Admin
class HookLogsController < Admin::ApplicationController
include WebHooks::HookLogActions
before_action :hook, only: [:show, :retry]
before_action :hook_log, only: [:show, :retry]
private
respond_to :html
def hook
@hook ||= SystemHook.find(params[:hook_id])
end
feature_category :integrations
urgency :low, [:retry]
def show
end
def retry
result = hook.execute(hook_log.request_data, hook_log.trigger)
set_hook_execution_notice(result)
redirect_to edit_admin_hook_path(@hook)
end
private
def hook
@hook ||= SystemHook.find(params[:hook_id])
end
def hook_log
@hook_log ||= hook.web_hook_logs.find(params[:id])
def after_retry_redirect_path
edit_admin_hook_path(hook)
end
end
end

View File

@ -6,6 +6,7 @@ class Admin::RunnersController < Admin::ApplicationController
before_action :runner, except: [:index, :tag_list, :runner_setup_scripts]
before_action only: [:index] do
push_frontend_feature_flag(:admin_runners_bulk_delete)
push_frontend_feature_flag(:runner_list_stacked_layout_admin)
end
feature_category :runner

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
module WebHooks
module HookLogActions
extend ActiveSupport::Concern
include HookExecutionNotice
included do
before_action :hook, only: [:show, :retry]
before_action :hook_log, only: [:show, :retry]
respond_to :html
feature_category :integrations
urgency :low, [:retry]
end
def show
end
def retry
execute_hook
redirect_to after_retry_redirect_path
end
private
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def hook_log
@hook_log ||= hook.web_hook_logs.find(params[:id])
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
def execute_hook
result = hook.execute(hook_log.request_data, hook_log.trigger)
set_hook_execution_notice(result)
end
end
end

View File

@ -4,6 +4,9 @@ class Groups::RunnersController < Groups::ApplicationController
before_action :authorize_read_group_runners!, only: [:index, :show]
before_action :authorize_admin_group_runners!, only: [:edit, :update, :destroy, :pause, :resume]
before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show]
before_action only: [:index] do
push_frontend_feature_flag(:runner_list_stacked_layout, @group)
end
feature_category :runner
urgency :low

View File

@ -1,40 +1,19 @@
# frozen_string_literal: true
class Projects::HookLogsController < Projects::ApplicationController
include ::WebHooks::HookExecutionNotice
before_action :authorize_admin_project!
before_action :hook, only: [:show, :retry]
before_action :hook_log, only: [:show, :retry]
respond_to :html
include WebHooks::HookLogActions
layout 'project_settings'
feature_category :integrations
urgency :low, [:retry]
def show
end
def retry
execute_hook
redirect_to edit_project_hook_path(@project, @hook)
end
private
def execute_hook
result = hook.execute(hook_log.request_data, hook_log.trigger)
set_hook_execution_notice(result)
end
def hook
@hook ||= @project.hooks.find(params[:hook_id])
end
def hook_log
@hook_log ||= hook.web_hook_logs.find(params[:id])
def after_retry_redirect_path
edit_project_hook_path(@project, hook)
end
end

View File

@ -7,13 +7,13 @@ module Projects
before_action :integration, only: [:show, :retry]
def retry
execute_hook
redirect_to edit_project_settings_integration_path(@project, @integration)
end
private
override :after_retry_redirect_path
def after_retry_redirect_path
edit_project_settings_integration_path(@project, @integration)
end
def integration
@integration ||= @project.find_or_initialize_integration(params[:integration_id])
end

View File

@ -57,6 +57,13 @@ class SearchController < ApplicationController
@search_highlight = @search_service.search_highlight
end
Gitlab::Metrics::GlobalSearchSlis.record_apdex(
elapsed: @global_search_duration_s,
search_type: @search_type,
search_level: @search_level,
search_scope: @scope
)
increment_search_counters
end

View File

@ -3,7 +3,7 @@
#fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'),
endpoint: new_project_fork_path(@project, format: :json),
new_group_path: new_group_path,
project_full_path: project_path(@project),
project_full_path: @project.full_path,
visibility_help_path: help_page_path("user/public_access"),
project_id: @project.id,
project_name: @project.name,

View File

@ -0,0 +1,8 @@
---
name: ci_variables_refactoring_to_variable
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95390
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/371559
milestone: '15.4'
type: development
group: group::pipeline authoring
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: global_search_custom_slis
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95182
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/372107
milestone: '15.4'
type: development
group: group::application performance
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: runner_list_stacked_layout
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95617
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/371031
milestone: '15.4'
type: development
group: group::runner
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: runner_list_stacked_layout_admin
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95617
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/371031
milestone: '15.4'
type: development
group: group::runner
default_enabled: false

View File

@ -40,6 +40,7 @@ if Gitlab::Metrics.enabled? && !Rails.env.test? && !(Rails.env.development? && d
if Gitlab::Runtime.puma?
Gitlab::Metrics::RequestsRackMiddleware.initialize_metrics
Gitlab::Metrics::GlobalSearchSlis.initialize_slis!
end
GC::Profiler.enable

View File

@ -232,7 +232,7 @@ In progress.
## Timeline
- 2021-01-21: Parent [CI Scaling](../ci_scale/) blueprint [merge request](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52203) created.
- 2021-01-21: Parent [CI Scaling](../ci_scale/index.md) blueprint [merge request](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52203) created.
- 2021-04-26: CI Scaling blueprint approved and merged.
- 2021-09-10: CI/CD data time decay blueprint discussions started.
- 2022-01-07: CI/CD data time decay blueprint [merged](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70052).

View File

@ -171,7 +171,7 @@ Work required to achieve our next CI/CD scaling target is tracked in the
1. ✓ Migrate primary keys to big integers on GitLab.com.
1. ✓ Implement the new architecture of builds queuing on GitLab.com.
1. [Make the new builds queuing architecture generally available](https://gitlab.com/groups/gitlab-org/-/epics/6954).
1. [Partition CI/CD data using time-decay pattern](../ci_data_decay/).
1. [Partition CI/CD data using time-decay pattern](../ci_data_decay/index.md).
## Status

View File

@ -146,43 +146,30 @@ See also the [Why CI/CD?](https://docs.google.com/presentation/d/1OGgk2Tcxbpl7DJ
As GitLab CI/CD has evolved, certain breaking changes have
been necessary.
#### 15.0 and later
Going forward, all breaking changes are documented on the following pages:
For GitLab 15.0 and later, all breaking changes are documented on the following pages:
- [Deprecations](../update/deprecations.md)
- [Removals](../update/removals.md)
#### 14.0
The breaking changes for [GitLab Runner](https://docs.gitlab.com/runner/) in earlier
major version releases are:
- No breaking changes.
#### 13.0
- [Remove Backported `os.Expand`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4915).
- [Remove Fedora 29 package support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/16158).
- [Remove macOS 32-bit support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/25466).
- [Removed `debug/jobs/list?v=1` endpoint](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6361).
- [Remove support for array of strings when defining services for Docker executor](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4922).
- [Remove `--docker-services` flag on register command](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6404).
- [Remove legacy build directory caching](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4180).
- [Remove `FF_USE_LEGACY_VOLUMES_MOUNTING_ORDER` feature flag](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6581).
- [Remove support for Windows Server 1803](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6553).
#### 12.0
- [Use `refspec` to clone/fetch Git repository](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4069).
- [Old cache configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4070).
- [Old metrics server configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4072).
- [Remove `FF_K8S_USE_ENTRYPOINT_OVER_COMMAND`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4073).
- [Remove Linux distributions that reach EOL](https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/1130).
- [Update command line API for helper images](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4013).
- [Remove old `git clean` flow](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4175).
#### 11.0
- No breaking changes.
#### 10.0
- No breaking changes.
- 14.0: No breaking changes.
- 13.0:
- [Remove Backported `os.Expand`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4915).
- [Remove Fedora 29 package support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/16158).
- [Remove macOS 32-bit support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/25466).
- [Removed `debug/jobs/list?v=1` endpoint](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6361).
- [Remove support for array of strings when defining services for Docker executor](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4922).
- [Remove `--docker-services` flag on register command](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6404).
- [Remove legacy build directory caching](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4180).
- [Remove `FF_USE_LEGACY_VOLUMES_MOUNTING_ORDER` feature flag](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6581).
- [Remove support for Windows Server 1803](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6553).
- 12.0:
- [Use `refspec` to clone/fetch Git repository](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4069).
- [Old cache configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4070).
- [Old metrics server configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4072).
- [Remove `FF_K8S_USE_ENTRYPOINT_OVER_COMMAND`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4073).
- [Remove Linux distributions that reach EOL](https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/1130).
- [Update command line API for helper images](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4013).
- [Remove old `git clean` flow](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4175).

View File

@ -67,7 +67,7 @@ Some changes affect more than one group. For example:
- Changes to [code review guidelines](code_review.md).
- Changes to [commit message guidelines](contributing/merge_request_workflow.md#commit-messages-guidelines).
- Changes to guidelines in [feature flags in development of GitLab](feature_flags/).
- Changes to guidelines in [feature flags in development of GitLab](feature_flags/index.md).
- Changes to [feature flags documentation guidelines](documentation/feature_flags.md).
In these cases, use the following workflow:

View File

@ -129,7 +129,7 @@ To deprecate an attribute:
```
To widely announce a deprecation, or if it's a breaking change,
[update the deprecations and removals documentation](../deprecation_guidelines/#update-the-deprecations-and-removals-documentation).
[update the deprecations and removals documentation](../deprecation_guidelines/index.md#update-the-deprecations-and-removals-documentation).
## Method description

View File

@ -11,7 +11,7 @@ This document lists the different implementations of CSV export in GitLab codeba
| Export type | How it works | Advantages | Disadvantages | Existing examples |
|---|---|---|---|---|
| Streaming | - Query and yield data in batches to a response stream.<br>- Download starts immediately. | - Report available immediately. | - No progress indicator.<br>- Requires a reliable connection. | [Export Audit Event Log](../administration/audit_events.md#export-to-csv) |
| Downloading | - Query and write data in batches to a temporary file.<br>- Loads the file into memory.<br>- Sends the file to the client. | - Report available immediately. | - Large amount of data might cause request timeout.<br>- Memory intensive.<br>- Request expires when user navigates to a different page. | - [Export Chain of Custody Report](../user/compliance/compliance_report/#chain-of-custody-report)<br>- [Export License Usage File](../subscriptions/self_managed/index.md#export-your-license-usage) |
| Downloading | - Query and write data in batches to a temporary file.<br>- Loads the file into memory.<br>- Sends the file to the client. | - Report available immediately. | - Large amount of data might cause request timeout.<br>- Memory intensive.<br>- Request expires when user navigates to a different page. | - [Export Chain of Custody Report](../user/compliance/compliance_report/index.md#chain-of-custody-report)<br>- [Export License Usage File](../subscriptions/self_managed/index.md#export-your-license-usage) |
| As email attachment | - Asynchronously process the query with background job.<br>- Email uses the export as an attachment. | - Asynchronous processing. | - Requires users use a different app (email) to download the CSV.<br>- Email providers may limit attachment size. | - [Export issues](../user/project/issues/csv_export.md)<br>- [Export merge requests](../user/project/merge_requests/csv_export.md) |
| As downloadable link in email (*) | - Asynchronously process the query with background job.<br>- Email uses an export link. | - Asynchronous processing.<br>- Bypasses email provider attachment size limit. | - Requires users use a different app (email).<br>- Requires additional storage and cleanup. | [Export User Permissions](https://gitlab.com/gitlab-org/gitlab/-/issues/1772) |
| Polling (non-persistent state) | - Asynchronously processes the query with the background job.<br>- Frontend(FE) polls every few seconds to check if CSV file is ready. | - Asynchronous processing.<br>- Automatically downloads to local machine on completion.<br>- In-app solution. | - Non-persistable request - request expires when user navigates to a different page.<br>- API is processed for each polling request. | [Export Vulnerabilities](../user/application_security/vulnerability_report/index.md#export-vulnerability-details) |

View File

@ -47,9 +47,9 @@ To add a story:
1. Write the story as per the [official Storybook instructions](https://storybook.js.org/docs/vue/writing-stories/introduction/)
NOTE:
Specify the `title` field of the story as the component's file path from the `javascripts/` directory.
Specify the `title` field of the story as the component's file path from the `javascripts/` directory, without the `/components` part.
For example, if the component is located at `app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue`,
specify the story `title` as `vue_shared/components/sidebar/todo_toggle/todo_button`.
specify the story `title` as `vue_shared/sidebar/todo_toggle/todo_button`.
If the component is located in the `ee/` directory, make sure to prefix the story's title with `ee/` as well.
This will ensure the Storybook navigation maps closely to our internal directory structure.

View File

@ -65,7 +65,7 @@ listed here that also do not work properly in FIPS mode:
- [Solutions for vulnerabilities](../user/application_security/vulnerabilities/index.md#resolve-a-vulnerability)
for yarn projects.
- [Static Application Security Testing (SAST)](../user/application_security/sast/index.md)
supports a reduced set of [analyzers](../user/application_security/sast/#fips-enabled-images)
supports a reduced set of [analyzers](../user/application_security/sast/index.md#fips-enabled-images)
when operating in FIPS-compliant mode.
- Advanced Search is currently not included in FIPS mode. It must not be enabled in order to be FIPS-compliant.
- [Gravatar or Libravatar-based profile images](../administration/libravatar.md) are not FIPS-compliant.

View File

@ -254,7 +254,7 @@ Following the POSIX exit code standard, the scanner exits with 0 for success and
Success also includes the case when vulnerabilities are found.
When a CI job fails, security report results are not ingested by GitLab, even if the job
[allows failure](../../ci/yaml/#allow_failure). The report artifacts are still uploaded to GitLab and available
[allows failure](../../ci/yaml/index.md#allow_failure). The report artifacts are still uploaded to GitLab and available
for [download in the pipeline security tab](../../user/application_security/vulnerability_report/pipeline.md#download-security-scan-outputs).
When executing a scanning job using the [Docker-in-Docker privileged mode](../../user/application_security/sast/index.md#requirements),

View File

@ -44,21 +44,21 @@ flowchart LR
### Scanning
The scanning part is responsible for finding vulnerabilities in given resources, and exporting results.
The scans are executed in CI/CD jobs via several small projects called [Analyzers](../../user/application_security/terminology/#analyzer), which can be found in our [Analyzers sub-group](https://gitlab.com/gitlab-org/security-products/analyzers).
The Analyzers are wrappers around security tools called [Scanners](../../user/application_security/terminology/#scanner), developed internally or externally, to integrate them into GitLab.
The scans are executed in CI/CD jobs via several small projects called [Analyzers](../../user/application_security/terminology/index.md#analyzer), which can be found in our [Analyzers sub-group](https://gitlab.com/gitlab-org/security-products/analyzers).
The Analyzers are wrappers around security tools called [Scanners](../../user/application_security/terminology/index.md#scanner), developed internally or externally, to integrate them into GitLab.
The Analyzers are mainly written in Go.
Some 3rd party integrators also make additional Scanners available by following our [integration documentation](../integrations/secure.md), which leverages the same architecture.
The results of the scans are exported as JSON reports that must comply with the [Secure report format](../../user/application_security/terminology/#secure-report-format) and are uploaded as [CI/CD Job Report artifacts](../../ci/pipelines/job_artifacts.md) to make them available for processing after the pipelines completes.
The results of the scans are exported as JSON reports that must comply with the [Secure report format](../../user/application_security/terminology/index.md#secure-report-format) and are uploaded as [CI/CD Job Report artifacts](../../ci/pipelines/job_artifacts.md) to make them available for processing after the pipelines completes.
### Processing, visualization, and management
After the data is available as a Report Artifact it can be processed by the GitLab Rails application to enable our security features, including:
- [Security Dashboards](../../user/application_security/security_dashboard/), Merge Request widget, Pipeline view, and so on.
- [Interactions with vulnerabilities](../../user/application_security/#interact-with-findings-and-vulnerabilities).
- [Approval rules](../../user/application_security/#security-approvals-in-merge-requests).
- [Security Dashboards](../../user/application_security/security_dashboard/index.md), Merge Request widget, Pipeline view, and so on.
- [Interactions with vulnerabilities](../../user/application_security/index.md#interact-with-findings-and-vulnerabilities).
- [Approval rules](../../user/application_security/index.md#security-approvals-in-merge-requests).
Depending on the context, the security reports may be stored either in the database or stay as Report Artifacts for on-demand access.

View File

@ -238,7 +238,7 @@ to the default branch (`main`).
NOTE:
For this tutorial, you merge your branch directly to the default branch for your
repository. In GitLab, you typically use a [merge request](../user/project/merge_requests/)
repository. In GitLab, you typically use a [merge request](../user/project/merge_requests/index.md)
to merge your branch.
### View your changes in GitLab

View File

@ -11,7 +11,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
You can set a limit so that users and processes can't request more than a certain number of pipelines each minute. This limit can help save resources and improve stability.
For example, if you set a limit of `10`, and `11` requests are sent to the [trigger API](../../../ci/triggers/) within one minute,
For example, if you set a limit of `10`, and `11` requests are sent to the [trigger API](../../../ci/triggers/index.md) within one minute,
the eleventh request is blocked. Access to the endpoint is allowed again after one minute.
This limit is:

View File

@ -68,7 +68,7 @@ information directly in the merge request.
| [Solutions for vulnerabilities (auto-remediation)](#solutions-for-vulnerabilities-auto-remediation) | No | Yes |
| Support for the [vulnerability allow list](#vulnerability-allowlisting) | No | Yes |
| [Access to Security Dashboard page](#security-dashboard) | No | Yes |
| [Access to Dependency List page](../dependency_list/) | No | Yes |
| [Access to Dependency List page](../dependency_list/index.md) | No | Yes |
## Requirements
@ -706,12 +706,12 @@ The results are stored in `gl-container-scanning-report.json`.
## Reports JSON format
The container scanning tool emits JSON reports which the [GitLab Runner](https://docs.gitlab.com/runner/)
recognizes through the [`artifacts:reports`](../../../ci/yaml/#artifactsreports)
recognizes through the [`artifacts:reports`](../../../ci/yaml/index.md#artifactsreports)
keyword in the CI configuration file.
Once the CI job finishes, the Runner uploads these reports to GitLab, which are then available in
the CI Job artifacts. In GitLab Ultimate, these reports can be viewed in the corresponding [pipeline](../vulnerability_report/pipeline.md)
and become part of the [Vulnerability Report](../vulnerability_report/).
and become part of the [Vulnerability Report](../vulnerability_report/index.md).
These reports must follow a format defined in the
[security report schemas](https://gitlab.com/gitlab-org/security-products/security-report-schemas/). See:

View File

@ -70,9 +70,9 @@ role.
Users granted:
- A higher role with Group Sync are displayed as having
[direct membership](../../project/members/#display-direct-members) of the group.
[direct membership](../../project/members/index.md#display-direct-members) of the group.
- A lower or the same role with Group Sync are displayed as having
[inherited membership](../../project/members/#display-inherited-members) of the group.
[inherited membership](../../project/members/index.md#display-inherited-members) of the group.
### Automatic member removal

View File

@ -220,7 +220,7 @@ It is important that this SCIM `id` and SCIM `externalId` are configured to the
### How do I verify user's SAML NameId matches the SCIM externalId
Admins can use the Admin Area to [list SCIM identities for a user](../../admin_area/#user-identities).
Admins can use the Admin Area to [list SCIM identities for a user](../../admin_area/index.md#user-identities).
Group owners can see the list of users and the `externalId` stored for each user in the group SAML SSO Settings page.

View File

@ -110,8 +110,8 @@ If you don't set `TF_STATE_NAME` or `TF_ADDRESS` in your job, the job fails with
To resolve this, ensure that either `TF_ADDRESS` or `TF_STATE_NAME` is accessible in the
job that returned the error:
1. Configure the [CI/CD environment scope](../../../ci/variables/#add-a-cicd-variable-to-a-project) for the job.
1. Set the job's [environment](../../../ci/yaml/#environment), matching the environment scope from the previous step.
1. Configure the [CI/CD environment scope](../../../ci/variables/index.md#add-a-cicd-variable-to-a-project) for the job.
1. Set the job's [environment](../../../ci/yaml/index.md#environment), matching the environment scope from the previous step.
### Error refreshing state: HTTP remote state endpoint requires auth

View File

@ -125,7 +125,7 @@ upgrading to [GitLab Premium or Ultimate](https://about.gitlab.com/upgrade/).
## Purchase additional data transfer
Read more about managing your [data transfer limits](../../../subscriptions/gitlab_com/#purchase-more-storage-and-transfer).
Read more about managing your [data transfer limits](../../../subscriptions/gitlab_com/index.md#purchase-more-storage-and-transfer).
## Related issues

View File

@ -119,7 +119,7 @@ The following table lists project permissions available for each role:
| [Merge requests](project/merge_requests/index.md):<br>Add labels | | | ✓ | ✓ | ✓ |
| [Merge requests](project/merge_requests/index.md):<br>Lock threads | | | ✓ | ✓ | ✓ |
| [Merge requests](project/merge_requests/index.md):<br>Manage or accept | | | ✓ | ✓ | ✓ |
| [Merge requests](project/merge_requests/index.md):<br>[Resolve a thread](discussions/#resolve-a-thread) | | | ✓ | ✓ | ✓ |
| [Merge requests](project/merge_requests/index.md):<br>[Resolve a thread](discussions/index.md#resolve-a-thread) | | | ✓ | ✓ | ✓ |
| [Merge requests](project/merge_requests/index.md):<br>Manage [merge approval rules](project/merge_requests/approvals/settings.md) (project settings) | | | | ✓ | ✓ |
| [Merge requests](project/merge_requests/index.md):<br>Delete | | | | | ✓ |
| [Metrics dashboards](../operations/metrics/dashboards/index.md):<br>Manage user-starred metrics dashboards (*6*) | ✓ | ✓ | ✓ | ✓ | ✓ |

View File

@ -85,7 +85,7 @@ Migrate the assets in this order:
Keep in mind the limitations of the [import/export feature](../settings/import_export.md#items-that-are-exported).
You must still migrate your [Container Registry](../../packages/container_registry/)
You must still migrate your [Container Registry](../../packages/container_registry/index.md)
over a series of Docker pulls and pushes. Re-run any CI pipelines to retrieve any build artifacts.
## Migrate from GitLab.com to self-managed GitLab

View File

@ -39,7 +39,7 @@ Complete these steps in GitLab:
1. Optional. Select **Test settings**.
1. Select **Save changes**.
After configuring the integration, see [Pipelines for external pull requests](../../../ci/ci_cd_for_external_repos/#pipelines-for-external-pull-requests)
After configuring the integration, see [Pipelines for external pull requests](../../../ci/ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests)
to configure pipelines to run for open pull requests.
### Static or dynamic status check names

View File

@ -170,7 +170,7 @@ include: # Execute individual project's configuration (if project contains .git
When used to enforce scan execution, this feature has some overlap with [scan execution policies](../../application_security/policies/scan-execution-policies.md),
as we have not [unified the user experience for these two features](https://gitlab.com/groups/gitlab-org/-/epics/7312).
For details on the similarities and differences between these features, see
[Enforce scan execution](../../application_security/#enforce-scan-execution).
[Enforce scan execution](../../application_security/index.md#enforce-scan-execution).
### Ensure compliance jobs are always run

View File

@ -57,7 +57,6 @@ module API
optional :only_allow_merge_if_all_discussions_are_resolved, type: Boolean, desc: 'Only allow to merge if all threads are resolved'
optional :tag_list, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, desc: 'Deprecated: Use :topics instead'
optional :topics, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, desc: 'The list of topics for a project'
# TODO: remove rubocop disable - https://gitlab.com/gitlab-org/gitlab/issues/14960
optional :avatar, type: ::API::Validations::Types::WorkhorseFile, desc: 'Avatar image for project'
optional :printing_merge_request_link_enabled, type: Boolean, desc: 'Show link to create/view merge request when pushing from the command line'
optional :merge_method, type: String, values: %w(ff rebase_merge merge), desc: 'The merge method used when merging merge requests'

View File

@ -65,6 +65,13 @@ module API
set_global_search_log_information
Gitlab::Metrics::GlobalSearchSlis.record_apdex(
elapsed: @search_duration_s,
search_type: search_type,
search_level: search_service.level,
search_scope: search_scope
)
Gitlab::UsageDataCounters::SearchCounter.count(:all_searches)
paginate(@results)

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Entry that represents CI/CD variables.
# The class will be renamed to `Variables` when removing the FF `ci_variables_refactoring_to_variable`.
#
class CurrentVariables < ::Gitlab::Config::Entry::ComposableHash
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, type: Hash
end
# Enable these lines when removing the FF `ci_variables_refactoring_to_variable`
# and renaming this class to `Variables`.
# def self.default(**)
# {}
# end
def value
@entries.to_h do |key, entry|
[key.to_s, entry.value]
end
end
def value_with_data
@entries.to_h do |key, entry|
[key.to_s, entry.value_with_data]
end
end
private
def composable_class(_name, _config)
Entry::Variable
end
def composable_metadata
{ allowed_value_data: opt(:allowed_value_data) }
end
end
end
end
end
end

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Entry that represents environment variables.
# This is legacy implementation and will be removed with the FF `ci_variables_refactoring_to_variable`.
#
class LegacyVariables < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
ALLOWED_VALUE_DATA = %i[value description].freeze
validations do
validates :config, variables: { allowed_value_data: ALLOWED_VALUE_DATA }, if: :use_value_data?
validates :config, variables: true, unless: :use_value_data?
end
def value
@config.to_h { |key, value| [key.to_s, expand_value(value)[:value]] }
end
def value_with_data
@config.to_h { |key, value| [key.to_s, expand_value(value)] }
end
def use_value_data?
opt(:use_value_data)
end
private
def expand_value(value)
if value.is_a?(Hash)
{ value: value[:value].to_s, description: value[:description] }
else
{ value: value.to_s, description: nil }
end
end
end
end
end
end
end

View File

@ -48,9 +48,10 @@ module Gitlab
description: 'Script that will be executed after each job.',
reserved: true
# use_value_data will be removed with the FF ci_variables_refactoring_to_variable
entry :variables, Entry::Variables,
description: 'Environment variables that will be used.',
metadata: { use_value_data: true },
metadata: { use_value_data: true, allowed_value_data: %i[value description] },
reserved: true
entry :stages, Entry::Stages,

View File

@ -0,0 +1,98 @@
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Entry that represents a CI/CD variable.
#
class Variable < ::Gitlab::Config::Entry::Simplifiable
strategy :SimpleVariable, if: -> (config) { SimpleVariable.applies_to?(config) }
strategy :ComplexVariable, if: -> (config) { ComplexVariable.applies_to?(config) }
class SimpleVariable < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
class << self
def applies_to?(config)
Gitlab::Config::Entry::Validators::AlphanumericValidator.validate(config)
end
end
validations do
validates :key, alphanumeric: true
validates :config, alphanumeric: true
end
def value
@config.to_s
end
def value_with_data
{ value: @config.to_s, description: nil }
end
end
class ComplexVariable < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
class << self
def applies_to?(config)
config.is_a?(Hash)
end
end
validations do
validates :key, alphanumeric: true
validates :config_value, alphanumeric: true, allow_nil: false, if: :config_value_defined?
validates :config_description, alphanumeric: true, allow_nil: false, if: :config_description_defined?
validate do
allowed_value_data = Array(opt(:allowed_value_data))
if allowed_value_data.any?
extra_keys = config.keys - allowed_value_data
errors.add(:config, "uses invalid data keys: #{extra_keys.join(', ')}") if extra_keys.present?
else
errors.add(:config, "must be a string")
end
end
end
def value
config_value.to_s
end
def value_with_data
{ value: value, description: config_description }
end
def config_value
@config[:value]
end
def config_description
@config[:description]
end
def config_value_defined?
config.key?(:value)
end
def config_description_defined?
config.key?(:description)
end
end
class UnknownStrategy < ::Gitlab::Config::Entry::Node
def errors
["variable definition must be either a string or a hash"]
end
end
end
end
end
end
end

View File

@ -5,43 +5,21 @@ module Gitlab
class Config
module Entry
##
# Entry that represents environment variables.
# Entry that represents CI/CD variables.
# CurrentVariables will be renamed to this class when removing the FF `ci_variables_refactoring_to_variable`.
#
class Variables < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
ALLOWED_VALUE_DATA = %i[value description].freeze
validations do
validates :config, variables: { allowed_value_data: ALLOWED_VALUE_DATA }, if: :use_value_data?
validates :config, variables: true, unless: :use_value_data?
end
def value
@config.to_h { |key, value| [key.to_s, expand_value(value)[:value]] }
class Variables
def self.new(...)
if YamlProcessor::FeatureFlags.enabled?(:ci_variables_refactoring_to_variable)
CurrentVariables.new(...)
else
LegacyVariables.new(...)
end
end
def self.default(**)
{}
end
def value_with_data
@config.to_h { |key, value| [key.to_s, expand_value(value)] }
end
def use_value_data?
opt(:use_value_data)
end
private
def expand_value(value)
if value.is_a?(Hash)
{ value: value[:value].to_s, description: value[:description] }
else
{ value: value.to_s, description: nil }
end
end
end
end
end

View File

@ -25,9 +25,9 @@ module Gitlab
entry_class_name = entry_class.name.demodulize.underscore
factory = ::Gitlab::Config::Entry::Factory.new(entry_class)
.value(config || {})
.value(config.nil? ? {} : config)
.with(key: name, parent: self, description: "#{name} #{entry_class_name} definition") # rubocop:disable CodeReuse/ActiveRecord
.metadata(name: name)
.metadata(composable_metadata.merge(name: name))
@entries[name] = factory.create!
end
@ -38,9 +38,15 @@ module Gitlab
end
end
private
def composable_class(name, config)
opt(:composable_class)
end
def composable_metadata
{}
end
end
end
end

View File

@ -304,6 +304,7 @@ module Gitlab
end
end
# This will be removed with the FF `ci_variables_refactoring_to_variable`.
class VariablesValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
@ -336,6 +337,18 @@ module Gitlab
end
end
class AlphanumericValidator < ActiveModel::EachValidator
def self.validate(value)
value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer)
end
def validate_each(record, attribute, value)
unless self.class.validate(value)
record.errors.add(attribute, 'must be an alphanumeric string')
end
end
end
class ExpressionValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.is_a?(String) && ::Gitlab::Ci::Pipeline::Expression::Statement.new(value).valid?

View File

@ -0,0 +1,101 @@
# frozen_string_literal: true
module Gitlab
module Metrics
module GlobalSearchSlis
class << self
# The following targets are the 99.95th percentile of code searches
# gathered on 24-08-2022
# from https://log.gprd.gitlab.net/goto/0c89cd80-23af-11ed-8656-f5f2137823ba (internal only)
BASIC_CONTENT_TARGET_S = 7.031
BASIC_CODE_TARGET_S = 21.903
ADVANCED_CONTENT_TARGET_S = 4.865
ADVANCED_CODE_TARGET_S = 13.546
def initialize_slis!
return unless Feature.enabled?(:global_search_custom_slis)
Gitlab::Metrics::Sli::Apdex.initialize_sli(:global_search, possible_labels)
end
def record_apdex(elapsed:, search_type:, search_level:, search_scope:)
return unless Feature.enabled?(:global_search_custom_slis)
Gitlab::Metrics::Sli::Apdex[:global_search].increment(
labels: labels(search_type: search_type, search_level: search_level, search_scope: search_scope),
success: elapsed < duration_target(search_type, search_scope)
)
end
private
def duration_target(search_type, search_scope)
if search_type == 'basic' && content_search?(search_scope)
BASIC_CONTENT_TARGET_S
elsif search_type == 'basic' && code_search?(search_scope)
BASIC_CODE_TARGET_S
elsif search_type == 'advanced' && content_search?(search_scope)
ADVANCED_CONTENT_TARGET_S
elsif search_type == 'advanced' && code_search?(search_scope)
ADVANCED_CODE_TARGET_S
end
end
def search_types
%w[basic advanced]
end
def search_levels
%w[project group global]
end
def search_scopes
Gitlab::Search::AbuseDetection::ALLOWED_SCOPES
end
def endpoint_ids
['SearchController#show', 'GET /api/:version/search', 'GET /api/:version/projects/:id/(-/)search',
'GET /api/:version/groups/:id/(-/)search']
end
def possible_labels
search_types.flat_map do |search_type|
search_levels.flat_map do |search_level|
search_scopes.flat_map do |search_scope|
endpoint_ids.flat_map do |endpoint_id|
{
search_type: search_type,
search_level: search_level,
search_scope: search_scope,
endpoint_id: endpoint_id
}
end
end
end
end
end
def labels(search_type:, search_level:, search_scope:)
{
search_type: search_type,
search_level: search_level,
search_scope: search_scope,
endpoint_id: endpoint_id
}
end
def endpoint_id
::Gitlab::ApplicationContext.current_context_attribute(:caller_id)
end
def code_search?(search_scope)
search_scope == 'blobs'
end
def content_search?(search_scope)
!code_search?(search_scope)
end
end
end
end
end

View File

@ -6392,6 +6392,9 @@ msgstr ""
msgid "Blocking"
msgstr ""
msgid "Blocking epics"
msgstr ""
msgid "Blocking issues"
msgstr ""
@ -16806,6 +16809,9 @@ msgstr ""
msgid "ForkProject|Select a namespace"
msgstr ""
msgid "ForkProject|Something went wrong while loading data. Please refresh the page to try again."
msgstr ""
msgid "ForkProject|The project can be accessed by any logged in user."
msgstr ""
@ -33811,6 +33817,12 @@ msgstr ""
msgid "Runners|An error has occurred fetching instructions"
msgstr ""
msgid "Runners|An upgrade is available for this runner"
msgstr ""
msgid "Runners|An upgrade is recommended for this runner"
msgstr ""
msgid "Runners|Architecture"
msgstr ""
@ -33859,6 +33871,9 @@ msgstr ""
msgid "Runners|Copy registration token"
msgstr ""
msgid "Runners|Created %{timeAgo}"
msgstr ""
msgid "Runners|Delete %d runner"
msgid_plural "Runners|Delete %d runners"
msgstr[0] ""
@ -33930,6 +33945,9 @@ msgstr ""
msgid "Runners|Last contact"
msgstr ""
msgid "Runners|Last contact: %{timeAgo}"
msgstr ""
msgid "Runners|Locked to this project"
msgstr ""
@ -34213,6 +34231,9 @@ msgstr ""
msgid "Runners|Version"
msgstr ""
msgid "Runners|Version %{version}"
msgstr ""
msgid "Runners|View installation instructions"
msgstr ""

View File

@ -6,19 +6,40 @@ module QA
module Fork
class New < Page::Base
view 'app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue' do
element :fork_namespace_dropdown
element :fork_project_button
element :fork_privacy_button
end
view 'app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue' do
element :select_namespace_dropdown
element :select_namespace_dropdown_item
element :select_namespace_dropdown_search_field
element :select_namespace_dropdown_item
end
def fork_project(namespace = Runtime::Namespace.path)
select_element(:fork_namespace_dropdown, namespace)
choose_namespace(namespace)
click_element(:fork_privacy_button, privacy_level: 'public')
click_element(:fork_project_button)
end
def fork_namespace_dropdown_values
find_element(:fork_namespace_dropdown).all(:option).map { |option| option.text.tr("\n", '').strip }
def get_list_of_namespaces
click_element(:select_namespace_dropdown)
wait_until(reload: false) do
has_element?(:select_namespace_dropdown_item)
end
all_elements(:select_namespace_dropdown_item, minimum: 1).map(&:text)
end
def choose_namespace(namespace)
retry_on_exception do
click_element(:select_namespace_dropdown)
fill_element(:select_namespace_dropdown_search_field, namespace)
wait_until(reload: false) do
has_element?(:select_namespace_dropdown_item, text: namespace)
end
click_button(namespace)
end
end
end
end

View File

@ -36,7 +36,7 @@ module QA
def fabricate!
populate(:upstream, :user)
namespace_path ||= user.name
namespace_path ||= user.username
# Sign out as admin and sign is as the fork user
Flow::Login.sign_in(as: user)

View File

@ -270,6 +270,17 @@ RSpec.describe SearchController do
get(:show, params: { search: 'foo@bar.com', scope: 'users' })
end
end
it 'increments the custom search sli apdex' do
expect(Gitlab::Metrics::GlobalSearchSlis).to receive(:record_apdex).with(
elapsed: a_kind_of(Numeric),
search_scope: 'issues',
search_type: 'basic',
search_level: 'global'
)
get :show, params: { scope: 'issues', search: 'hello world' }
end
end
describe 'GET #count', :aggregate_failures do

View File

@ -81,7 +81,7 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
within_runner_row(runner.id) do
expect(find("[data-label='Jobs']")).to have_content '2'
expect(find("[data-testid='job-count']")).to have_content '2'
end
end

View File

@ -126,7 +126,10 @@ RSpec.describe 'Project fork' do
let(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user }
def submit_form
select(group.name)
find('[data-testid="select_namespace_dropdown"]').click
find('[data-testid="select_namespace_dropdown_search_field"]').fill_in(with: group.name)
click_button group.name
click_button 'Fork project'
end

View File

@ -10,13 +10,17 @@ import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
import { truncate } from '~/lib/utils/text_utility';
import {
mockIssue,
mockEpic,
mockBlockingIssue1,
mockBlockingIssue2,
mockBlockingEpic1,
mockBlockingIssuablesResponse1,
mockBlockingIssuablesResponse2,
mockBlockingIssuablesResponse3,
mockBlockedIssue1,
mockBlockedIssue2,
mockBlockedEpic1,
mockBlockingEpicIssuablesResponse1,
} from '../mock_data';
describe('BoardBlockedIcon', () => {
@ -51,9 +55,11 @@ describe('BoardBlockedIcon', () => {
const createWrapperWithApollo = ({
item = mockBlockedIssue1,
blockingIssuablesSpy = jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1),
issuableItem = mockIssue,
issuableType = issuableTypes.issue,
} = {}) => {
mockApollo = createMockApollo([
[blockingIssuablesQueries[issuableTypes.issue].query, blockingIssuablesSpy],
[blockingIssuablesQueries[issuableType].query, blockingIssuablesSpy],
]);
Vue.use(VueApollo);
@ -62,27 +68,34 @@ describe('BoardBlockedIcon', () => {
apolloProvider: mockApollo,
propsData: {
item: {
...mockIssue,
...issuableItem,
...item,
},
uniqueId: 'uniqueId',
issuableType: issuableTypes.issue,
issuableType,
},
attachTo: document.body,
}),
);
};
const createWrapper = ({ item = {}, queries = {}, data = {}, loading = false } = {}) => {
const createWrapper = ({
item = {},
queries = {},
data = {},
loading = false,
mockIssuable = mockIssue,
issuableType = issuableTypes.issue,
} = {}) => {
wrapper = extendedWrapper(
shallowMount(BoardBlockedIcon, {
propsData: {
item: {
...mockIssue,
...mockIssuable,
...item,
},
uniqueId: 'uniqueid',
issuableType: issuableTypes.issue,
issuableType,
},
data() {
return {
@ -105,11 +118,24 @@ describe('BoardBlockedIcon', () => {
);
};
it('should render blocked icon', () => {
createWrapper();
it.each`
mockIssuable | issuableType | expectedIcon
${mockIssue} | ${issuableTypes.issue} | ${'issue-block'}
${mockEpic} | ${issuableTypes.epic} | ${'entity-blocked'}
`(
'should render blocked icon for $issuableType',
({ mockIssuable, issuableType, expectedIcon }) => {
createWrapper({
mockIssuable,
issuableType,
});
expect(findGlIcon().exists()).toBe(true);
});
expect(findGlIcon().exists()).toBe(true);
const icon = findGlIcon();
expect(icon.exists()).toBe(true);
expect(icon.props('name')).toBe(expectedIcon);
},
);
it('should display a loading spinner while loading', () => {
createWrapper({ loading: true });
@ -124,17 +150,29 @@ describe('BoardBlockedIcon', () => {
});
describe('on mouseenter on blocked icon', () => {
it('should query for blocking issuables and render the result', async () => {
createWrapperWithApollo();
it.each`
item | issuableType | mockBlockingIssuable | issuableItem | blockingIssuablesSpy
${mockBlockedIssue1} | ${issuableTypes.issue} | ${mockBlockingIssue1} | ${mockIssue} | ${jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1)}
${mockBlockedEpic1} | ${issuableTypes.epic} | ${mockBlockingEpic1} | ${mockEpic} | ${jest.fn().mockResolvedValue(mockBlockingEpicIssuablesResponse1)}
`(
'should query for blocking issuables and render the result for $issuableType',
async ({ item, issuableType, issuableItem, mockBlockingIssuable, blockingIssuablesSpy }) => {
createWrapperWithApollo({
item,
issuableType,
issuableItem,
blockingIssuablesSpy,
});
expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title);
expect(findGlPopover().text()).not.toContain(mockBlockingIssuable.title);
await mouseenter();
await mouseenter();
expect(findGlPopover().exists()).toBe(true);
expect(findIssuableTitle().text()).toContain(mockBlockingIssue1.title);
expect(wrapper.vm.skip).toBe(true);
});
expect(findGlPopover().exists()).toBe(true);
expect(findIssuableTitle().text()).toContain(mockBlockingIssuable.title);
expect(wrapper.vm.skip).toBe(true);
},
);
it('should emit "blocking-issuables-error" event on query error', async () => {
const mockError = new Error('mayday');

View File

@ -266,6 +266,7 @@ export const rawIssue = {
};
export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test';
export const mockEpicFullPath = 'gitlab-org/test-subgroup';
export const mockIssue = {
id: 'gid://gitlab/Issue/436',
@ -291,6 +292,47 @@ export const mockIssue = {
type: 'ISSUE',
};
export const mockEpic = {
id: 'gid://gitlab/Epic/26',
iid: '1',
group: {
id: 'gid://gitlab/Group/33',
fullPath: 'twitter',
__typename: 'Group',
},
title: 'Eum animi debitis occaecati ad non odio repellat voluptatem similique.',
state: 'opened',
reference: '&1',
referencePath: `${mockEpicFullPath}&1`,
webPath: `/groups/${mockEpicFullPath}/-/epics/1`,
webUrl: `${mockEpicFullPath}/-/epics/1`,
createdAt: '2022-01-18T05:15:15Z',
closedAt: null,
__typename: 'Epic',
relativePosition: null,
confidential: false,
subscribed: true,
blocked: true,
blockedByCount: 1,
labels: {
nodes: [],
__typename: 'LabelConnection',
},
hasIssues: true,
descendantCounts: {
closedEpics: 0,
closedIssues: 0,
openedEpics: 0,
openedIssues: 2,
__typename: 'EpicDescendantCount',
},
descendantWeightSum: {
closedIssues: 0,
openedIssues: 0,
__typename: 'EpicDescendantWeights',
},
};
export const mockActiveIssue = {
...mockIssue,
id: 'gid://gitlab/Issue/436',
@ -523,6 +565,15 @@ export const mockBlockingIssue1 = {
__typename: 'Issue',
};
export const mockBlockingEpic1 = {
id: 'gid://gitlab/Epic/29',
iid: '4',
title: 'Sint nihil exercitationem aspernatur unde molestiae rem accusantium.',
reference: 'twitter&4',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/test-subgroup/-/epics/4',
__typename: 'Epic',
};
export const mockBlockingIssue2 = {
id: 'gid://gitlab/Issue/524',
iid: '5',
@ -564,6 +615,23 @@ export const mockBlockingIssuablesResponse1 = {
},
};
export const mockBlockingEpicIssuablesResponse1 = {
data: {
group: {
__typename: 'Group',
id: 'gid://gitlab/Group/33',
issuable: {
__typename: 'Epic',
id: 'gid://gitlab/Epic/26',
blockingIssuables: {
__typename: 'EpicConnection',
nodes: [mockBlockingEpic1],
},
},
},
},
};
export const mockBlockingIssuablesResponse2 = {
data: {
issuable: {
@ -601,6 +669,12 @@ export const mockBlockedIssue2 = {
webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0',
};
export const mockBlockedEpic1 = {
id: '26',
blockedByCount: 1,
webUrl: 'http://gdk.test:3000/gitlab-org/test-subgroup/-/epics/1',
};
export const mockMoveIssueParams = {
itemId: 1,
fromListId: 'gid://gitlab/List/1',

View File

@ -4,11 +4,14 @@ import { mount, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
import { kebabCase } from 'lodash';
import { nextTick } from 'vue';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createFlash from '~/flash';
import httpStatus from '~/lib/utils/http_status';
import * as urlUtility from '~/lib/utils/url_utility';
import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql';
import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue';
jest.mock('~/flash');
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
@ -16,6 +19,7 @@ jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
describe('ForkForm component', () => {
let wrapper;
let axiosMock;
let mockQueryResponse;
const PROJECT_VISIBILITY_TYPE = {
private:
@ -24,26 +28,11 @@ describe('ForkForm component', () => {
public: 'Public The project can be accessed without any authentication.',
};
const GON_GITLAB_URL = 'https://gitlab.com';
const GON_API_VERSION = 'v7';
const MOCK_NAMESPACES_RESPONSE = [
{
name: 'one',
full_name: 'one-group/one',
id: 1,
},
{
name: 'two',
full_name: 'two-group/two',
id: 2,
},
];
const DEFAULT_PROVIDE = {
newGroupPath: 'some/groups/path',
visibilityHelpPath: 'some/visibility/help/path',
endpoint: '/some/project-full-path/-/forks/new.json',
projectFullPath: '/some/project-full-path',
projectId: '10',
projectName: 'Project Name',
@ -53,12 +42,44 @@ describe('ForkForm component', () => {
restrictedVisibilityLevels: [],
};
const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => {
axiosMock.onGet(DEFAULT_PROVIDE.endpoint).replyOnce(statusCode, data);
};
Vue.use(VueApollo);
const createComponentFactory = (mountFn) => (provide = {}, data = {}) => {
const queryResponse = {
project: {
id: 'gid://gitlab/Project/1',
forkTargets: {
nodes: [
{
id: 'gid://gitlab/Group/21',
fullPath: 'flightjs',
name: 'Flight JS',
visibility: 'public',
},
{
id: 'gid://gitlab/Namespace/4',
fullPath: 'root',
name: 'Administrator',
visibility: 'public',
},
],
},
},
};
mockQueryResponse = jest.fn().mockResolvedValue({ data: queryResponse });
const requestHandlers = [[searchQuery, mockQueryResponse]];
const apolloProvider = createMockApollo(requestHandlers);
apolloProvider.clients.defaultClient.cache.writeQuery({
query: searchQuery,
data: {
...queryResponse,
},
});
wrapper = mountFn(ForkForm, {
apolloProvider,
provide: {
...DEFAULT_PROVIDE,
...provide,
@ -83,7 +104,6 @@ describe('ForkForm component', () => {
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
window.gon = {
gitlab_url: GON_GITLAB_URL,
api_version: GON_API_VERSION,
};
});
@ -93,12 +113,11 @@ describe('ForkForm component', () => {
axiosMock.restore();
});
const findFormSelectOptions = () => wrapper.find('select[name="namespace"]').findAll('option');
const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]');
const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]');
const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]');
const findForkNameInput = () => wrapper.find('[data-testid="fork-name-input"]');
const findForkUrlInput = () => wrapper.find('[data-testid="fork-url-input"]');
const findForkUrlInput = () => wrapper.findComponent(ProjectNamespace);
const findForkSlugInput = () => wrapper.find('[data-testid="fork-slug-input"]');
const findForkDescriptionTextarea = () =>
wrapper.find('[data-testid="fork-description-textarea"]');
@ -106,7 +125,6 @@ describe('ForkForm component', () => {
wrapper.find('[data-testid="fork-visibility-radio-group"]');
it('will go to projectFullPath when click cancel button', () => {
mockGetRequest();
createComponent();
const { projectFullPath } = DEFAULT_PROVIDE;
@ -115,8 +133,13 @@ describe('ForkForm component', () => {
expect(cancelButton.attributes('href')).toBe(projectFullPath);
});
const selectedMockNamespace = { name: 'two', full_name: 'two-group/two', id: 2 };
const fillForm = () => {
findForkUrlInput().vm.$emit('select', selectedMockNamespace);
};
it('has input with csrf token', () => {
mockGetRequest();
createComponent();
expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe(
@ -125,7 +148,6 @@ describe('ForkForm component', () => {
});
it('pre-populate form from project props', () => {
mockGetRequest();
createComponent();
expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROVIDE.projectName);
@ -135,75 +157,19 @@ describe('ForkForm component', () => {
);
});
it('sets project URL prepend text with gon.gitlab_url', () => {
mockGetRequest();
createComponent();
expect(wrapper.find(GlFormInputGroup).text()).toContain(`${GON_GITLAB_URL}/`);
});
it('will have required attribute for required fields', () => {
mockGetRequest();
createComponent();
expect(findForkNameInput().attributes('required')).not.toBeUndefined();
expect(findForkUrlInput().attributes('required')).not.toBeUndefined();
expect(findForkSlugInput().attributes('required')).not.toBeUndefined();
expect(findVisibilityRadioGroup().attributes('required')).not.toBeUndefined();
expect(findForkDescriptionTextarea().attributes('required')).toBeUndefined();
});
describe('forks namespaces', () => {
beforeEach(() => {
mockGetRequest({ namespaces: MOCK_NAMESPACES_RESPONSE });
createFullComponent();
});
it('make GET request from endpoint', async () => {
await axios.waitForAll();
expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROVIDE.endpoint);
});
it('generate default option', async () => {
await axios.waitForAll();
const optionsArray = findForkUrlInput().findAll('option');
expect(optionsArray.at(0).text()).toBe('Select a namespace');
});
it('populate project url namespace options', async () => {
await axios.waitForAll();
const optionsArray = findForkUrlInput().findAll('option');
expect(optionsArray).toHaveLength(MOCK_NAMESPACES_RESPONSE.length + 1);
expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].full_name);
expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].full_name);
});
it('set namespaces in alphabetical order', async () => {
const namespace = {
name: 'three',
full_name: 'aaa/three',
id: 3,
};
mockGetRequest({
namespaces: [...MOCK_NAMESPACES_RESPONSE, namespace],
});
createComponent();
await axios.waitForAll();
expect(wrapper.vm.namespaces).toEqual([namespace, ...MOCK_NAMESPACES_RESPONSE]);
});
});
describe('project slug', () => {
const projectPath = 'some other project slug';
beforeEach(() => {
mockGetRequest();
createComponent({
projectPath,
});
@ -232,7 +198,6 @@ describe('ForkForm component', () => {
describe('visibility level', () => {
it('displays the correct description', () => {
mockGetRequest();
createComponent();
const formRadios = wrapper.findAll(GlFormRadio);
@ -243,7 +208,6 @@ describe('ForkForm component', () => {
});
it('displays all 3 visibility levels', () => {
mockGetRequest();
createComponent();
expect(wrapper.findAll(GlFormRadio)).toHaveLength(3);
@ -262,16 +226,12 @@ describe('ForkForm component', () => {
},
];
beforeEach(() => {
mockGetRequest();
});
it('resets the visibility to default "private"', async () => {
createFullComponent({ projectVisibility: 'public' }, { namespaces });
expect(wrapper.vm.form.fields.visibility.value).toBe('public');
await findFormSelectOptions().at(1).setSelected();
fillForm();
await nextTick();
expect(getByRole(wrapper.element, 'radio', { name: /private/i }).checked).toBe(true);
@ -280,8 +240,7 @@ describe('ForkForm component', () => {
it('sets the visibility to be null when restrictedVisibilityLevels is set', async () => {
createFullComponent({ restrictedVisibilityLevels: [10] }, { namespaces });
await findFormSelectOptions().at(1).setSelected();
fillForm();
await nextTick();
const container = getByRole(wrapper.element, 'radiogroup', { name: /visibility/i });
@ -315,8 +274,7 @@ describe('ForkForm component', () => {
${'public'} | ${[0, 20]}
${'public'} | ${[10, 20]}
${'public'} | ${[0, 10, 20]}
`('checks the correct radio button', async ({ project, restrictedVisibilityLevels }) => {
mockGetRequest();
`('checks the correct radio button', ({ project, restrictedVisibilityLevels }) => {
createFullComponent({
projectVisibility: project,
restrictedVisibilityLevels,
@ -357,7 +315,7 @@ describe('ForkForm component', () => {
${'public'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} | ${[0, 10, 20]}
`(
'sets appropriate radio button disabled state',
async ({
({
project,
namespace,
privateIsDisabled,
@ -365,7 +323,6 @@ describe('ForkForm component', () => {
publicIsDisabled,
restrictedVisibilityLevels,
}) => {
mockGetRequest();
createComponent(
{
projectVisibility: project,
@ -387,11 +344,9 @@ describe('ForkForm component', () => {
const setupComponent = (fields = {}) => {
jest.spyOn(urlUtility, 'redirectTo').mockImplementation();
mockGetRequest();
createFullComponent(
{},
{
namespaces: MOCK_NAMESPACES_RESPONSE,
form: {
state: true,
...fields,
@ -400,17 +355,13 @@ describe('ForkForm component', () => {
);
};
const selectedMockNamespaceIndex = 1;
const namespaceId = MOCK_NAMESPACES_RESPONSE[selectedMockNamespaceIndex].id;
const fillForm = async () => {
const namespaceOptions = findForkUrlInput().findAll('option');
await namespaceOptions.at(selectedMockNamespaceIndex + 1).setSelected();
};
beforeEach(() => {
setupComponent();
});
const submitForm = async () => {
await fillForm();
fillForm();
await nextTick();
const form = wrapper.find(GlForm);
await form.trigger('submit');
@ -418,7 +369,7 @@ describe('ForkForm component', () => {
};
describe('with invalid form', () => {
it('does not make POST request', async () => {
it('does not make POST request', () => {
jest.spyOn(axios, 'post');
setupComponent();
@ -471,7 +422,7 @@ describe('ForkForm component', () => {
description: projectDescription,
id: projectId,
name: projectName,
namespace_id: namespaceId,
namespace_id: selectedMockNamespace.id,
path: projectPath,
visibility: projectVisibility,
};

View File

@ -0,0 +1,177 @@
import {
GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlTruncate,
} from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import createFlash from '~/flash';
import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql';
import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue';
jest.mock('~/flash');
describe('ProjectNamespace component', () => {
let wrapper;
let originalGon;
const data = {
project: {
__typename: 'Project',
id: 'gid://gitlab/Project/1',
forkTargets: {
nodes: [
{
id: 'gid://gitlab/Group/21',
fullPath: 'flightjs',
name: 'Flight JS',
visibility: 'public',
},
{
id: 'gid://gitlab/Namespace/4',
fullPath: 'root',
name: 'Administrator',
visibility: 'public',
},
],
},
},
};
const mockQueryResponse = jest.fn().mockResolvedValue({ data });
const emptyQueryResponse = {
project: {
__typename: 'Project',
id: 'gid://gitlab/Project/1',
forkTargets: {
nodes: [],
},
},
};
const mockQueryError = jest.fn().mockRejectedValue(new Error('Network error'));
Vue.use(VueApollo);
const gitlabUrl = 'https://gitlab.com';
const defaultProvide = {
projectFullPath: 'gitlab-org/project',
};
const mountComponent = ({
provide = defaultProvide,
queryHandler = mockQueryResponse,
mountFn = shallowMount,
} = {}) => {
const requestHandlers = [[searchQuery, queryHandler]];
const apolloProvider = createMockApollo(requestHandlers);
wrapper = mountFn(ProjectNamespace, {
apolloProvider,
provide,
});
};
const findButtonLabel = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownText = () => wrapper.findComponent(GlTruncate);
const findInput = () => wrapper.findComponent(GlSearchBoxByType);
const clickDropdownItem = async () => {
wrapper.findComponent(GlDropdownItem).vm.$emit('click');
await nextTick();
};
const showDropdown = () => {
findDropdown().vm.$emit('shown');
};
beforeAll(() => {
originalGon = window.gon;
window.gon = { gitlab_url: gitlabUrl };
});
afterAll(() => {
window.gon = originalGon;
wrapper.destroy();
});
describe('Initial state', () => {
beforeEach(() => {
mountComponent({ mountFn: mount });
jest.runOnlyPendingTimers();
});
it('renders the root url as a label', () => {
expect(findButtonLabel().text()).toBe(`${gitlabUrl}/`);
expect(findButtonLabel().props('label')).toBe(true);
});
it('renders placeholder text', () => {
expect(findDropdownText().props('text')).toBe('Select a namespace');
});
});
describe('After user interactions', () => {
beforeEach(async () => {
mountComponent({ mountFn: mount });
jest.runOnlyPendingTimers();
await nextTick();
showDropdown();
});
it('focuses on the input when the dropdown is opened', () => {
const spy = jest.spyOn(findInput().vm, 'focusInput');
showDropdown();
expect(spy).toHaveBeenCalledTimes(1);
});
it('displays fetched namespaces', () => {
const listItems = wrapper.findAll('li');
expect(listItems).toHaveLength(3);
expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Namespaces');
expect(listItems.at(1).text()).toBe(data.project.forkTargets.nodes[0].fullPath);
expect(listItems.at(2).text()).toBe(data.project.forkTargets.nodes[1].fullPath);
});
it('sets the selected namespace', async () => {
const { fullPath } = data.project.forkTargets.nodes[0];
await clickDropdownItem();
expect(findDropdownText().props('text')).toBe(fullPath);
});
});
describe('With empty query response', () => {
beforeEach(() => {
mountComponent({ queryHandler: emptyQueryResponse, mountFn: mount });
jest.runOnlyPendingTimers();
});
it('renders `No matches found`', () => {
expect(wrapper.find('li').text()).toBe('No matches found');
});
});
describe('With error while fetching data', () => {
beforeEach(async () => {
mountComponent({ queryHandler: mockQueryError });
jest.runOnlyPendingTimers();
await nextTick();
});
it('creates a flash message and captures the error', () => {
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong while loading data. Please refresh the page to try again.',
captureError: true,
error: expect.any(Error),
});
});
});
});

View File

@ -0,0 +1,164 @@
import { __ } from '~/locale';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import RunnerStackedSummaryCell from '~/runner/components/cells/runner_stacked_summary_cell.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerTags from '~/runner/components/runner_tags.vue';
import RunnerSummaryField from '~/runner/components/cells/runner_summary_field.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { INSTANCE_TYPE, PROJECT_TYPE } from '~/runner/constants';
import { allRunnersData } from '../../mock_data';
const mockRunner = allRunnersData.data.runners.nodes[0];
describe('RunnerTypeCell', () => {
let wrapper;
const findLockIcon = () => wrapper.findByTestId('lock-icon');
const findRunnerTags = () => wrapper.findComponent(RunnerTags);
const findRunnerSummaryField = (icon) =>
wrapper.findAllComponents(RunnerSummaryField).filter((w) => w.props('icon') === icon)
.wrappers[0];
const createComponent = (runner, options) => {
wrapper = mountExtended(RunnerStackedSummaryCell, {
propsData: {
runner: {
...mockRunner,
...runner,
},
},
stubs: {
RunnerSummaryField,
},
...options,
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays the runner name as id and short token', () => {
expect(wrapper.text()).toContain(
`#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`,
);
});
it('Does not display the locked icon', () => {
expect(findLockIcon().exists()).toBe(false);
});
it('Displays the locked icon for locked runners', () => {
createComponent({
runnerType: PROJECT_TYPE,
locked: true,
});
expect(findLockIcon().exists()).toBe(true);
});
it('Displays the runner type', () => {
createComponent({
runnerType: INSTANCE_TYPE,
locked: true,
});
expect(wrapper.text()).toContain('shared');
});
it('Displays the runner version', () => {
expect(wrapper.text()).toContain(mockRunner.version);
});
it('Displays the runner description', () => {
expect(wrapper.text()).toContain(mockRunner.description);
});
it('Displays last contact', () => {
createComponent({
contactedAt: '2022-01-02',
});
expect(findRunnerSummaryField('clock').find(TimeAgo).props('time')).toBe('2022-01-02');
});
it('Displays empty last contact', () => {
createComponent({
contactedAt: null,
});
expect(findRunnerSummaryField('clock').find(TimeAgo).exists()).toBe(false);
expect(findRunnerSummaryField('clock').text()).toContain(__('Never'));
});
it('Displays ip address', () => {
createComponent({
ipAddress: '127.0.0.1',
});
expect(findRunnerSummaryField('disk').text()).toContain('127.0.0.1');
});
it('Displays no ip address', () => {
createComponent({
ipAddress: null,
});
expect(findRunnerSummaryField('disk')).toBeUndefined();
});
it('Displays job count', () => {
expect(findRunnerSummaryField('pipeline').text()).toContain(`${mockRunner.jobCount}`);
});
it('Formats large job counts ', () => {
createComponent({
jobCount: 1000,
});
expect(findRunnerSummaryField('pipeline').text()).toContain('1,000');
});
it('Formats large job counts with a plus symbol', () => {
createComponent({
jobCount: 1001,
});
expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+');
});
it('Displays created at', () => {
expect(findRunnerSummaryField('calendar').find(TimeAgo).props('time')).toBe(
mockRunner.createdAt,
);
});
it('Displays tag list', () => {
createComponent({
tagList: ['shell', 'linux'],
});
expect(findRunnerTags().props('tagList')).toEqual(['shell', 'linux']);
});
it('Displays a custom slot', () => {
const slotContent = 'My custom runner name';
createComponent(
{},
{
slots: {
'runner-name': slotContent,
},
},
);
expect(wrapper.text()).toContain(slotContent);
});
});

View File

@ -0,0 +1,49 @@
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerSummaryField from '~/runner/components/cells/runner_summary_field.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
describe('RunnerSummaryField', () => {
let wrapper;
const findIcon = () => wrapper.findComponent(GlIcon);
const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip').value;
const createComponent = ({ props, ...options } = {}) => {
wrapper = shallowMount(RunnerSummaryField, {
propsData: {
icon: '',
tooltip: '',
...props,
},
directives: {
GlTooltip: createMockDirective(),
},
...options,
});
};
afterEach(() => {
wrapper.destroy();
});
it('shows content in slot', () => {
createComponent({
slots: { default: 'content' },
});
expect(wrapper.text()).toBe('content');
});
it('shows icon', () => {
createComponent({ props: { icon: 'git' } });
expect(findIcon().props('name')).toBe('git');
});
it('shows tooltip', () => {
createComponent({ props: { tooltip: 'tooltip' } });
expect(getTooltipValue()).toBe('tooltip');
});
});

View File

@ -22,7 +22,10 @@ describe('RunnerList', () => {
const findCell = ({ row = 0, fieldKey }) =>
extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`));
const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => {
const createComponent = (
{ props = {}, provide = {}, ...options } = {},
mountFn = shallowMountExtended,
) => {
wrapper = mountFn(RunnerList, {
propsData: {
runners: mockRunners,
@ -32,6 +35,7 @@ describe('RunnerList', () => {
provide: {
onlineContactTimeoutSecs,
staleTimeoutSecs,
...provide,
},
...options,
});
@ -221,4 +225,60 @@ describe('RunnerList', () => {
expect(findSkeletonLoader().exists()).toBe(false);
});
});
describe.each`
glFeatures
${{ runnerListStackedLayoutAdmin: true }}
${{ runnerListStackedLayout: true }}
`('When glFeatures = $glFeatures', ({ glFeatures }) => {
beforeEach(() => {
createComponent(
{
stubs: {
RunnerStatusPopover: {
template: '<div/>',
},
},
provide: {
glFeatures,
},
},
mountExtended,
);
});
it('Displays stacked list headers', () => {
const headerLabels = findHeaders().wrappers.map((w) => w.text());
expect(headerLabels).toEqual([
'Status',
'Runner',
'', // actions has no label
]);
});
it('Displays stacked details of a runner', () => {
const { id, description, version, shortSha } = mockRunners[0];
const numericId = getIdFromGraphQLId(id);
// Badges
expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText('never contacted');
// Runner summary
const summary = findCell({ fieldKey: 'summary' }).text();
expect(summary).toContain(`#${numericId} (${shortSha})`);
expect(summary).toContain('specific');
expect(summary).toContain(version);
expect(summary).toContain(description);
expect(summary).toContain('Last contact');
expect(summary).toContain('0'); // job count
expect(summary).toContain('Created');
// Actions
expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true);
});
});
});

View File

@ -0,0 +1,167 @@
import $ from 'jquery';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import SetStatusForm from '~/set_status_modal/set_status_form.vue';
import EmojiPicker from '~/emoji/components/picker.vue';
import { timeRanges } from '~/vue_shared/constants';
import { sprintf } from '~/locale';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
describe('SetStatusForm', () => {
let wrapper;
const defaultPropsData = {
defaultEmoji: 'speech_balloon',
emoji: 'thumbsup',
message: 'Foo bar',
availability: false,
};
const createComponent = async ({ propsData = {} } = {}) => {
wrapper = mountExtended(SetStatusForm, {
propsData: {
...defaultPropsData,
...propsData,
},
});
await waitForPromises();
};
const findMessageInput = () =>
wrapper.findByPlaceholderText(SetStatusForm.i18n.statusMessagePlaceholder);
const findSelectedEmoji = (emoji) =>
wrapper.findByTestId('selected-emoji').find(`gl-emoji[data-name="${emoji}"]`);
it('sets up emoji autocomplete for the message input', async () => {
const gfmAutoCompleteSetupSpy = jest.spyOn(GfmAutoComplete.prototype, 'setup');
await createComponent();
expect(gfmAutoCompleteSetupSpy).toHaveBeenCalledWith($(findMessageInput().element), {
emojis: true,
});
});
describe('when emoji is set', () => {
it('displays emoji', async () => {
await createComponent();
expect(findSelectedEmoji(defaultPropsData.emoji).exists()).toBe(true);
});
});
describe('when emoji is not set and message is changed', () => {
it('displays default emoji', async () => {
await createComponent({
propsData: {
emoji: '',
},
});
await findMessageInput().trigger('keyup');
expect(findSelectedEmoji(defaultPropsData.defaultEmoji).exists()).toBe(true);
});
});
describe('when message is set', () => {
it('displays filled in message input', async () => {
await createComponent();
expect(findMessageInput().element.value).toBe(defaultPropsData.message);
});
});
describe('when clear status after is set', () => {
it('displays value in dropdown toggle button', async () => {
const clearStatusAfter = timeRanges[0];
await createComponent({
propsData: {
clearStatusAfter,
},
});
expect(wrapper.findByRole('button', { name: clearStatusAfter.label }).exists()).toBe(true);
});
});
describe('when emoji is changed', () => {
beforeEach(async () => {
await createComponent();
wrapper.findComponent(EmojiPicker).vm.$emit('click', defaultPropsData.emoji);
});
it('emits `emoji-click` event', () => {
expect(wrapper.emitted('emoji-click')).toEqual([[defaultPropsData.emoji]]);
});
});
describe('when message is changed', () => {
it('emits `message-input` event', async () => {
await createComponent();
const newMessage = 'Foo bar baz';
await findMessageInput().setValue(newMessage);
expect(wrapper.emitted('message-input')).toEqual([[newMessage]]);
});
});
describe('when availability checkbox is changed', () => {
it('emits `availability-input` event', async () => {
await createComponent();
await wrapper
.findByLabelText(
`${SetStatusForm.i18n.availabilityCheckboxLabel} ${SetStatusForm.i18n.availabilityCheckboxHelpText}`,
)
.setChecked();
expect(wrapper.emitted('availability-input')).toEqual([[true]]);
});
});
describe('when `Clear status after` dropdown is changed', () => {
it('emits `clear-status-after-click`', async () => {
await wrapper.findByTestId('thirtyMinutes').trigger('click');
expect(wrapper.emitted('clear-status-after-click')).toEqual([[timeRanges[0]]]);
});
});
describe('when clear status button is clicked', () => {
beforeEach(async () => {
await createComponent();
await wrapper
.findByRole('button', { name: SetStatusForm.i18n.clearStatusButtonLabel })
.trigger('click');
});
it('clears emoji and message', () => {
expect(wrapper.emitted('emoji-click')).toEqual([['']]);
expect(wrapper.emitted('message-input')).toEqual([['']]);
expect(wrapper.findByTestId('no-emoji-placeholder').exists()).toBe(true);
});
});
describe('when `currentClearStatusAfter` prop is set', () => {
it('displays clear status message', async () => {
const date = '2022-08-25 21:14:48 UTC';
await createComponent({
propsData: {
currentClearStatusAfter: date,
},
});
expect(
wrapper.findByText(sprintf(SetStatusForm.i18n.clearStatusAfterMessage, { date })).exists(),
).toBe(true);
});
});
});

View File

@ -9,6 +9,7 @@ import stubChildren from 'helpers/stub_children';
import SetStatusModalWrapper, {
AVAILABILITY_STATUS,
} from '~/set_status_modal/set_status_modal_wrapper.vue';
import SetStatusForm from '~/set_status_modal/set_status_form.vue';
jest.mock('~/flash');
@ -42,6 +43,7 @@ describe('SetStatusModalWrapper', () => {
...stubChildren(SetStatusModalWrapper),
GlFormInput: false,
GlFormInputGroup: false,
SetStatusForm: false,
EmojiPicker: EmojiPickerStub,
},
mocks: {
@ -118,10 +120,10 @@ describe('SetStatusModalWrapper', () => {
});
});
it('sets emojiTag when clicking in emoji picker', async () => {
it('passes emoji to `SetStatusForm`', async () => {
await getEmojiPicker().vm.$emit('click', 'thumbsup');
expect(wrapper.vm.emojiTag).toContain('data-name="thumbsup"');
expect(wrapper.findComponent(SetStatusForm).props('emoji')).toBe('thumbsup');
});
});
@ -194,7 +196,7 @@ describe('SetStatusModalWrapper', () => {
findAvailabilityCheckbox().vm.$emit('input', true);
// set the currentClearStatusAfter to 30 minutes
wrapper.find('[data-testid="thirtyMinutes"]').vm.$emit('click');
wrapper.find('[data-testid="thirtyMinutes"]').trigger('click');
findModal().vm.$emit('primary');
await nextTick();

View File

@ -6,6 +6,17 @@ import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisse
import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue';
import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue';
const createRenderTrackedArguments = () => [
undefined,
'survey:mr_experience',
{
label: 'render',
extra: {
accountAge: 0,
},
},
];
describe('MergeRequestExperienceSurveyApp', () => {
let trackingSpy;
let wrapper;
@ -24,6 +35,7 @@ describe('MergeRequestExperienceSurveyApp', () => {
dismiss,
shouldShowCallout,
});
trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
wrapper = shallowMountExtended(MergeRequestExperienceSurveyApp, {
propsData: {
accountAge: 0,
@ -33,9 +45,12 @@ describe('MergeRequestExperienceSurveyApp', () => {
GlSprintf,
},
});
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
};
beforeEach(() => {
localStorage.clear();
});
describe('when user callout is visible', () => {
beforeEach(() => {
createWrapper();
@ -47,6 +62,16 @@ describe('MergeRequestExperienceSurveyApp', () => {
expect(wrapper.emitted().close).toBe(undefined);
});
it('tracks render once', async () => {
expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments());
});
it("doesn't track subsequent renders", async () => {
createWrapper();
expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments());
expect(trackingSpy).toHaveBeenCalledTimes(1);
});
describe('when close button clicked', () => {
beforeEach(() => {
findCloseButton().vm.$emit('click');
@ -68,6 +93,15 @@ describe('MergeRequestExperienceSurveyApp', () => {
},
});
});
it('tracks subsequent renders', async () => {
createWrapper();
expect(trackingSpy.mock.calls).toEqual([
createRenderTrackedArguments(),
expect.anything(),
createRenderTrackedArguments(),
]);
});
});
it('applies correct feature name for user callout', () => {
@ -148,6 +182,10 @@ describe('MergeRequestExperienceSurveyApp', () => {
it('emits close event', async () => {
expect(wrapper.emitted()).toMatchObject({ close: [[]] });
});
it("doesn't track anything", async () => {
expect(trackingSpy).toHaveBeenCalledTimes(0);
});
});
describe('when Escape key is pressed', () => {

View File

@ -1,10 +1,10 @@
import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import AddedCommentMessage from '~/vue_merge_request_widget/components/added_commit_message.vue';
let wrapper;
function factory(propsData) {
wrapper = shallowMount(AddedCommentMessage, {
wrapper = mount(AddedCommentMessage, {
propsData: {
isFastForwardEnabled: false,
targetBranch: 'main',
@ -23,4 +23,13 @@ describe('Widget added commit message', () => {
expect(wrapper.element.outerHTML).toContain('The changes were not merged');
});
it('renders merge commit as a link', () => {
factory({ state: 'merged', mergeCommitPath: 'https://test.host/merge-commit-link' });
expect(wrapper.find('[data-testid="merge-commit-sha"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="merge-commit-sha"]').attributes('href')).toBe(
'https://test.host/merge-commit-link',
);
});
});

View File

@ -605,8 +605,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
let(:deps) do
double('deps',
'default_entry' => default,
'workflow_entry' => workflow,
'variables_value' => nil)
'workflow_entry' => workflow)
end
context 'when job config overrides default config' do

View File

@ -0,0 +1,173 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Ci::Config::Entry::LegacyVariables do
let(:config) { {} }
let(:metadata) { {} }
subject(:entry) { described_class.new(config, **metadata) }
before do
entry.compose!
end
shared_examples 'valid config' do
describe '#value' do
it 'returns hash with key value strings' do
expect(entry.value).to eq result
end
end
describe '#errors' do
it 'does not append errors' do
expect(entry.errors).to be_empty
end
end
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
end
shared_examples 'invalid config' do |error_message|
describe '#valid?' do
it 'is not valid' do
expect(entry).not_to be_valid
end
end
describe '#errors' do
it 'saves errors' do
expect(entry.errors)
.to include(error_message)
end
end
end
context 'when entry config value has key-value pairs' do
let(:config) do
{ 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
end
let(:result) do
{ 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
end
it_behaves_like 'valid config'
describe '#value_with_data' do
it 'returns variable with data' do
expect(entry.value_with_data).to eq(
'VARIABLE_1' => { value: 'value 1', description: nil },
'VARIABLE_2' => { value: 'value 2', description: nil }
)
end
end
end
context 'with numeric keys and values in the config' do
let(:config) { { 10 => 20 } }
let(:result) do
{ '10' => '20' }
end
it_behaves_like 'valid config'
end
context 'when key is an array' do
let(:config) { { ['VAR1'] => 'val1' } }
let(:result) do
{ 'VAR1' => 'val1' }
end
it_behaves_like 'invalid config', /should be a hash of key value pairs/
end
context 'when value is a symbol' do
let(:config) { { 'VAR1' => :val1 } }
let(:result) do
{ 'VAR1' => 'val1' }
end
it_behaves_like 'valid config'
end
context 'when value is a boolean' do
let(:config) { { 'VAR1' => true } }
let(:result) do
{ 'VAR1' => 'val1' }
end
it_behaves_like 'invalid config', /should be a hash of key value pairs/
end
context 'when entry config value has key-value pair and hash' do
let(:config) do
{ 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' },
'VARIABLE_2' => 'value 2' }
end
it_behaves_like 'invalid config', /should be a hash of key value pairs/
context 'when metadata has use_value_data: true' do
let(:metadata) { { use_value_data: true } }
let(:result) do
{ 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
end
it_behaves_like 'valid config'
describe '#value_with_data' do
it 'returns variable with data' do
expect(entry.value_with_data).to eq(
'VARIABLE_1' => { value: 'value 1', description: 'variable 1' },
'VARIABLE_2' => { value: 'value 2', description: nil }
)
end
end
end
end
context 'when entry value is an array' do
let(:config) { [:VAR, 'test'] }
it_behaves_like 'invalid config', /should be a hash of key value pairs/
end
context 'when metadata has use_value_data: true' do
let(:metadata) { { use_value_data: true } }
context 'when entry value has hash with other key-pairs' do
let(:config) do
{ 'VARIABLE_1' => { value: 'value 1', hello: 'variable 1' },
'VARIABLE_2' => 'value 2' }
end
it_behaves_like 'invalid config', /should be a hash of key value pairs, value can be a hash/
end
context 'when entry config value has hash with nil description' do
let(:config) do
{ 'VARIABLE_1' => { value: 'value 1', description: nil } }
end
it_behaves_like 'invalid config', /should be a hash of key value pairs, value can be a hash/
end
context 'when entry config value has hash without description' do
let(:config) do
{ 'VARIABLE_1' => { value: 'value 1' } }
end
let(:result) do
{ 'VARIABLE_1' => 'value 1' }
end
it_behaves_like 'valid config'
end
end
end

View File

@ -197,6 +197,34 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do
end
end
end
context 'when a variable has an invalid data attribute' do
let(:config) do
{
script: 'echo',
variables: { 'VAR1' => 'val 1', 'VAR2' => { value: 'val 2', description: 'hello var 2' } }
}
end
it 'reports error about variable' do
expect(entry.errors)
.to include 'variables:var2 config must be a string'
end
context 'when the FF ci_variables_refactoring_to_variable is disabled' do
let(:entry_without_ff) { node_class.new(config, name: :rspec) }
before do
stub_feature_flags(ci_variables_refactoring_to_variable: false)
entry_without_ff.compose!
end
it 'reports error about variable' do
expect(entry_without_ff.errors)
.to include /config should be a hash of key value pairs/
end
end
end
end
end
@ -212,13 +240,11 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do
let(:unspecified) { double('unspecified', 'specified?' => false) }
let(:default) { double('default', '[]' => unspecified) }
let(:workflow) { double('workflow', 'has_rules?' => false) }
let(:variables) {}
let(:deps) do
double('deps',
default_entry: default,
workflow_entry: workflow,
variables_value: variables)
workflow_entry: workflow)
end
context 'with workflow rules' do

View File

@ -350,6 +350,33 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
end
end
end
context 'when a variable has an invalid data key' do
let(:hash) do
{ variables: { VAR1: { invalid: 'hello' } }, rspec: { script: 'hello' } }
end
describe '#errors' do
it 'reports errors about the invalid variable' do
expect(root.errors)
.to include /var1 config uses invalid data keys: invalid/
end
context 'when the FF ci_variables_refactoring_to_variable is disabled' do
let(:root_without_ff) { described_class.new(hash, user: user, project: project) }
before do
stub_feature_flags(ci_variables_refactoring_to_variable: false)
root_without_ff.compose!
end
it 'reports errors about the invalid variable' do
expect(root_without_ff.errors)
.to include /variables config should be a hash of key value pairs, value can be a hash/
end
end
end
end
end
context 'when value is not a hash' do

View File

@ -1,8 +1,7 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require 'spec_helper'
require 'gitlab_chronic_duration'
require_dependency 'active_model'
RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do
let(:factory) do
@ -363,7 +362,20 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do
it { is_expected.not_to be_valid }
it 'returns an error about invalid variables:' do
expect(subject.errors).to include(/variables config should be a hash of key value pairs/)
expect(subject.errors).to include(/variables config should be a hash/)
end
context 'when the FF ci_variables_refactoring_to_variable is disabled' do
let(:entry_without_ff) { factory.create! }
before do
stub_feature_flags(ci_variables_refactoring_to_variable: false)
entry_without_ff.compose!
end
it 'returns an error about invalid variables:' do
expect(subject.errors).to include(/variables config should be a hash/)
end
end
end
end

View File

@ -0,0 +1,212 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Ci::Config::Entry::Variable do
let(:config) { {} }
let(:metadata) { {} }
subject(:entry) do
described_class.new(config, **metadata).tap do |entry|
entry.key = 'VAR1' # composable_hash requires key to be set
end
end
before do
entry.compose!
end
describe 'SimpleVariable' do
context 'when config is a string' do
let(:config) { 'value' }
describe '#valid?' do
it { is_expected.to be_valid }
end
describe '#value' do
subject(:value) { entry.value }
it { is_expected.to eq('value') }
end
end
context 'when config is an integer' do
let(:config) { 1 }
describe '#valid?' do
it { is_expected.to be_valid }
end
describe '#value' do
subject(:value) { entry.value }
it { is_expected.to eq('1') }
end
end
context 'when config is an array' do
let(:config) { [] }
describe '#valid?' do
it { is_expected.not_to be_valid }
end
describe '#errors' do
subject(:errors) { entry.errors }
it { is_expected.to include 'variable definition must be either a string or a hash' }
end
end
end
describe 'ComplexVariable' do
context 'when config is a hash with description' do
let(:config) { { value: 'value', description: 'description' } }
context 'when metadata allowed_value_data is not provided' do
describe '#valid?' do
it { is_expected.not_to be_valid }
end
describe '#errors' do
subject(:errors) { entry.errors }
it { is_expected.to include 'var1 config must be a string' }
end
end
context 'when metadata allowed_value_data is (value, description)' do
let(:metadata) { { allowed_value_data: %i[value description] } }
describe '#valid?' do
it { is_expected.to be_valid }
end
describe '#value' do
subject(:value) { entry.value }
it { is_expected.to eq('value') }
end
describe '#value_with_data' do
subject(:value_with_data) { entry.value_with_data }
it { is_expected.to eq(value: 'value', description: 'description') }
end
context 'when config value is a symbol' do
let(:config) { { value: :value, description: 'description' } }
describe '#value' do
subject(:value) { entry.value }
it { is_expected.to eq('value') }
end
describe '#value_with_data' do
subject(:value_with_data) { entry.value_with_data }
it { is_expected.to eq(value: 'value', description: 'description') }
end
end
context 'when config value is an integer' do
let(:config) { { value: 123, description: 'description' } }
describe '#value' do
subject(:value) { entry.value }
it { is_expected.to eq('123') }
end
describe '#value_with_data' do
subject(:value_with_data) { entry.value_with_data }
it { is_expected.to eq(value: '123', description: 'description') }
end
end
context 'when config value is an array' do
let(:config) { { value: ['value'], description: 'description' } }
describe '#valid?' do
it { is_expected.not_to be_valid }
end
describe '#errors' do
subject(:errors) { entry.errors }
it { is_expected.to include 'var1 config value must be an alphanumeric string' }
end
end
context 'when config description is a symbol' do
let(:config) { { value: 'value', description: :description } }
describe '#value' do
subject(:value) { entry.value }
it { is_expected.to eq('value') }
end
describe '#value_with_data' do
subject(:value_with_data) { entry.value_with_data }
it { is_expected.to eq(value: 'value', description: :description) }
end
end
end
context 'when metadata allowed_value_data is (value, xyz)' do
let(:metadata) { { allowed_value_data: %i[value xyz] } }
describe '#valid?' do
it { is_expected.not_to be_valid }
end
describe '#errors' do
subject(:errors) { entry.errors }
it { is_expected.to include 'var1 config uses invalid data keys: description' }
end
end
end
context 'when config is a hash without description' do
let(:config) { { value: 'value' } }
context 'when metadata allowed_value_data is not provided' do
describe '#valid?' do
it { is_expected.not_to be_valid }
end
describe '#errors' do
subject(:errors) { entry.errors }
it { is_expected.to include 'var1 config must be a string' }
end
end
context 'when metadata allowed_value_data is (value, description)' do
let(:metadata) { { allowed_value_data: %i[value description] } }
describe '#valid?' do
it { is_expected.to be_valid }
end
describe '#value' do
subject(:value) { entry.value }
it { is_expected.to eq('value') }
end
describe '#value_with_data' do
subject(:value_with_data) { entry.value_with_data }
it { is_expected.to eq(value: 'value', description: nil) }
end
end
end
end
end

Some files were not shown because too many files have changed in this diff Show More