Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
6170bdc060
commit
3034c7e6aa
3
Gemfile
3
Gemfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
query searchForkableNamespaces($projectPath: ID!, $search: String) {
|
||||
project(fullPath: $projectPath) {
|
||||
id
|
||||
forkTargets(search: $search) {
|
||||
nodes {
|
||||
id
|
||||
fullPath
|
||||
name
|
||||
visibility
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ fragment ListItemShared on CiRunner {
|
|||
locked
|
||||
jobCount
|
||||
tagList
|
||||
createdAt
|
||||
contactedAt
|
||||
status(legacyMode: null)
|
||||
userPermissions {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
</gl-modal>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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,7 +123,6 @@ 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"
|
||||
|
|
@ -130,10 +139,7 @@ export default {
|
|||
category="tertiary"
|
||||
class="gl-top-4 gl-right-3 gl-absolute"
|
||||
icon="close"
|
||||
@click="
|
||||
dismiss();
|
||||
close();
|
||||
"
|
||||
@click="dismiss"
|
||||
/>
|
||||
<div
|
||||
v-if="stepIndex === 0"
|
||||
|
|
@ -174,10 +180,7 @@ export default {
|
|||
<satisfaction-rate
|
||||
aria-labelledby="mr_survey_question"
|
||||
class="gl-mt-5"
|
||||
@rate="
|
||||
dismiss();
|
||||
onRate($event);
|
||||
"
|
||||
@rate="onRate"
|
||||
/>
|
||||
</section>
|
||||
<section v-else class="gl-px-7">
|
||||
|
|
@ -186,6 +189,5 @@ export default {
|
|||
</div>
|
||||
</transition>
|
||||
</aside>
|
||||
</template>
|
||||
</user-callout-dismisser>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -1,26 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::HookLogsController < Admin::ApplicationController
|
||||
include ::WebHooks::HookExecutionNotice
|
||||
|
||||
before_action :hook, only: [:show, :retry]
|
||||
before_action :hook_log, only: [:show, :retry]
|
||||
|
||||
respond_to :html
|
||||
|
||||
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
|
||||
module Admin
|
||||
class HookLogsController < Admin::ApplicationController
|
||||
include WebHooks::HookLogActions
|
||||
|
||||
private
|
||||
|
||||
|
|
@ -28,7 +10,8 @@ class Admin::HookLogsController < Admin::ApplicationController
|
|||
@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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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*) | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
class Variables
|
||||
def self.new(...)
|
||||
if YamlProcessor::FeatureFlags.enabled?(:ci_variables_refactoring_to_variable)
|
||||
CurrentVariables.new(...)
|
||||
else
|
||||
LegacyVariables.new(...)
|
||||
end
|
||||
|
||||
def value
|
||||
@config.to_h { |key, value| [key.to_s, expand_value(value)[:value]] }
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
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();
|
||||
|
||||
expect(findGlPopover().exists()).toBe(true);
|
||||
expect(findIssuableTitle().text()).toContain(mockBlockingIssue1.title);
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue