diff options
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 |