Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab/ci/reports.gitlab-ci.yml2
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--app/assets/javascripts/diffs/components/settings_dropdown.vue2
-rw-r--r--app/assets/javascripts/diffs/constants.js4
-rw-r--r--app/assets/javascripts/diffs/index.js21
-rw-r--r--app/assets/javascripts/diffs/store/actions.js18
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js11
-rw-r--r--app/assets/javascripts/diffs/store/utils.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue83
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue55
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js1
-rw-r--r--app/controllers/projects/merge_requests/content_controller.rb11
-rw-r--r--app/models/issue.rb1
-rw-r--r--app/policies/base_policy.rb4
-rw-r--r--app/policies/concerns/policy_actor.rb4
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb1
-rw-r--r--app/serializers/merge_request_widget_entity.rb2
-rw-r--r--app/views/projects/merge_requests/show.html.haml2
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--doc/administration/geo/replication/datatypes.md6
-rw-r--r--doc/development/testing_guide/best_practices.md3
-rw-r--r--doc/user/admin_area/analytics/usage_trends.md5
-rw-r--r--doc/user/project/merge_requests/changes.md4
-rw-r--r--lib/gitlab/ci/config/entry/job.rb2
-rw-r--r--lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml11
-rw-r--r--lib/gitlab/ci/yaml_processor/result.rb2
-rw-r--r--spec/controllers/projects/merge_requests/content_controller_spec.rb11
-rw-r--r--spec/frontend/diffs/components/settings_dropdown_spec.js1
-rw-r--r--spec/frontend/diffs/store/actions_spec.js50
-rw-r--r--spec/frontend/diffs/store/utils_spec.js22
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js31
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js91
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js173
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js357
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js72
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js88
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js84
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js241
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js93
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js176
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js140
-rw-r--r--spec/serializers/merge_request_poll_widget_entity_spec.rb18
56 files changed, 1956 insertions, 287 deletions
diff --git a/.gitlab/ci/reports.gitlab-ci.yml b/.gitlab/ci/reports.gitlab-ci.yml
index 0162996e3a9..4cc03fdb1a4 100644
--- a/.gitlab/ci/reports.gitlab-ci.yml
+++ b/.gitlab/ci/reports.gitlab-ci.yml
@@ -27,7 +27,7 @@ code_quality:
variables:
SAST_BRAKEMAN_LEVEL: 2 # GitLab-specific
SAST_EXCLUDED_PATHS: "qa, spec, doc, ee/spec, config/gitlab.yml.example, tmp" # GitLab-specific
- SAST_DISABLE_BABEL: "true"
+ SAST_EXCLUDED_ANALYZERS: bandit, flawfinder, phpcs-security-audit, pmd-apex, security-code-scan, spotbugs
brakeman-sast:
rules: !reference [".reports:rules:sast", rules]
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 9b197696144..e2d9a2df4a7 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-8a6d0e26de9d584941267d2b68c94b37bc30e092
+b82b2da71ab8ad5e61e4f162b99e64a7ab78ca7f
diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue
index 443fe147d6b..178f93b651e 100644
--- a/app/assets/javascripts/diffs/components/settings_dropdown.vue
+++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue
@@ -36,7 +36,7 @@ export default {
this.setFileByFile({ fileByFile: !this.viewDiffsFileByFile });
},
toggleWhitespace(updatedSetting) {
- this.setShowWhitespace({ showWhitespace: updatedSetting, pushState: true });
+ this.setShowWhitespace({ showWhitespace: updatedSetting });
},
},
};
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index f0e15983336..d1e02fbc598 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -1,7 +1,3 @@
-// The backend actually uses "hide_whitespace" while the frontend
-// uses "show whitspace" so these values are opposite what you might expect
-export const NO_SHOW_WHITESPACE = '1';
-export const SHOW_WHITESPACE = '0';
export const INLINE_DIFF_VIEW_TYPE = 'inline';
export const PARALLEL_DIFF_VIEW_TYPE = 'parallel';
export const MATCH_LINE_TYPE = 'match';
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 5a8862c2b70..0ab72749760 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -98,10 +98,23 @@ export default function initDiffsApp(store) {
this.setRenderTreeList(renderTreeList);
- // Set whitespace default as per user preferences unless cookie is already set
- if (!Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)) {
- const hideWhitespace = this.showWhitespaceDefault ? '0' : '1';
- this.setShowWhitespace({ showWhitespace: hideWhitespace !== '1' });
+ // NOTE: A "true" or "checked" value for `showWhitespace` is '0' not '1'.
+ // Check for cookie and save that setting for future use.
+ // Then delete the cookie as we are phasing it out and using the database as SSOT.
+ // NOTE: This can/should be removed later
+ if (Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)) {
+ const hideWhitespace = Cookies.get(DIFF_WHITESPACE_COOKIE_NAME);
+ this.setShowWhitespace({
+ url: this.endpointUpdateUser,
+ showWhitespace: hideWhitespace !== '1',
+ });
+ Cookies.remove(DIFF_WHITESPACE_COOKIE_NAME);
+ } else {
+ // This is only to set the the user preference in Vuex for use later
+ this.setShowWhitespace({
+ showWhitespace: this.showWhitespaceDefault,
+ updateDatabase: false,
+ });
}
},
methods: {
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index bb13ad5f426..2e94f147086 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -26,9 +26,6 @@ import {
START_RENDERING_INDEX,
INLINE_DIFF_LINES_KEY,
DIFFS_PER_PAGE,
- DIFF_WHITESPACE_COOKIE_NAME,
- SHOW_WHITESPACE,
- NO_SHOW_WHITESPACE,
DIFF_FILE_MANUAL_COLLAPSE,
DIFF_FILE_AUTOMATIC_COLLAPSE,
EVT_PERF_MARK_FILE_TREE_START,
@@ -569,16 +566,15 @@ export const setRenderTreeList = ({ commit }, renderTreeList) => {
}
};
-export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = false }) => {
- commit(types.SET_SHOW_WHITESPACE, showWhitespace);
- const w = showWhitespace ? SHOW_WHITESPACE : NO_SHOW_WHITESPACE;
-
- Cookies.set(DIFF_WHITESPACE_COOKIE_NAME, w);
-
- if (pushState) {
- historyPushState(mergeUrlParams({ w }, window.location.href));
+export const setShowWhitespace = async (
+ { state, commit },
+ { url, showWhitespace, updateDatabase = true },
+) => {
+ if (updateDatabase) {
+ await axios.put(url || state.endpointUpdateUser, { show_whitespace_in_diffs: showWhitespace });
}
+ commit(types.SET_SHOW_WHITESPACE, showWhitespace);
notesEventHub.$emit('refetchDiffData');
if (window.gon?.features?.diffSettingsUsageData) {
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 1674d3d3b5a..348dd452698 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -1,20 +1,13 @@
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
-import {
- INLINE_DIFF_VIEW_TYPE,
- DIFF_VIEW_COOKIE_NAME,
- DIFF_WHITESPACE_COOKIE_NAME,
-} from '../../constants';
+import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
import { fileByFile } from '../../utils/preferences';
-import { getDefaultWhitespace } from '../utils';
const getViewTypeFromQueryString = () => getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
-const whiteSpaceFromQueryString = getParameterValues('w')[0];
-const whiteSpaceFromCookie = Cookies.get(DIFF_WHITESPACE_COOKIE_NAME);
export default () => ({
isLoading: true,
@@ -42,7 +35,7 @@ export default () => ({
commentForms: [],
highlightedRow: null,
renderTreeList: true,
- showWhitespace: getDefaultWhitespace(whiteSpaceFromQueryString, whiteSpaceFromCookie),
+ showWhitespace: true,
viewDiffsFileByFile: fileByFile(),
fileFinderVisible: false,
dismissEndpoint: '',
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 5eda509163e..75d2cf43b94 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -11,8 +11,6 @@ import {
MATCH_LINE_TYPE,
LINES_TO_BE_RENDERED_DIRECTLY,
INLINE_DIFF_LINES_KEY,
- SHOW_WHITESPACE,
- NO_SHOW_WHITESPACE,
CONFLICT_OUR,
CONFLICT_THEIR,
CONFLICT_MARKER,
@@ -559,10 +557,3 @@ export const allDiscussionWrappersExpanded = (diff) => {
return discussionsExpanded;
};
-
-export const getDefaultWhitespace = (queryString, cookie) => {
- // Querystring should override stored cookie value
- if (queryString) return queryString === SHOW_WHITESPACE;
- if (cookie === NO_SHOW_WHITESPACE) return false;
- return true;
-};
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index 2e7b3e149b2..3b261f5ac25 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -71,9 +71,9 @@ export default {
<template>
<base-token
- :token-config="config"
- :token-value="value"
- :token-active="active"
+ :config="config"
+ :value="value"
+ :active="active"
:tokens-list-loading="loading"
:token-values="authors"
:fn-active-token-value="getActiveAuthor"
@@ -81,6 +81,7 @@ export default {
:preloaded-token-values="preloadedAuthors"
:recent-token-values-storage-key="config.recentTokenValuesStorageKey"
@fetch-token-values="fetchAuthorBySearchTerm"
+ v-on="$listeners"
>
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
<gl-avatar
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index fb6b9e4bc0d..3dbac0d146c 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -19,29 +19,34 @@ export default {
GlLoadingIcon,
},
props: {
- tokenConfig: {
+ config: {
type: Object,
required: true,
},
- tokenValue: {
+ value: {
type: Object,
required: true,
},
- tokenActive: {
+ active: {
type: Boolean,
required: true,
},
tokensListLoading: {
type: Boolean,
- required: true,
+ required: false,
+ default: false,
},
tokenValues: {
type: Array,
- required: true,
+ required: false,
+ default: () => [],
},
fnActiveTokenValue: {
type: Function,
- required: true,
+ required: false,
+ default: (tokenValues, currentTokenValue) => {
+ return tokenValues.find(({ value }) => value === currentTokenValue);
+ },
},
defaultTokenValues: {
type: Array,
@@ -90,9 +95,9 @@ export default {
},
currentTokenValue() {
if (this.fnCurrentTokenValue) {
- return this.fnCurrentTokenValue(this.tokenValue.data);
+ return this.fnCurrentTokenValue(this.value.data);
}
- return this.tokenValue.data.toLowerCase();
+ return this.value.data.toLowerCase();
},
activeTokenValue() {
return this.fnActiveTokenValue(this.tokenValues, this.currentTokenValue);
@@ -113,11 +118,11 @@ export default {
},
},
watch: {
- tokenActive: {
+ active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.tokenValues.length) {
- this.$emit('fetch-token-values', this.tokenValue.data);
+ this.$emit('fetch-token-values', this.value.data);
}
},
},
@@ -148,9 +153,11 @@ export default {
<template>
<gl-filtered-search-token
- :config="tokenConfig"
- v-bind="{ ...this.$parent.$props, ...this.$parent.$attrs }"
- v-on="this.$parent.$listeners"
+ :config="config"
+ :value="value"
+ :active="active"
+ v-bind="$attrs"
+ v-on="$listeners"
@input="handleInput"
@select="handleTokenValueSelected(activeTokenValue)"
>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index 20b8cbfe933..e496d099a42 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -93,15 +93,16 @@ export default {
<template>
<base-token
- :token-config="config"
- :token-value="value"
- :token-active="active"
+ :config="config"
+ :value="value"
+ :active="active"
:tokens-list-loading="loading"
:token-values="labels"
:fn-active-token-value="getActiveLabel"
:default-token-values="defaultLabels"
:recent-token-values-storage-key="config.recentTokenValuesStorageKey"
@fetch-token-values="fetchLabelBySearchTerm"
+ v-on="$listeners"
>
<template
#view-token="{ viewTokenProps: { inputValue, cssClasses, listeners, activeTokenValue } }"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index d80b66fd9be..1f0704f7308 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -1,5 +1,6 @@
<script>
-import { mapGetters, mapState } from 'vuex';
+import { GlButton } from '@gitlab/ui';
+import { mapActions, mapGetters, mapState } from 'vuex';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
@@ -8,6 +9,7 @@ export default {
components: {
DropdownContentsLabelsView,
DropdownContentsCreateView,
+ GlButton,
},
props: {
renderOnTop: {
@@ -15,10 +17,14 @@ export default {
required: false,
default: false,
},
+ labelsCreateTitle: {
+ type: String,
+ required: true,
+ },
},
computed: {
- ...mapState(['showDropdownContentsCreateView']),
- ...mapGetters(['isDropdownVariantSidebar']),
+ ...mapState(['showDropdownContentsCreateView', 'labelsListTitle']),
+ ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
dropdownContentsView() {
if (this.showDropdownContentsCreateView) {
return 'dropdown-contents-create-view';
@@ -29,6 +35,12 @@ export default {
const bottom = this.isDropdownVariantSidebar ? '3rem' : '2rem';
return this.renderOnTop ? { bottom } : {};
},
+ dropdownTitle() {
+ return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle;
+ },
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContentsCreateView', 'toggleDropdownContents']),
},
};
</script>
@@ -39,6 +51,30 @@ export default {
data-qa-selector="labels_dropdown_content"
:style="directionStyle"
>
- <component :is="dropdownContentsView" />
+ <div
+ v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ data-testid="dropdown-title"
+ >
+ <gl-button
+ v-if="showDropdownContentsCreateView"
+ :aria-label="__('Go back')"
+ variant="link"
+ size="small"
+ class="js-btn-back dropdown-header-button p-0"
+ icon="arrow-left"
+ @click="toggleDropdownContentsCreateView"
+ />
+ <span class="flex-grow-1">{{ dropdownTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ size="small"
+ class="dropdown-header-button gl-p-0!"
+ icon="close"
+ @click="toggleDropdownContents"
+ />
+ </div>
+ <component :is="dropdownContentsView" @hideCreateView="toggleDropdownContentsCreateView" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
index f8cc981ba3d..a7f20fbe851 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -1,6 +1,10 @@
<script>
import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import createLabelMutation from './graphql/create_label.mutation.graphql';
+
+const errorMessage = __('Error creating label.');
export default {
components: {
@@ -12,14 +16,19 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ },
data() {
return {
labelTitle: '',
selectedColor: '',
+ labelCreateInProgress: false,
};
},
computed: {
- ...mapState(['labelsCreateTitle', 'labelCreateInProgress']),
disableCreate() {
return !this.labelTitle.length || !this.selectedColor.length || this.labelCreateInProgress;
},
@@ -29,7 +38,6 @@ export default {
},
},
methods: {
- ...mapActions(['toggleDropdownContents', 'toggleDropdownContentsCreateView', 'createLabel']),
getColorCode(color) {
return Object.keys(color).pop();
},
@@ -39,11 +47,27 @@ export default {
handleColorClick(color) {
this.selectedColor = this.getColorCode(color);
},
- handleCreateClick() {
- this.createLabel({
- title: this.labelTitle,
- color: this.selectedColor,
- });
+ async createLabel() {
+ this.labelCreateInProgress = true;
+ try {
+ const {
+ data: { labelCreate },
+ } = await this.$apollo.mutate({
+ mutation: createLabelMutation,
+ variables: {
+ title: this.labelTitle,
+ color: this.selectedColor,
+ projectPath: this.projectPath,
+ },
+ });
+ if (labelCreate.errors.length) {
+ createFlash({ message: errorMessage });
+ }
+ } catch {
+ createFlash({ message: errorMessage });
+ }
+ this.labelCreateInProgress = false;
+ this.$emit('hideCreateView');
},
},
};
@@ -51,34 +75,16 @@ export default {
<template>
<div class="labels-select-contents-create js-labels-create">
- <div class="dropdown-title d-flex align-items-center pt-0 pb-2">
- <gl-button
- :aria-label="__('Go back')"
- variant="link"
- size="small"
- class="js-btn-back dropdown-header-button p-0"
- icon="arrow-left"
- @click="toggleDropdownContentsCreateView"
- />
- <span class="flex-grow-1">{{ labelsCreateTitle }}</span>
- <gl-button
- :aria-label="__('Close')"
- variant="link"
- size="small"
- class="dropdown-header-button p-0"
- icon="close"
- @click="toggleDropdownContents"
- />
- </div>
<div class="dropdown-input">
<gl-form-input
v-model.trim="labelTitle"
:placeholder="__('Name new label')"
:autofocus="true"
+ data-testid="label-title-input"
/>
</div>
- <div class="dropdown-content px-2">
- <div class="suggest-colors suggest-colors-dropdown mt-0 mb-2">
+ <div class="dropdown-content gl-px-3">
+ <div class="suggest-colors suggest-colors-dropdown gl-mt-0! gl-mb-3!">
<gl-link
v-for="(color, index) in suggestedColors"
:key="index"
@@ -90,28 +96,35 @@ export default {
</div>
<div class="color-input-container gl-display-flex">
<span
- class="dropdown-label-color-preview position-relative position-relative d-inline-block"
+ class="dropdown-label-color-preview gl-relative gl-display-inline-block"
+ data-testid="selected-color"
:style="{ backgroundColor: selectedColor }"
></span>
<gl-form-input
v-model.trim="selectedColor"
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:placeholder="__('Use custom color #FF0000')"
+ data-testid="selected-color-text"
/>
</div>
</div>
- <div class="dropdown-actions clearfix pt-2 px-2">
+ <div class="dropdown-actions gl-display-flex gl-justify-content-space-between gl-pt-3 gl-px-3">
<gl-button
:disabled="disableCreate"
category="primary"
variant="success"
- class="float-left d-flex align-items-center"
- @click="handleCreateClick"
+ class="gl-display-flex gl-align-items-center"
+ data-testid="create-button"
+ @click="createLabel"
>
- <gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" />
+ <gl-loading-icon v-if="labelCreateInProgress" :inline="true" class="mr-1" />
{{ __('Create') }}
</gl-button>
- <gl-button class="float-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView">
+ <gl-button
+ class="js-btn-cancel-create"
+ data-testid="cancel-button"
+ @click="$emit('hideCreateView')"
+ >
{{ __('Cancel') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
index 86788a84260..bff34743344 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
@@ -1,11 +1,5 @@
<script>
-import {
- GlIntersectionObserver,
- GlLoadingIcon,
- GlButton,
- GlSearchBoxByType,
- GlLink,
-} from '@gitlab/ui';
+import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { mapState, mapGetters, mapActions } from 'vuex';
@@ -17,7 +11,6 @@ export default {
components: {
GlIntersectionObserver,
GlLoadingIcon,
- GlButton,
GlSearchBoxByType,
GlLink,
LabelItem,
@@ -149,21 +142,6 @@ export default {
<template>
<gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear">
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
- <div
- v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
- class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
- data-testid="dropdown-title"
- >
- <span class="flex-grow-1">{{ labelsListTitle }}</span>
- <gl-button
- :aria-label="__('Close')"
- variant="link"
- size="small"
- class="dropdown-header-button gl-p-0!"
- icon="close"
- @click="toggleDropdownContents"
- />
- </div>
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type
ref="searchInput"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue
new file mode 100644
index 00000000000..122250d1ce7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlIcon,
+ },
+ props: {
+ labels: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ labelsList() {
+ const labelsString = this.labels.length
+ ? this.labels
+ .slice(0, 5)
+ .map((label) => label.title)
+ .join(', ')
+ : s__('LabelSelect|Labels');
+
+ if (this.labels.length > 5) {
+ return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
+ labelsString,
+ remainingLabelCount: this.labels.length - 5,
+ });
+ }
+
+ return labelsString;
+ },
+ },
+ methods: {
+ handleClick() {
+ this.$emit('onValueClick');
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-gl-tooltip.left.viewport
+ :title="labelsList"
+ class="sidebar-collapsed-icon"
+ @click="handleClick"
+ >
+ <gl-icon name="labels" />
+ <span>{{ labels.length }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql
new file mode 100644
index 00000000000..9aa4f5d165e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql
@@ -0,0 +1,15 @@
+mutation createLabel($title: String!, $color: String, $projectPath: ID, $groupPath: ID) {
+ labelCreate(
+ input: { title: $title, color: $color, projectPath: $projectPath, groupPath: $groupPath }
+ ) {
+ label {
+ id
+ color
+ description
+ descriptionHtml
+ title
+ textColor
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
index bf30e3cfac5..7728c758e18 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -5,13 +5,12 @@ import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue';
-
import { DropdownVariant } from './constants';
import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
+import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import labelsSelectModule from './store';
Vue.use(Vuex);
@@ -163,7 +162,6 @@ export default {
labelsFilterBasePath: this.labelsFilterBasePath,
labelsFilterParam: this.labelsFilterParam,
labelsListTitle: this.labelsListTitle,
- labelsCreateTitle: this.labelsCreateTitle,
footerCreateLabelTitle: this.footerCreateLabelTitle,
footerManageLabelTitle: this.footerManageLabelTitle,
});
@@ -313,6 +311,7 @@ export default {
v-show="dropdownButtonVisible && showDropdownContents"
ref="dropdownContents"
:render-on-top="!contentIsOnViewport"
+ :labels-create-title="labelsCreateTitle"
/>
</template>
<template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
index 89f96ab916b..2b96b159ca3 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
@@ -28,31 +28,5 @@ export const fetchLabels = ({ state, dispatch }) => {
.catch(() => dispatch('receiveLabelsFailure'));
};
-export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LABEL);
-export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS);
-export const receiveCreateLabelFailure = ({ commit }) => {
- commit(types.RECEIVE_CREATE_LABEL_FAILURE);
- flash(__('Error creating label.'));
-};
-export const createLabel = ({ state, dispatch }, label) => {
- dispatch('requestCreateLabel');
- axios
- .post(state.labelsManagePath, {
- label,
- })
- .then(({ data }) => {
- if (data.id) {
- dispatch('receiveCreateLabelSuccess');
- dispatch('toggleDropdownContentsCreateView');
- } else {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- throw new Error('Error Creating Label');
- }
- })
- .catch(() => {
- dispatch('receiveCreateLabelFailure');
- });
-};
-
export const updateSelectedLabels = ({ commit }, labels) =>
commit(types.UPDATE_SELECTED_LABELS, { labels });
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
index 2e044dc3b3c..b8da7a90b36 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
@@ -8,10 +8,6 @@ export const REQUEST_SET_LABELS = 'REQUEST_SET_LABELS';
export const RECEIVE_SET_LABELS_SUCCESS = 'RECEIVE_SET_LABELS_SUCCESS';
export const RECEIVE_SET_LABELS_FAILURE = 'RECEIVE_SET_LABELS_FAILURE';
-export const REQUEST_CREATE_LABEL = 'REQUEST_CREATE_LABEL';
-export const RECEIVE_CREATE_LABEL_SUCCESS = 'RECEIVE_CREATE_LABEL_SUCCESS';
-export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
-
export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
index 55716e1105e..131c6e6fb57 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
@@ -46,17 +46,6 @@ export default {
[types.RECEIVE_SET_LABELS_FAILURE](state) {
state.labelsFetchInProgress = false;
},
-
- [types.REQUEST_CREATE_LABEL](state) {
- state.labelCreateInProgress = true;
- },
- [types.RECEIVE_CREATE_LABEL_SUCCESS](state) {
- state.labelCreateInProgress = false;
- },
- [types.RECEIVE_CREATE_LABEL_FAILURE](state) {
- state.labelCreateInProgress = false;
- },
-
[types.UPDATE_SELECTED_LABELS](state, { labels }) {
// Find the label to update from all the labels
// and change `set` prop value to represent their current state.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
index d66cfed4163..220bab05ed2 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
@@ -3,7 +3,6 @@ export default () => ({
labels: [],
selectedLabels: [],
labelsListTitle: '',
- labelsCreateTitle: '',
footerCreateLabelTitle: '',
footerManageLabelTitle: '',
dropdownButtonText: '',
diff --git a/app/controllers/projects/merge_requests/content_controller.rb b/app/controllers/projects/merge_requests/content_controller.rb
index dfc060c9204..399745151b1 100644
--- a/app/controllers/projects/merge_requests/content_controller.rb
+++ b/app/controllers/projects/merge_requests/content_controller.rb
@@ -14,8 +14,6 @@ class Projects::MergeRequests::ContentController < Projects::MergeRequests::Appl
SLOW_POLLING_INTERVAL = 5.minutes.in_milliseconds
def widget
- check_mergeability_async!
-
respond_to do |format|
format.json do
render json: serializer(MergeRequestPollWidgetEntity)
@@ -40,13 +38,6 @@ class Projects::MergeRequests::ContentController < Projects::MergeRequests::Appl
def serializer(entity)
serializer = MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
- serializer.represent(merge_request, { async_mergeability_check: params[:async_mergeability_check] }, entity)
- end
-
- def check_mergeability_async!
- return unless Feature.enabled?(:check_mergeability_async_in_widget, merge_request.project, default_enabled: :yaml)
- return if params[:async_mergeability_check].blank?
-
- merge_request.check_mergeability(async: true)
+ serializer.represent(merge_request, {}, entity)
end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index b0a126c4442..48f388ea48d 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -23,6 +23,7 @@ class Issue < ApplicationRecord
include IssueAvailableFeatures
include Todoable
include FromUnion
+ include EachBatch
extend ::Gitlab::Utils::Override
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 0f7a6b852ab..ea1ea87ff2f 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -27,6 +27,10 @@ class BasePolicy < DeclarativePolicy::Base
with_options scope: :user, score: 0
condition(:security_bot) { @user&.security_bot? }
+ desc "User is automation bot"
+ with_options scope: :user, score: 0
+ condition(:automation_bot) { @user&.automation_bot? }
+
desc "User email is unconfirmed or user account is locked"
with_options scope: :user, score: 0
condition(:inactive) { @user&.confirmation_required_on_sign_in? || @user&.access_locked? }
diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb
index cbc34bdeed3..513bb85f538 100644
--- a/app/policies/concerns/policy_actor.rb
+++ b/app/policies/concerns/policy_actor.rb
@@ -53,6 +53,10 @@ module PolicyActor
false
end
+ def automation_bot?
+ false
+ end
+
def deactivated?
false
end
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index c00dceadf22..3ce67d92af1 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -31,7 +31,6 @@ class MergeRequestPollWidgetEntity < Grape::Entity
expose :mergeable do |merge_request, options|
next merge_request.mergeable? if Feature.disabled?(:check_mergeability_async_in_widget, merge_request.project, default_enabled: :yaml)
- next false if options[:async_mergeability_check].present? && merge_request.checking?
merge_request.mergeable?
end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index ac9970579ed..0616d94a1ed 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -36,7 +36,7 @@ class MergeRequestWidgetEntity < Grape::Entity
end
expose :merge_request_widget_path do |merge_request|
- widget_project_json_merge_request_path(merge_request.target_project, merge_request, async_mergeability_check: true, format: :json)
+ widget_project_json_merge_request_path(merge_request.target_project, merge_request, format: :json)
end
expose :merge_request_cached_widget_path do |merge_request|
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 49f2795538c..691ce8dc5fc 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -62,7 +62,7 @@
- add_page_startup_api_call notes_url
- else
- add_page_startup_api_call discussions_path(@merge_request)
- - add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, async_mergeability_check: true, format: :json)
+ - add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, format: :json)
- add_page_startup_api_call cached_widget_project_json_merge_request_path(@project, @merge_request, format: :json)
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request, Feature.enabled?(:paginated_notes, @project)).to_json,
endpoint_metadata: @endpoint_metadata_url,
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index f315a8cb28e..283683d22a1 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -196,6 +196,8 @@
- 2
- - issue_rebalancing
- 1
+- - iterations
+ - 1
- - jira_connect
- 1
- - jira_importer
diff --git a/doc/administration/geo/replication/datatypes.md b/doc/administration/geo/replication/datatypes.md
index aef152ecf37..a1461a64518 100644
--- a/doc/administration/geo/replication/datatypes.md
+++ b/doc/administration/geo/replication/datatypes.md
@@ -189,7 +189,7 @@ successfully, you must replicate their data using some other means.
|[Object pools for forked project deduplication](../../../development/git_object_deduplication.md) | **Yes** | No | No | |
|[Container Registry](../../packages/container_registry.md) | **Yes** (12.3) | No | No | Disabled by default. See [instructions](docker_registry.md) to enable. |
|[Content in object storage (beta)](object_storage.md) | **Yes** (12.4) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/13845) | No | |
-|[Project designs repository](../../../user/project/issues/design_management.md) | **Yes** (12.7) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/32467) | Via Object Storage provider if supported. Native Geo support (Beta). | |
+|[Project designs repository](../../../user/project/issues/design_management.md) | **Yes** (12.7) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/32467) | No | Designs also require replication of LFS objects and Uploads. |
|[Package Registry for npm](../../../user/packages/npm_registry/index.md) | **Yes** (13.2) | [**Yes**](#limitation-of-verification-for-files-in-object-storage) (13.10) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_package_file_replication`, enabled by default. |
|[Package Registry for Maven](../../../user/packages/maven_repository/index.md) | **Yes** (13.2) | [**Yes**](#limitation-of-verification-for-files-in-object-storage) (13.10) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_package_file_replication`, enabled by default. |
|[Package Registry for Conan](../../../user/packages/conan_repository/index.md) | **Yes** (13.2) | [**Yes**](#limitation-of-verification-for-files-in-object-storage) (13.10) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_package_file_replication`, enabled by default. |
@@ -202,9 +202,9 @@ successfully, you must replicate their data using some other means.
|[Versioned snippets](../../../user/snippets.md#versioned-snippets) | [**Yes** (13.7)](https://gitlab.com/groups/gitlab-org/-/epics/2809) | [No](https://gitlab.com/groups/gitlab-org/-/epics/2810) | No | |
|[Server-side Git hooks](../../server_hooks.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/1867) | No | No | |
|[Elasticsearch integration](../../../integration/elasticsearch.md) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/1186) | No | No | |
-|[GitLab Pages](../../pages/index.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/589) | No | No | |
+|[GitLab Pages](../../pages/index.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/589) | No | Via Object Storage provider if supported. **No** native Geo support (Beta). | |
|[Dependency proxy images](../../../user/packages/dependency_proxy/index.md) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/259694) | No | No | Blocked on [Geo: Secondary Mimicry](https://gitlab.com/groups/gitlab-org/-/epics/1528). Note that replication of this cache is not needed for Disaster Recovery purposes because it can be recreated from external sources. |
-|[Vulnerability Export](../../../user/application_security/vulnerability_report/#export-vulnerability-details) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/3111) | No | Via Object Storage provider if supported. Native Geo support (Beta). | Not planned because they are ephemeral and sensitive. They can be regenerated on demand. |
+|[Vulnerability Export](../../../user/application_security/vulnerability_report/#export-vulnerability-details) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/3111) | No | | Not planned because they are ephemeral and sensitive. They can be regenerated on demand. |
#### Limitation of verification for files in Object Storage
diff --git a/doc/development/testing_guide/best_practices.md b/doc/development/testing_guide/best_practices.md
index 4ca4ea0e64e..c44e26927fe 100644
--- a/doc/development/testing_guide/best_practices.md
+++ b/doc/development/testing_guide/best_practices.md
@@ -871,7 +871,8 @@ at the start and end of each context only. The [Elasticsearch DeleteByQuery API]
is used to delete data in all indices in between examples to ensure a clean index.
Note that Elasticsearch indexing uses [`Gitlab::Redis::SharedState`](../../../ee/development/redis.md#gitlabrediscachesharedstatequeues).
-Therefore, it is recommended to use `:clean_gitlab_redis_shared_state` in conjunction with the Elasticsearch traits.
+Therefore, the Elasticsearch traits dynamically use the `:clean_gitlab_redis_shared_state` trait.
+You do NOT need to add `:clean_gitlab_redis_shared_state` manually.
Specs using Elasticsearch require that you:
diff --git a/doc/user/admin_area/analytics/usage_trends.md b/doc/user/admin_area/analytics/usage_trends.md
index 49c81b1a965..9c09b62f8af 100644
--- a/doc/user/admin_area/analytics/usage_trends.md
+++ b/doc/user/admin_area/analytics/usage_trends.md
@@ -17,7 +17,10 @@ This feature might not be available to you. Check the **version history** note a
Usage Trends gives you an overview of how much data your instance contains, and how quickly this volume is changing over time.
-To see Usage Trends, go to **Admin Area > Analytics > Usage Trends**.
+To see Usage Trends:
+
+1. On the top bar, select **Menu >** **{admin}** **Admin**.
+1. On the left sidebar, select **Analytics > Usage Trends**.
## Total counts
diff --git a/doc/user/project/merge_requests/changes.md b/doc/user/project/merge_requests/changes.md
index d80af434cdd..e594f8048e3 100644
--- a/doc/user/project/merge_requests/changes.md
+++ b/doc/user/project/merge_requests/changes.md
@@ -91,10 +91,6 @@ specific commit page.
![MR diff](img/merge_request_diff.png)
-NOTE:
-You can append `?w=1` while on the diffs page of a merge request to ignore any
-whitespace changes.
-
## Mark files as viewed
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51513) in GitLab 13.9.
diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb
index c8e8f0bc1fc..e6d63969161 100644
--- a/lib/gitlab/ci/config/entry/job.rb
+++ b/lib/gitlab/ci/config/entry/job.rb
@@ -14,7 +14,7 @@ module Gitlab
ALLOWED_KEYS = %i[tags script type image services start_in artifacts
cache dependencies before_script after_script
environment coverage retry parallel interruptible timeout
- release secrets].freeze
+ release dast_configuration secrets].freeze
REQUIRED_BY_NEEDS = %i[stage].freeze
diff --git a/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml
index 6834766da3d..e936364c86c 100644
--- a/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml
@@ -46,13 +46,10 @@ dast:
$REVIEW_DISABLED && $DAST_WEBSITE == null &&
$DAST_API_SPECIFICATION == null
when: never
- - if: $CI_MERGE_REQUEST_IID &&
- $CI_KUBERNETES_ACTIVE &&
- $GITLAB_FEATURES =~ /\bdast\b/
- - if: $CI_MERGE_REQUEST_IID && ($DAST_WEBSITE || $DAST_API_SPECIFICATION)
- - if: $CI_OPEN_MERGE_REQUESTS
- when: never
- if: $CI_COMMIT_BRANCH &&
$CI_KUBERNETES_ACTIVE &&
$GITLAB_FEATURES =~ /\bdast\b/
- - if: $CI_COMMIT_BRANCH && ($DAST_WEBSITE || $DAST_API_SPECIFICATION)
+ - if: $CI_COMMIT_BRANCH &&
+ $DAST_WEBSITE
+ - if: $CI_COMMIT_BRANCH &&
+ $DAST_API_SPECIFICATION
diff --git a/lib/gitlab/ci/yaml_processor/result.rb b/lib/gitlab/ci/yaml_processor/result.rb
index 15cc0c28296..dd5107bad9a 100644
--- a/lib/gitlab/ci/yaml_processor/result.rb
+++ b/lib/gitlab/ci/yaml_processor/result.rb
@@ -148,3 +148,5 @@ module Gitlab
end
end
end
+
+Gitlab::Ci::YamlProcessor::Result.prepend_mod_with('Gitlab::Ci::YamlProcessor::Result')
diff --git a/spec/controllers/projects/merge_requests/content_controller_spec.rb b/spec/controllers/projects/merge_requests/content_controller_spec.rb
index 0eaa528a330..0116071bddf 100644
--- a/spec/controllers/projects/merge_requests/content_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/content_controller_spec.rb
@@ -57,17 +57,6 @@ RSpec.describe Projects::MergeRequests::ContentController do
expect(response.headers['Poll-Interval']).to eq('10000')
end
- context 'when async_mergeability_check param is passed' do
- it 'checks mergeability asynchronously' do
- expect_next_instance_of(MergeRequests::MergeabilityCheckService) do |service|
- expect(service).not_to receive(:execute)
- expect(service).to receive(:async_execute).and_call_original
- end
-
- do_request(:widget, { async_mergeability_check: true })
- end
- end
-
context 'merged merge request' do
let(:merge_request) do
create(:merged_merge_request, :with_test_reports, target_project: project, source_project: project)
diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js
index feac88cb802..43b9c5871a6 100644
--- a/spec/frontend/diffs/components/settings_dropdown_spec.js
+++ b/spec/frontend/diffs/components/settings_dropdown_spec.js
@@ -142,7 +142,6 @@ describe('Diff settings dropdown component', () => {
expect(store.dispatch).toHaveBeenCalledWith('diffs/setShowWhitespace', {
showWhitespace: !checked,
- pushState: true,
});
});
});
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index 05a771c19f1..14f8e090be9 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -9,8 +9,6 @@ import {
INLINE_DIFF_VIEW_TYPE,
PARALLEL_DIFF_VIEW_TYPE,
DIFFS_PER_PAGE,
- DIFF_WHITESPACE_COOKIE_NAME,
- SHOW_WHITESPACE,
} from '~/diffs/constants';
import {
setBaseConfig,
@@ -1019,14 +1017,26 @@ describe('DiffsStoreActions', () => {
});
describe('setShowWhitespace', () => {
+ const endpointUpdateUser = 'user/prefs';
+ let putSpy;
+ let mock;
+
beforeEach(() => {
+ mock = new MockAdapter(axios);
+ putSpy = jest.spyOn(axios, 'put');
+
+ mock.onPut(endpointUpdateUser).reply(200, {});
jest.spyOn(eventHub, '$emit').mockImplementation();
});
+ afterEach(() => {
+ mock.restore();
+ });
+
it('commits SET_SHOW_WHITESPACE', (done) => {
testAction(
setShowWhitespace,
- { showWhitespace: true },
+ { showWhitespace: true, updateDatabase: false },
{},
[{ type: types.SET_SHOW_WHITESPACE, payload: true }],
[],
@@ -1034,32 +1044,20 @@ describe('DiffsStoreActions', () => {
);
});
- it('sets cookie', () => {
- setShowWhitespace({ commit() {} }, { showWhitespace: true });
-
- expect(Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)).toEqual(SHOW_WHITESPACE);
- });
-
- it('calls history pushState', () => {
- setShowWhitespace({ commit() {} }, { showWhitespace: true, pushState: true });
-
- expect(window.history.pushState).toHaveBeenCalled();
- });
-
- it('calls history pushState with merged params', () => {
- window.history.pushState({}, '', '?test=1');
-
- setShowWhitespace({ commit() {} }, { showWhitespace: true, pushState: true });
-
- expect(
- window.history.pushState.mock.calls[window.history.pushState.mock.calls.length - 1][2],
- ).toMatch(/(.*)\?test=1&w=0/);
+ it('saves to the database', async () => {
+ await setShowWhitespace(
+ { state: { endpointUpdateUser }, commit() {} },
+ { showWhitespace: true, updateDatabase: true },
+ );
- window.history.pushState({}, '', '?');
+ expect(putSpy).toHaveBeenCalledWith(endpointUpdateUser, { show_whitespace_in_diffs: true });
});
- it('emits eventHub event', () => {
- setShowWhitespace({ commit() {} }, { showWhitespace: true, pushState: true });
+ it('emits eventHub event', async () => {
+ await setShowWhitespace(
+ { state: {}, commit() {} },
+ { showWhitespace: true, updateDatabase: false },
+ );
expect(eventHub.$emit).toHaveBeenCalledWith('refetchDiffData');
});
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index 45cf3a32565..73de0a6d381 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -752,28 +752,6 @@ describe('DiffsStoreUtils', () => {
});
});
- describe('getDefaultWhitespace', () => {
- it('defaults to true if querystring and cookie are undefined', () => {
- expect(utils.getDefaultWhitespace()).toBe(true);
- });
-
- it('returns false if querystring is `1`', () => {
- expect(utils.getDefaultWhitespace('1', '0')).toBe(false);
- });
-
- it('returns true if querystring is `0`', () => {
- expect(utils.getDefaultWhitespace('0', undefined)).toBe(true);
- });
-
- it('returns false if cookie is `1`', () => {
- expect(utils.getDefaultWhitespace(undefined, '1')).toBe(false);
- });
-
- it('returns true if cookie is `0`', () => {
- expect(utils.getDefaultWhitespace(undefined, '0')).toBe(true);
- });
- });
-
describe('isAdded', () => {
it.each`
type | expected
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
index f50eafdbc52..19fda1433e0 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
@@ -46,6 +46,7 @@ function createComponent(options = {}) {
active = false,
stubs = defaultStubs,
data = {},
+ listeners = {},
} = options;
return mount(AuthorToken, {
propsData: {
@@ -62,6 +63,7 @@ function createComponent(options = {}) {
return { ...data };
},
stubs,
+ listeners,
});
}
@@ -258,6 +260,18 @@ describe('AuthorToken', () => {
expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text);
});
+ it('emits listeners in the base-token', () => {
+ const mockInput = jest.fn();
+ wrapper = createComponent({
+ listeners: {
+ input: mockInput,
+ },
+ });
+ wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
+
+ expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]);
+ });
+
describe('when loading', () => {
beforeEach(() => {
wrapper = createComponent({
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
index 602864f4fa5..89c5cedc9b8 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
@@ -46,12 +46,11 @@ const defaultSlots = {
};
const mockProps = {
- tokenConfig: mockLabelToken,
- tokenValue: { data: '' },
- tokenActive: false,
- tokensListLoading: false,
+ config: mockLabelToken,
+ value: { data: '' },
+ active: false,
tokenValues: [],
- fnActiveTokenValue: jest.fn(),
+ tokensListLoading: false,
defaultTokenValues: DEFAULT_LABELS,
recentTokenValuesStorageKey: mockStorageKey,
fnCurrentTokenValue: jest.fn(),
@@ -83,7 +82,7 @@ describe('BaseToken', () => {
wrapper = createComponent({
props: {
...mockProps,
- tokenValue: { data: `"${mockRegularLabel.title}"` },
+ value: { data: `"${mockRegularLabel.title}"` },
tokenValues: mockLabels,
},
});
@@ -112,17 +111,17 @@ describe('BaseToken', () => {
describe('activeTokenValue', () => {
it('calls `fnActiveTokenValue` when it is provided', async () => {
+ const mockFnActiveTokenValue = jest.fn();
+
wrapper.setProps({
+ fnActiveTokenValue: mockFnActiveTokenValue,
fnCurrentTokenValue: undefined,
});
await wrapper.vm.$nextTick();
- // We're disabling lint to trigger computed prop execution for this test.
- // eslint-disable-next-line no-unused-vars
- const { activeTokenValue } = wrapper.vm;
-
- expect(wrapper.vm.fnActiveTokenValue).toHaveBeenCalledWith(
+ expect(mockFnActiveTokenValue).toHaveBeenCalledTimes(1);
+ expect(mockFnActiveTokenValue).toHaveBeenCalledWith(
mockLabels,
`"${mockRegularLabel.title.toLowerCase()}"`,
);
@@ -131,15 +130,15 @@ describe('BaseToken', () => {
});
describe('watch', () => {
- describe('tokenActive', () => {
+ describe('active', () => {
let wrapperWithTokenActive;
beforeEach(() => {
wrapperWithTokenActive = createComponent({
props: {
...mockProps,
- tokenActive: true,
- tokenValue: { data: `"${mockRegularLabel.title}"` },
+ value: { data: `"${mockRegularLabel.title}"` },
+ active: true,
},
});
});
@@ -150,7 +149,7 @@ describe('BaseToken', () => {
it('emits `fetch-token-values` event on the component when value of this prop is changed to false and `tokenValues` array is empty', async () => {
wrapperWithTokenActive.setProps({
- tokenActive: false,
+ active: false,
});
await wrapperWithTokenActive.vm.$nextTick();
@@ -238,7 +237,7 @@ describe('BaseToken', () => {
jest.runAllTimers();
expect(wrapperWithNoStubs.emitted('fetch-token-values')).toBeTruthy();
- expect(wrapperWithNoStubs.emitted('fetch-token-values')[1]).toEqual(['foo']);
+ expect(wrapperWithNoStubs.emitted('fetch-token-values')[2]).toEqual(['foo']);
});
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
index dd1c61b92b8..cc40ff96b65 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
@@ -40,6 +40,7 @@ function createComponent(options = {}) {
value = { data: '' },
active = false,
stubs = defaultStubs,
+ listeners = {},
} = options;
return mount(LabelToken, {
propsData: {
@@ -53,6 +54,7 @@ function createComponent(options = {}) {
suggestionsListClass: 'custom-class',
},
stubs,
+ listeners,
});
}
@@ -206,7 +208,7 @@ describe('LabelToken', () => {
expect(wrapper.find(GlDropdownDivider).exists()).toBe(false);
});
- it('renders `DEFAULT_LABELS` as default suggestions', async () => {
+ it('renders `DEFAULT_LABELS` as default suggestions', () => {
wrapper = createComponent({
active: true,
config: { ...mockLabelToken },
@@ -215,7 +217,6 @@ describe('LabelToken', () => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
- await wrapper.vm.$nextTick();
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
@@ -224,5 +225,17 @@ describe('LabelToken', () => {
expect(suggestions.at(index).text()).toBe(label.text);
});
});
+
+ it('emits listeners in the base-token', () => {
+ const mockInput = jest.fn();
+ wrapper = createComponent({
+ listeners: {
+ input: mockInput,
+ },
+ });
+ wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
+
+ expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js
new file mode 100644
index 00000000000..0a42d389b67
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js
@@ -0,0 +1,91 @@
+import { GlIcon, GlButton } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue';
+
+import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+let store;
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig) => {
+ store = new Vuex.Store(labelSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownButton, {
+ localVue,
+ store,
+ });
+};
+
+describe('DropdownButton', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDropdownButton = () => wrapper.find(GlButton);
+ const findDropdownText = () => wrapper.find('.dropdown-toggle-text');
+ const findDropdownIcon = () => wrapper.find(GlIcon);
+
+ describe('methods', () => {
+ describe('handleButtonClick', () => {
+ it.each`
+ variant | expectPropagationStopped
+ ${'standalone'} | ${true}
+ ${'embedded'} | ${false}
+ `(
+ 'toggles dropdown content and handles event propagation when `state.variant` is "$variant"',
+ ({ variant, expectPropagationStopped }) => {
+ const event = { stopPropagation: jest.fn() };
+
+ wrapper = createComponent({ ...mockConfig, variant });
+
+ findDropdownButton().vm.$emit('click', event);
+
+ expect(store.state.showDropdownContents).toBe(true);
+ expect(event.stopPropagation).toHaveBeenCalledTimes(expectPropagationStopped ? 1 : 0);
+ },
+ );
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element', () => {
+ expect(wrapper.find(GlButton).element).toBe(wrapper.element);
+ });
+
+ it('renders default button text element', () => {
+ const dropdownTextEl = findDropdownText();
+
+ expect(dropdownTextEl.exists()).toBe(true);
+ expect(dropdownTextEl.text()).toBe('Label');
+ });
+
+ it('renders provided button text element', () => {
+ store.state.dropdownButtonText = 'Custom label';
+ const dropdownTextEl = findDropdownText();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(dropdownTextEl.text()).toBe('Custom label');
+ });
+ });
+
+ it('renders chevron icon element', () => {
+ const iconEl = findDropdownIcon();
+
+ expect(iconEl.exists()).toBe(true);
+ expect(iconEl.props('name')).toBe('chevron-down');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
new file mode 100644
index 00000000000..46a11bc28d8
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
@@ -0,0 +1,173 @@
+import { GlLoadingIcon, GlLink } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue';
+import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql';
+import { mockSuggestedColors, createLabelSuccessfulResponse } from './mock_data';
+
+jest.mock('~/flash');
+
+const colors = Object.keys(mockSuggestedColors);
+
+const localVue = createLocalVue();
+Vue.use(VueApollo);
+
+const userRecoverableError = {
+ ...createLabelSuccessfulResponse,
+ errors: ['Houston, we have a problem'],
+};
+
+const createLabelSuccessHandler = jest.fn().mockResolvedValue(createLabelSuccessfulResponse);
+const createLabelUserRecoverableErrorHandler = jest.fn().mockResolvedValue(userRecoverableError);
+const createLabelErrorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
+
+describe('DropdownContentsCreateView', () => {
+ let wrapper;
+
+ const findAllColors = () => wrapper.findAllComponents(GlLink);
+ const findSelectedColor = () => wrapper.find('[data-testid="selected-color"]');
+ const findSelectedColorText = () => wrapper.find('[data-testid="selected-color-text"]');
+ const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
+ const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
+ const findLabelTitleInput = () => wrapper.find('[data-testid="label-title-input"]');
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ const fillLabelAttributes = () => {
+ findLabelTitleInput().vm.$emit('input', 'Test title');
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ };
+
+ const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => {
+ const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]);
+
+ wrapper = shallowMount(DropdownContentsCreateView, {
+ localVue,
+ apolloProvider: mockApollo,
+ });
+ };
+
+ beforeEach(() => {
+ gon.suggested_label_colors = mockSuggestedColors;
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders a palette of 21 colors', () => {
+ createComponent();
+ expect(findAllColors()).toHaveLength(21);
+ });
+
+ it('selects a color after clicking on colored block', async () => {
+ createComponent();
+ expect(findSelectedColor().attributes('style')).toBeUndefined();
+
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findSelectedColor().attributes('style')).toBe('background-color: rgb(0, 153, 102);');
+ });
+
+ it('shows correct color hex code after selecting a color', async () => {
+ createComponent();
+ expect(findSelectedColorText().attributes('value')).toBe('');
+
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findSelectedColorText().attributes('value')).toBe(colors[0]);
+ });
+
+ it('disables a Create button if label title is not set', async () => {
+ createComponent();
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findCreateButton().props('disabled')).toBe(true);
+ });
+
+ it('disables a Create button if color is not set', async () => {
+ createComponent();
+ findLabelTitleInput().vm.$emit('input', 'Test title');
+ await nextTick();
+
+ expect(findCreateButton().props('disabled')).toBe(true);
+ });
+
+ it('does not render a loader spinner', () => {
+ createComponent();
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('emits a `hideCreateView` event on Cancel button click', () => {
+ createComponent();
+ findCancelButton().vm.$emit('click');
+
+ expect(wrapper.emitted('hideCreateView')).toHaveLength(1);
+ });
+
+ describe('when label title and selected color are set', () => {
+ beforeEach(() => {
+ createComponent();
+ fillLabelAttributes();
+ });
+
+ it('enables a Create button', () => {
+ expect(findCreateButton().props('disabled')).toBe(false);
+ });
+
+ it('calls a mutation with correct parameters on Create button click', () => {
+ findCreateButton().vm.$emit('click');
+ expect(createLabelSuccessHandler).toHaveBeenCalledWith({
+ color: '#009966',
+ projectPath: '',
+ title: 'Test title',
+ });
+ });
+
+ it('renders a loader spinner after Create button click', async () => {
+ findCreateButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not loader spinner after mutation is resolved', async () => {
+ findCreateButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ it('calls createFlash is mutation has a user-recoverable error', async () => {
+ createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler });
+ fillLabelAttributes();
+ await nextTick();
+
+ findCreateButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+
+ it('calls createFlash is mutation was rejected', async () => {
+ createComponent({ mutationHandler: createLabelErrorHandler });
+ fillLabelAttributes();
+ await nextTick();
+
+ findCreateButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
new file mode 100644
index 00000000000..51301387c99
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
@@ -0,0 +1,357 @@
+import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue';
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
+
+import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions';
+import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters';
+import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations';
+import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state';
+
+import { mockConfig, mockLabels, mockRegularLabel } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('DropdownContentsLabelsView', () => {
+ let wrapper;
+
+ const createComponent = (initialState = mockConfig) => {
+ const store = new Vuex.Store({
+ getters,
+ mutations,
+ state: {
+ ...defaultState(),
+ footerCreateLabelTitle: 'Create label',
+ footerManageLabelTitle: 'Manage labels',
+ },
+ actions: {
+ ...actions,
+ fetchLabels: jest.fn(),
+ },
+ });
+
+ store.dispatch('setInitialState', initialState);
+ store.dispatch('receiveLabelsSuccess', mockLabels);
+
+ wrapper = shallowMount(DropdownContentsLabelsView, {
+ localVue,
+ store,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]');
+ const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]');
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ describe('computed', () => {
+ describe('visibleLabels', () => {
+ it('returns matching labels filtered with `searchKey`', () => {
+ wrapper.setData({
+ searchKey: 'bug',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(1);
+ expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ });
+
+ it('returns matching labels with fuzzy filtering', () => {
+ wrapper.setData({
+ searchKey: 'bg',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(2);
+ expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ expect(wrapper.vm.visibleLabels[1].title).toBe('Boog');
+ });
+
+ it('returns all labels when `searchKey` is empty', () => {
+ wrapper.setData({
+ searchKey: '',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length);
+ });
+ });
+
+ describe('showNoMatchingResultsMessage', () => {
+ it.each`
+ searchKey | labels | labelsDescription | returnValue
+ ${''} | ${[]} | ${'empty'} | ${false}
+ ${'bug'} | ${[]} | ${'empty'} | ${true}
+ ${''} | ${mockLabels} | ${'not empty'} | ${false}
+ ${'bug'} | ${mockLabels} | ${'not empty'} | ${false}
+ `(
+ 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription',
+ async ({ searchKey, labels, returnValue }) => {
+ wrapper.setData({
+ searchKey,
+ });
+
+ wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue);
+ },
+ );
+ });
+ });
+
+ describe('methods', () => {
+ describe('isLabelSelected', () => {
+ it('returns true when provided `label` param is one of the selected labels', () => {
+ expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true);
+ });
+
+ it('returns false when provided `label` param is not one of the selected labels', () => {
+ expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false);
+ });
+ });
+
+ describe('handleComponentAppear', () => {
+ it('calls `focusInput` on searchInput field', async () => {
+ wrapper.vm.$refs.searchInput.focusInput = jest.fn();
+
+ await wrapper.vm.handleComponentAppear();
+
+ expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleComponentDisappear', () => {
+ it('calls action `receiveLabelsSuccess` with empty array', () => {
+ jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
+
+ wrapper.vm.handleComponentDisappear();
+
+ expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
+ });
+ });
+
+ describe('handleCreateLabelClick', () => {
+ it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', () => {
+ jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
+ jest.spyOn(wrapper.vm, 'toggleDropdownContentsCreateView');
+
+ wrapper.vm.handleCreateLabelClick();
+
+ expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
+ expect(wrapper.vm.toggleDropdownContentsCreateView).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleKeyDown', () => {
+ it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: UP_KEY_CODE,
+ });
+
+ expect(wrapper.vm.currentHighlightItem).toBe(0);
+ });
+
+ it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: DOWN_KEY_CODE,
+ });
+
+ expect(wrapper.vm.currentHighlightItem).toBe(2);
+ });
+
+ it('resets the search text when the Enter key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ searchKey: 'bug',
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ENTER_KEY_CODE,
+ });
+
+ expect(wrapper.vm.searchKey).toBe('');
+ });
+
+ it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ENTER_KEY_CODE,
+ });
+
+ expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([
+ {
+ ...mockLabels[1],
+ set: true,
+ },
+ ]);
+ });
+
+ it('calls action `toggleDropdownContents` when Esc key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ESC_KEY_CODE,
+ });
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ });
+
+ it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: DOWN_KEY_CODE,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.scrollIntoViewIfNeeded).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('handleLabelClick', () => {
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
+ });
+
+ it('calls action `updateSelectedLabels` with provided `label` param', () => {
+ wrapper.vm.handleLabelClick(mockRegularLabel);
+
+ expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]);
+ });
+
+ it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => {
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents');
+ wrapper.vm.$store.state.allowMultiselect = false;
+
+ wrapper.vm.handleLabelClick(mockRegularLabel);
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders gl-intersection-observer as component root', () => {
+ expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true);
+ });
+
+ it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => {
+ wrapper.vm.$store.dispatch('requestLabels');
+
+ return wrapper.vm.$nextTick(() => {
+ const loadingIconEl = findLoadingIcon();
+
+ expect(loadingIconEl.exists()).toBe(true);
+ expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading');
+ });
+ });
+
+ it('renders label search input element', () => {
+ const searchInputEl = wrapper.find(GlSearchBoxByType);
+
+ expect(searchInputEl.exists()).toBe(true);
+ });
+
+ it('renders label elements for all labels', () => {
+ expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length);
+ });
+
+ it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => {
+ wrapper.setData({
+ currentHighlightItem: 0,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const labelItemEl = findDropdownContent().find(LabelItem);
+
+ expect(labelItemEl.attributes('highlight')).toBe('true');
+ });
+ });
+
+ it('renders element containing "No matching results" when `searchKey` does not match with any label', () => {
+ wrapper.setData({
+ searchKey: 'abc',
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const noMatchEl = findDropdownContent().find('li');
+
+ expect(noMatchEl.isVisible()).toBe(true);
+ expect(noMatchEl.text()).toContain('No matching results');
+ });
+ });
+
+ it('renders empty content while loading', () => {
+ wrapper.vm.$store.state.labelsFetchInProgress = true;
+
+ return wrapper.vm.$nextTick(() => {
+ const dropdownContent = findDropdownContent();
+ const loadingIcon = findLoadingIcon();
+
+ expect(dropdownContent.exists()).toBe(true);
+ expect(dropdownContent.isVisible()).toBe(true);
+ expect(loadingIcon.exists()).toBe(true);
+ expect(loadingIcon.isVisible()).toBe(true);
+ });
+ });
+
+ it('renders footer list items', () => {
+ const footerLinks = findDropdownFooter().findAll(GlLink);
+ const createLabelLink = footerLinks.at(0);
+ const manageLabelsLink = footerLinks.at(1);
+
+ expect(createLabelLink.exists()).toBe(true);
+ expect(createLabelLink.text()).toBe('Create label');
+ expect(manageLabelsLink.exists()).toBe(true);
+ expect(manageLabelsLink.text()).toBe('Manage labels');
+ });
+
+ it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', () => {
+ wrapper.vm.$store.state.allowLabelCreate = false;
+
+ return wrapper.vm.$nextTick(() => {
+ const createLabelLink = findDropdownFooter().findAll(GlLink).at(0);
+
+ expect(createLabelLink.text()).not.toBe('Create label');
+ });
+ });
+
+ it('does not render footer list items when `state.variant` is "standalone"', () => {
+ createComponent({ ...mockConfig, variant: 'standalone' });
+ expect(findDropdownFooter().exists()).toBe(false);
+ });
+
+ it('renders footer list items when `state.variant` is "embedded"', () => {
+ expect(findDropdownFooter().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
new file mode 100644
index 00000000000..8273bbdf7a7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
@@ -0,0 +1,72 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
+import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig, defaultProps = {}) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownContents, {
+ propsData: {
+ ...defaultProps,
+ labelsCreateTitle: 'test',
+ },
+ localVue,
+ store,
+ });
+};
+
+describe('DropdownContent', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('computed', () => {
+ describe('dropdownContentsView', () => {
+ it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => {
+ wrapper.vm.$store.dispatch('toggleDropdownContentsCreateView');
+
+ expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-create-view');
+ });
+
+ it('returns string "dropdown-contents-labels-view" when `showDropdownContentsCreateView` prop is `false`', () => {
+ expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-labels-view');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element with class `labels-select-dropdown-contents` and no styles', () => {
+ expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents');
+ expect(wrapper.attributes('style')).toBeUndefined();
+ });
+
+ describe('when `renderOnTop` is true', () => {
+ it.each`
+ variant | expected
+ ${DropdownVariant.Sidebar} | ${'bottom: 3rem'}
+ ${DropdownVariant.Standalone} | ${'bottom: 2rem'}
+ ${DropdownVariant.Embedded} | ${'bottom: 2rem'}
+ `('renders upward for $variant variant', ({ variant, expected }) => {
+ wrapper = createComponent({ ...mockConfig, variant }, { renderOnTop: true });
+
+ expect(wrapper.attributes('style')).toContain(expected);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js
new file mode 100644
index 00000000000..d2401a1f725
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js
@@ -0,0 +1,61 @@
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownTitle, {
+ localVue,
+ store,
+ propsData: {
+ labelsSelectInProgress: false,
+ },
+ });
+};
+
+describe('DropdownTitle', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ it('renders component container element with string "Labels"', () => {
+ expect(wrapper.text()).toContain('Labels');
+ });
+
+ it('renders edit link', () => {
+ const editBtnEl = wrapper.find(GlButton);
+
+ expect(editBtnEl.exists()).toBe(true);
+ expect(editBtnEl.text()).toBe('Edit');
+ });
+
+ it('renders loading icon element when `labelsSelectInProgress` prop is true', () => {
+ wrapper.setProps({
+ labelsSelectInProgress: true,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
new file mode 100644
index 00000000000..59f3268c000
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
@@ -0,0 +1,88 @@
+import { GlLabel } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('DropdownValue', () => {
+ let wrapper;
+
+ const createComponent = (initialState = {}, slots = {}) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', { ...mockConfig, ...initialState });
+
+ wrapper = shallowMount(DropdownValue, {
+ localVue,
+ store,
+ slots,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('methods', () => {
+ describe('labelFilterUrl', () => {
+ it('returns a label filter URL based on provided label param', () => {
+ createComponent();
+
+ expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe(
+ '/gitlab-org/my-project/issues?label_name[]=Foo%20Label',
+ );
+ });
+ });
+
+ describe('scopedLabel', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('returns `true` when provided label param is a scoped label', () => {
+ expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true);
+ });
+
+ it('returns `false` when provided label param is a regular label', () => {
+ expect(wrapper.vm.scopedLabel(mockRegularLabel)).toBe(false);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => {
+ createComponent();
+
+ expect(wrapper.attributes('class')).toContain('has-labels');
+ });
+
+ it('renders element containing `None` when `selectedLabels` is empty', () => {
+ createComponent(
+ {
+ selectedLabels: [],
+ },
+ {
+ default: 'None',
+ },
+ );
+ const noneEl = wrapper.find('span.text-secondary');
+
+ expect(noneEl.exists()).toBe(true);
+ expect(noneEl.text()).toBe('None');
+ });
+
+ it('renders labels when `selectedLabels` is not empty', () => {
+ createComponent();
+
+ expect(wrapper.findAll(GlLabel).length).toBe(2);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js
new file mode 100644
index 00000000000..23810339833
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js
@@ -0,0 +1,84 @@
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
+import { mockRegularLabel } from './mock_data';
+
+const mockLabel = { ...mockRegularLabel, set: true };
+
+const createComponent = ({
+ label = mockLabel,
+ isLabelSet = mockLabel.set,
+ highlight = true,
+} = {}) =>
+ shallowMount(LabelItem, {
+ propsData: {
+ label,
+ isLabelSet,
+ highlight,
+ },
+ });
+
+describe('LabelItem', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ it('renders gl-link component', () => {
+ expect(wrapper.find(GlLink).exists()).toBe(true);
+ });
+
+ it('renders component root with class `is-focused` when `highlight` prop is true', () => {
+ const wrapperTemp = createComponent({
+ highlight: true,
+ });
+
+ expect(wrapperTemp.classes()).toContain('is-focused');
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders visible gl-icon component when `isLabelSet` prop is true', () => {
+ const wrapperTemp = createComponent({
+ isLabelSet: true,
+ });
+
+ const iconEl = wrapperTemp.find(GlIcon);
+
+ expect(iconEl.isVisible()).toBe(true);
+ expect(iconEl.props('name')).toBe('mobile-issue-close');
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => {
+ const wrapperTemp = createComponent({
+ isLabelSet: false,
+ });
+
+ const placeholderEl = wrapperTemp.find('[data-testid="no-icon"]');
+
+ expect(placeholderEl.isVisible()).toBe(true);
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders label color element', () => {
+ const colorEl = wrapper.find('[data-testid="label-color-box"]');
+
+ expect(colorEl.exists()).toBe(true);
+ expect(colorEl.attributes('style')).toBe('background-color: rgb(186, 218, 85);');
+ });
+
+ it('renders label title', () => {
+ expect(wrapper.text()).toContain(mockLabel.title);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
new file mode 100644
index 00000000000..ee1346c362f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
@@ -0,0 +1,241 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import { isInViewport } from '~/lib/utils/common_utils';
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
+import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue';
+import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
+import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue';
+import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue';
+import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue';
+import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ isInViewport: jest.fn().mockReturnValue(true),
+}));
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('LabelsSelectRoot', () => {
+ let wrapper;
+ let store;
+
+ const createComponent = (config = mockConfig, slots = {}) => {
+ wrapper = shallowMount(LabelsSelectRoot, {
+ localVue,
+ slots,
+ store,
+ propsData: config,
+ stubs: {
+ 'dropdown-contents': DropdownContents,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ store = new Vuex.Store(labelsSelectModule());
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('methods', () => {
+ describe('handleVuexActionDispatch', () => {
+ it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => {
+ createComponent();
+ jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
+
+ wrapper.vm.handleVuexActionDispatch(
+ { type: 'toggleDropdownContents' },
+ {
+ showDropdownButton: false,
+ showDropdownContents: false,
+ labels: [{ id: 1 }, { id: 2, touched: true }],
+ },
+ );
+
+ expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ {
+ id: 2,
+ touched: true,
+ },
+ ]),
+ );
+ });
+
+ it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => {
+ createComponent({
+ ...mockConfig,
+ variant: 'embedded',
+ });
+
+ jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
+
+ wrapper.vm.handleVuexActionDispatch(
+ { type: 'toggleDropdownContents' },
+ {
+ showDropdownButton: false,
+ showDropdownContents: false,
+ labels: [{ id: 1 }, { id: 2, set: true }],
+ },
+ );
+
+ expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ {
+ id: 2,
+ set: true,
+ },
+ ]),
+ );
+ });
+ });
+
+ describe('handleDropdownClose', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => {
+ wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]);
+
+ expect(wrapper.emitted().updateSelectedLabels).toBeTruthy();
+ expect(wrapper.emitted().onDropdownClose).toBeTruthy();
+ });
+
+ it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => {
+ wrapper.vm.handleDropdownClose([]);
+
+ expect(wrapper.emitted().updateSelectedLabels).toBeFalsy();
+ expect(wrapper.emitted().onDropdownClose).toBeTruthy();
+ });
+ });
+
+ describe('handleCollapsedValueClick', () => {
+ it('emits `toggleCollapse` event on component', () => {
+ createComponent();
+ wrapper.vm.handleCollapsedValueClick();
+
+ expect(wrapper.emitted().toggleCollapse).toBeTruthy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component with classes `labels-select-wrapper position-relative`', () => {
+ createComponent();
+ expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
+ });
+
+ it.each`
+ variant | cssClass
+ ${'standalone'} | ${'is-standalone'}
+ ${'embedded'} | ${'is-embedded'}
+ `(
+ 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"',
+ ({ variant, cssClass }) => {
+ createComponent({
+ ...mockConfig,
+ variant,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.classes()).toContain(cssClass);
+ });
+ },
+ );
+
+ it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => {
+ createComponent();
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
+ });
+
+ it('renders `dropdown-title` component', async () => {
+ createComponent();
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownTitle).exists()).toBe(true);
+ });
+
+ it('renders `dropdown-value` component', async () => {
+ createComponent(mockConfig, {
+ default: 'None',
+ });
+ await wrapper.vm.$nextTick;
+
+ const valueComp = wrapper.find(DropdownValue);
+
+ expect(valueComp.exists()).toBe(true);
+ expect(valueComp.text()).toBe('None');
+ });
+
+ it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => {
+ createComponent();
+ wrapper.vm.$store.dispatch('toggleDropdownButton');
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownButton).exists()).toBe(true);
+ });
+
+ it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => {
+ createComponent();
+ wrapper.vm.$store.dispatch('toggleDropdownContents');
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownContents).exists()).toBe(true);
+ });
+
+ describe('sets content direction based on viewport', () => {
+ describe.each(Object.values(DropdownVariant))(
+ 'when labels variant is "%s"',
+ ({ variant }) => {
+ beforeEach(() => {
+ createComponent({ ...mockConfig, variant });
+ wrapper.vm.$store.dispatch('toggleDropdownContents');
+ });
+
+ it('set direction when out of viewport', () => {
+ isInViewport.mockImplementation(() => false);
+ wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true);
+ });
+ });
+
+ it('does not set direction when inside of viewport', () => {
+ isInViewport.mockImplementation(() => true);
+ wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
+ });
+ });
+ },
+ );
+ });
+ });
+
+ it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => {
+ createComponent();
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ await wrapper.setProps({ isEditing: true });
+
+ expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents');
+ });
+
+ it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => {
+ createComponent();
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ await wrapper.setProps({ isEditing: false });
+
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
new file mode 100644
index 00000000000..9e29030fb56
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
@@ -0,0 +1,93 @@
+export const mockRegularLabel = {
+ id: 26,
+ title: 'Foo Label',
+ description: 'Foobar',
+ color: '#BADA55',
+ textColor: '#FFFFFF',
+};
+
+export const mockScopedLabel = {
+ id: 27,
+ title: 'Foo::Bar',
+ description: 'Foobar',
+ color: '#0033CC',
+ textColor: '#FFFFFF',
+};
+
+export const mockLabels = [
+ mockRegularLabel,
+ mockScopedLabel,
+ {
+ id: 28,
+ title: 'Bug',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
+ {
+ id: 29,
+ title: 'Boog',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
+];
+
+export const mockConfig = {
+ allowLabelEdit: true,
+ allowLabelCreate: true,
+ allowScopedLabels: true,
+ allowMultiselect: true,
+ labelsListTitle: 'Assign labels',
+ labelsCreateTitle: 'Create label',
+ variant: 'sidebar',
+ dropdownOnly: false,
+ selectedLabels: [mockRegularLabel, mockScopedLabel],
+ labelsSelectInProgress: false,
+ labelsFetchPath: '/gitlab-org/my-project/-/labels.json',
+ labelsManagePath: '/gitlab-org/my-project/-/labels',
+ labelsFilterBasePath: '/gitlab-org/my-project/issues',
+ labelsFilterParam: 'label_name',
+};
+
+export const mockSuggestedColors = {
+ '#009966': 'Green-cyan',
+ '#8fbc8f': 'Dark sea green',
+ '#3cb371': 'Medium sea green',
+ '#00b140': 'Green screen',
+ '#013220': 'Dark green',
+ '#6699cc': 'Blue-gray',
+ '#0000ff': 'Blue',
+ '#e6e6fa': 'Lavendar',
+ '#9400d3': 'Dark violet',
+ '#330066': 'Deep violet',
+ '#808080': 'Gray',
+ '#36454f': 'Charcoal grey',
+ '#f7e7ce': 'Champagne',
+ '#c21e56': 'Rose red',
+ '#cc338b': 'Magenta-pink',
+ '#dc143c': 'Crimson',
+ '#ff0000': 'Red',
+ '#cd5b45': 'Dark coral',
+ '#eee600': 'Titanium yellow',
+ '#ed9121': 'Carrot orange',
+ '#c39953': 'Aztec Gold',
+};
+
+export const createLabelSuccessfulResponse = {
+ data: {
+ labelCreate: {
+ label: {
+ id: 'gid://gitlab/ProjectLabel/126',
+ color: '#dc143c',
+ description: null,
+ descriptionHtml: '',
+ title: 'ewrwrwer',
+ textColor: '#FFFFFF',
+ __typename: 'Label',
+ },
+ errors: [],
+ __typename: 'LabelCreatePayload',
+ },
+ },
+};
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
new file mode 100644
index 00000000000..7ef4b769b6b
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
@@ -0,0 +1,176 @@
+import MockAdapter from 'axios-mock-adapter';
+
+import testAction from 'helpers/vuex_action_helper';
+import axios from '~/lib/utils/axios_utils';
+import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions';
+import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types';
+import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state';
+
+describe('LabelsSelect Actions', () => {
+ let state;
+ const mockInitialState = {
+ labels: [],
+ selectedLabels: [],
+ };
+
+ beforeEach(() => {
+ state = { ...defaultState() };
+ });
+
+ describe('setInitialState', () => {
+ it('sets initial store state', (done) => {
+ testAction(
+ actions.setInitialState,
+ mockInitialState,
+ state,
+ [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownButton', () => {
+ it('toggles dropdown button', (done) => {
+ testAction(
+ actions.toggleDropdownButton,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_BUTTON }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownContents', () => {
+ it('toggles dropdown contents', (done) => {
+ testAction(
+ actions.toggleDropdownContents,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_CONTENTS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownContentsCreateView', () => {
+ it('toggles dropdown create view', (done) => {
+ testAction(
+ actions.toggleDropdownContentsCreateView,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestLabels', () => {
+ it('sets value of `state.labelsFetchInProgress` to `true`', (done) => {
+ testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done);
+ });
+ });
+
+ describe('receiveLabelsSuccess', () => {
+ it('sets provided labels to `state.labels`', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ testAction(
+ actions.receiveLabelsSuccess,
+ labels,
+ state,
+ [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveLabelsFailure', () => {
+ beforeEach(() => {
+ setFixtures('<div class="flash-container"></div>');
+ });
+
+ it('sets value `state.labelsFetchInProgress` to `false`', (done) => {
+ testAction(
+ actions.receiveLabelsFailure,
+ {},
+ state,
+ [{ type: types.RECEIVE_SET_LABELS_FAILURE }],
+ [],
+ done,
+ );
+ });
+
+ it('shows flash error', () => {
+ actions.receiveLabelsFailure({ commit: () => {} });
+
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ 'Error fetching labels.',
+ );
+ });
+ });
+
+ describe('fetchLabels', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ state.labelsFetchPath = 'labels.json';
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('on success', () => {
+ it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+ mock.onGet(/labels.json/).replyOnce(200, labels);
+
+ testAction(
+ actions.fetchLabels,
+ {},
+ state,
+ [],
+ [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }],
+ done,
+ );
+ });
+ });
+
+ describe('on failure', () => {
+ it('dispatches `requestLabels` & `receiveLabelsFailure` actions', (done) => {
+ mock.onGet(/labels.json/).replyOnce(500, {});
+
+ testAction(
+ actions.fetchLabels,
+ {},
+ state,
+ [],
+ [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('updateSelectedLabels', () => {
+ it('updates `state.labels` based on provided `labels` param', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ testAction(
+ actions.updateSelectedLabels,
+ labels,
+ state,
+ [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js
new file mode 100644
index 00000000000..40eb0323146
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js
@@ -0,0 +1,59 @@
+import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters';
+
+describe('LabelsSelect Getters', () => {
+ describe('dropdownButtonText', () => {
+ it.each`
+ labelType | dropdownButtonText | expected
+ ${'default'} | ${''} | ${'Label'}
+ ${'custom'} | ${'Custom label'} | ${'Custom label'}
+ `(
+ 'returns $labelType text when state.labels has no selected labels',
+ ({ dropdownButtonText, expected }) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+ const selectedLabels = [];
+ const state = { labels, selectedLabels, dropdownButtonText };
+
+ expect(getters.dropdownButtonText(state, {})).toBe(expected);
+ },
+ );
+
+ it('returns label title when state.labels has only 1 label', () => {
+ const labels = [{ id: 1, title: 'Foobar', set: true }];
+
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foobar',
+ );
+ });
+
+ it('returns first label title and remaining labels count when state.labels has more than 1 label', () => {
+ const labels = [
+ { id: 1, title: 'Foo', set: true },
+ { id: 2, title: 'Bar', set: true },
+ ];
+
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foo +1 more',
+ );
+ });
+ });
+
+ describe('selectedLabelsList', () => {
+ it('returns array of IDs of all labels within `state.selectedLabels`', () => {
+ const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]);
+ });
+ });
+
+ describe('isDropdownVariantSidebar', () => {
+ it('returns `true` when `state.variant` is "sidebar"', () => {
+ expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true);
+ });
+ });
+
+ describe('isDropdownVariantStandalone', () => {
+ it('returns `true` when `state.variant` is "standalone"', () => {
+ expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
new file mode 100644
index 00000000000..acb275b5d90
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
@@ -0,0 +1,140 @@
+import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types';
+import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations';
+
+describe('LabelsSelect Mutations', () => {
+ describe(`${types.SET_INITIAL_STATE}`, () => {
+ it('initializes provided props to store state', () => {
+ const state = {};
+ mutations[types.SET_INITIAL_STATE](state, {
+ labels: 'foo',
+ });
+
+ expect(state.labels).toEqual('foo');
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_BUTTON}`, () => {
+ it('toggles value of `state.showDropdownButton`', () => {
+ const state = {
+ showDropdownButton: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_BUTTON](state);
+
+ expect(state.showDropdownButton).toBe(true);
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_CONTENTS}`, () => {
+ it('toggles value of `state.showDropdownButton` when `state.dropdownOnly` is false', () => {
+ const state = {
+ dropdownOnly: false,
+ showDropdownButton: false,
+ variant: 'sidebar',
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownButton).toBe(true);
+ });
+
+ it('toggles value of `state.showDropdownContents`', () => {
+ const state = {
+ showDropdownContents: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownContents).toBe(true);
+ });
+
+ it('sets value of `state.showDropdownContentsCreateView` to `false` when `showDropdownContents` is true', () => {
+ const state = {
+ showDropdownContents: false,
+ showDropdownContentsCreateView: true,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownContentsCreateView).toBe(false);
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW}`, () => {
+ it('toggles value of `state.showDropdownContentsCreateView`', () => {
+ const state = {
+ showDropdownContentsCreateView: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state);
+
+ expect(state.showDropdownContentsCreateView).toBe(true);
+ });
+ });
+
+ describe(`${types.REQUEST_LABELS}`, () => {
+ it('sets value of `state.labelsFetchInProgress` to true', () => {
+ const state = {
+ labelsFetchInProgress: false,
+ };
+ mutations[types.REQUEST_LABELS](state);
+
+ expect(state.labelsFetchInProgress).toBe(true);
+ });
+ });
+
+ describe(`${types.RECEIVE_SET_LABELS_SUCCESS}`, () => {
+ const selectedLabels = [{ id: 2 }, { id: 4 }];
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ it('sets value of `state.labelsFetchInProgress` to false', () => {
+ const state = {
+ selectedLabels,
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
+
+ expect(state.labelsFetchInProgress).toBe(false);
+ });
+
+ it('sets provided `labels` to `state.labels` along with `set` prop based on `state.selectedLabels`', () => {
+ const selectedLabelIds = selectedLabels.map((label) => label.id);
+ const state = {
+ selectedLabels,
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
+
+ state.labels.forEach((label) => {
+ if (selectedLabelIds.includes(label.id)) {
+ expect(label.set).toBe(true);
+ }
+ });
+ });
+ });
+
+ describe(`${types.RECEIVE_SET_LABELS_FAILURE}`, () => {
+ it('sets value of `state.labelsFetchInProgress` to false', () => {
+ const state = {
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_FAILURE](state);
+
+ expect(state.labelsFetchInProgress).toBe(false);
+ });
+ });
+
+ describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => {
+ const updatedLabelIds = [2];
+ const state = {
+ labels,
+ };
+ mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] });
+
+ state.labels.forEach((label) => {
+ if (updatedLabelIds.includes(label.id)) {
+ expect(label.touched).toBe(true);
+ expect(label.set).toBe(true);
+ }
+ });
+ });
+ });
+});
diff --git a/spec/serializers/merge_request_poll_widget_entity_spec.rb b/spec/serializers/merge_request_poll_widget_entity_spec.rb
index c88555226a9..9a0e25516cb 100644
--- a/spec/serializers/merge_request_poll_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_poll_widget_entity_spec.rb
@@ -229,21 +229,13 @@ RSpec.describe MergeRequestPollWidgetEntity do
expect(subject[:mergeable]).to eq(true)
end
- context 'when async_mergeability_check is passed' do
- let(:options) { { async_mergeability_check: true } }
-
- it 'returns false' do
- expect(subject[:mergeable]).to eq(false)
+ context 'when check_mergeability_async_in_widget is disabled' do
+ before do
+ stub_feature_flags(check_mergeability_async_in_widget: false)
end
- context 'when check_mergeability_async_in_widget is disabled' do
- before do
- stub_feature_flags(check_mergeability_async_in_widget: false)
- end
-
- it 'calculates mergeability and returns true' do
- expect(subject[:mergeable]).to eq(true)
- end
+ it 'calculates mergeability and returns true' do
+ expect(subject[:mergeable]).to eq(true)
end
end
end