diff options
Diffstat (limited to 'spec/frontend/sidebar/components/labels/labels_select_vue')
13 files changed, 2000 insertions, 0 deletions
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js new file mode 100644 index 00000000000..4f2a89e20db --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js @@ -0,0 +1,89 @@ +import { GlIcon, GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; + +import DropdownButton from '~/sidebar/components/labels/labels_select_vue/dropdown_button.vue'; + +import labelSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig } from './mock_data'; + +let store; +Vue.use(Vuex); + +const createComponent = (initialState = mockConfig) => { + store = new Vuex.Store(labelSelectModule()); + + store.dispatch('setInitialState', initialState); + + return shallowMount(DropdownButton, { + store, + }); +}; + +describe('DropdownButton', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownButton = () => wrapper.findComponent(GlButton); + const findDropdownText = () => wrapper.find('.dropdown-toggle-text'); + const findDropdownIcon = () => wrapper.findComponent(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.findComponent(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', async () => { + store.state.dropdownButtonText = 'Custom label'; + const dropdownTextEl = findDropdownText(); + + await nextTick(); + 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/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js new file mode 100644 index 00000000000..59e95edfa20 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js @@ -0,0 +1,211 @@ +import { GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; + +import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue'; + +import labelSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig, mockSuggestedColors } from './mock_data'; + +Vue.use(Vuex); + +const createComponent = (initialState = mockConfig) => { + const store = new Vuex.Store(labelSelectModule()); + + store.dispatch('setInitialState', initialState); + + return shallowMount(DropdownContentsCreateView, { + store, + }); +}; + +describe('DropdownContentsCreateView', () => { + let wrapper; + const colors = Object.keys(mockSuggestedColors).map((color) => ({ + [color]: mockSuggestedColors[color], + })); + + beforeEach(() => { + gon.suggested_label_colors = mockSuggestedColors; + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('disableCreate', () => { + it('returns `true` when label title and color is not defined', () => { + expect(wrapper.vm.disableCreate).toBe(true); + }); + + it('returns `true` when `labelCreateInProgress` is true', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + labelTitle: 'Foo', + selectedColor: '#ff0000', + }); + wrapper.vm.$store.dispatch('requestCreateLabel'); + + await nextTick(); + expect(wrapper.vm.disableCreate).toBe(true); + }); + + it('returns `false` when label title and color is defined and create request is not already in progress', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + labelTitle: 'Foo', + selectedColor: '#ff0000', + }); + + await nextTick(); + expect(wrapper.vm.disableCreate).toBe(false); + }); + }); + + describe('suggestedColors', () => { + it('returns array of color objects containing color code and name', () => { + colors.forEach((color, index) => { + expect(wrapper.vm.suggestedColors[index]).toEqual(expect.objectContaining(color)); + }); + }); + }); + }); + + describe('methods', () => { + describe('getColorCode', () => { + it('returns color code from color object', () => { + expect(wrapper.vm.getColorCode(colors[0])).toBe(Object.keys(colors[0]).pop()); + }); + }); + + describe('getColorName', () => { + it('returns color name from color object', () => { + expect(wrapper.vm.getColorName(colors[0])).toBe(Object.values(colors[0]).pop()); + }); + }); + + describe('handleColorClick', () => { + it('sets provided `color` param to `selectedColor` prop', () => { + wrapper.vm.handleColorClick(colors[0]); + + expect(wrapper.vm.selectedColor).toBe(Object.keys(colors[0]).pop()); + }); + }); + + describe('handleCreateClick', () => { + it('calls action `createLabel` with object containing `labelTitle` & `selectedColor`', async () => { + jest.spyOn(wrapper.vm, 'createLabel').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + labelTitle: 'Foo', + selectedColor: '#ff0000', + }); + + wrapper.vm.handleCreateClick(); + + await nextTick(); + expect(wrapper.vm.createLabel).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Foo', + color: '#ff0000', + }), + ); + }); + }); + }); + + describe('template', () => { + it('renders component container element with class "labels-select-contents-create"', () => { + expect(wrapper.attributes('class')).toContain('labels-select-contents-create'); + }); + + it('renders dropdown back button element', () => { + const backBtnEl = wrapper.find('.dropdown-title').findAllComponents(GlButton).at(0); + + expect(backBtnEl.exists()).toBe(true); + expect(backBtnEl.attributes('aria-label')).toBe('Go back'); + expect(backBtnEl.props('icon')).toBe('arrow-left'); + }); + + it('renders dropdown title element', () => { + const headerEl = wrapper.find('.dropdown-title > span'); + + expect(headerEl.exists()).toBe(true); + expect(headerEl.text()).toBe('Create label'); + }); + + it('renders dropdown close button element', () => { + const closeBtnEl = wrapper.find('.dropdown-title').findAllComponents(GlButton).at(1); + + expect(closeBtnEl.exists()).toBe(true); + expect(closeBtnEl.attributes('aria-label')).toBe('Close'); + expect(closeBtnEl.props('icon')).toBe('close'); + }); + + it('renders label title input element', () => { + const titleInputEl = wrapper.find('.dropdown-input').findComponent(GlFormInput); + + expect(titleInputEl.exists()).toBe(true); + expect(titleInputEl.attributes('placeholder')).toBe('Name new label'); + expect(titleInputEl.attributes('autofocus')).toBe('true'); + }); + + it('renders color block element for all suggested colors', () => { + const colorBlocksEl = wrapper.find('.dropdown-content').findAllComponents(GlLink); + + colorBlocksEl.wrappers.forEach((colorBlock, index) => { + expect(colorBlock.attributes('style')).toContain('background-color'); + expect(colorBlock.attributes('title')).toBe(Object.values(colors[index]).pop()); + }); + }); + + it('renders color input element', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + selectedColor: '#ff0000', + }); + + await nextTick(); + const colorPreviewEl = wrapper.find('.color-input-container > .dropdown-label-color-preview'); + const colorInputEl = wrapper.find('.color-input-container').findComponent(GlFormInput); + + expect(colorPreviewEl.exists()).toBe(true); + expect(colorPreviewEl.attributes('style')).toContain('background-color'); + expect(colorInputEl.exists()).toBe(true); + expect(colorInputEl.attributes('placeholder')).toBe('Use custom color #FF0000'); + expect(colorInputEl.attributes('value')).toBe('#ff0000'); + }); + + it('renders create button element', () => { + const createBtnEl = wrapper.find('.dropdown-actions').findAllComponents(GlButton).at(0); + + expect(createBtnEl.exists()).toBe(true); + expect(createBtnEl.text()).toContain('Create'); + }); + + it('shows gl-loading-icon within create button element when `labelCreateInProgress` is `true`', async () => { + wrapper.vm.$store.dispatch('requestCreateLabel'); + + await nextTick(); + const loadingIconEl = wrapper.find('.dropdown-actions').findComponent(GlLoadingIcon); + + expect(loadingIconEl.exists()).toBe(true); + expect(loadingIconEl.isVisible()).toBe(true); + }); + + it('renders cancel button element', () => { + const cancelBtnEl = wrapper.find('.dropdown-actions').findAllComponents(GlButton).at(1); + + expect(cancelBtnEl.exists()).toBe(true); + expect(cancelBtnEl.text()).toContain('Cancel'); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js new file mode 100644 index 00000000000..865dc8fe8fb --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -0,0 +1,413 @@ +import { + GlIntersectionObserver, + GlButton, + GlLoadingIcon, + GlSearchBoxByType, + GlLink, +} from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; +import DropdownContentsLabelsView from '~/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue'; +import LabelItem from '~/sidebar/components/labels/labels_select_vue/label_item.vue'; + +import * as actions from '~/sidebar/components/labels/labels_select_vue/store/actions'; +import * as getters from '~/sidebar/components/labels/labels_select_vue/store/getters'; +import mutations from '~/sidebar/components/labels/labels_select_vue/store/mutations'; +import defaultState from '~/sidebar/components/labels/labels_select_vue/store/state'; + +import { mockConfig, mockLabels, mockRegularLabel } from './mock_data'; + +Vue.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, { + store, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]'); + const findDropdownTitle = () => wrapper.find('[data-testid="dropdown-title"]'); + const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + describe('computed', () => { + describe('visibleLabels', () => { + it('returns matching labels filtered with `searchKey`', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + 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', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + 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', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + 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 }) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + searchKey, + }); + + wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels); + + await nextTick(); + + expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue); + }, + ); + }); + }); + + describe('methods', () => { + const fakePreventDefault = jest.fn(); + + 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[1])).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', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + 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', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + 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', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + currentHighlightItem: 1, + searchKey: 'bug', + }); + + wrapper.vm.handleKeyDown({ + keyCode: ENTER_KEY_CODE, + preventDefault: fakePreventDefault, + }); + + expect(wrapper.vm.searchKey).toBe(''); + expect(fakePreventDefault).toHaveBeenCalled(); + }); + + it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => { + jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + currentHighlightItem: 2, + }); + + wrapper.vm.handleKeyDown({ + keyCode: ENTER_KEY_CODE, + preventDefault: fakePreventDefault, + }); + + expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockLabels[2]]); + }); + + it('calls action `toggleDropdownContents` when Esc key is pressed', () => { + jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + 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', async () => { + jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + currentHighlightItem: 1, + }); + + wrapper.vm.handleKeyDown({ + keyCode: DOWN_KEY_CODE, + }); + + await 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.findComponent(GlIntersectionObserver).exists()).toBe(true); + }); + + it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', async () => { + wrapper.vm.$store.dispatch('requestLabels'); + + await nextTick(); + const loadingIconEl = findLoadingIcon(); + + expect(loadingIconEl.exists()).toBe(true); + expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading'); + }); + + it('renders dropdown title element', () => { + const titleEl = findDropdownTitle(); + + expect(titleEl.exists()).toBe(true); + expect(titleEl.text()).toBe('Assign labels'); + }); + + it('does not render dropdown title element when `state.variant` is "standalone"', () => { + createComponent({ ...mockConfig, variant: 'standalone' }); + expect(findDropdownTitle().exists()).toBe(false); + }); + + it('renders dropdown title element when `state.variant` is "embedded"', () => { + createComponent({ ...mockConfig, variant: 'embedded' }); + expect(findDropdownTitle().exists()).toBe(true); + }); + + it('renders dropdown close button element', () => { + const closeButtonEl = findDropdownTitle().findComponent(GlButton); + + expect(closeButtonEl.exists()).toBe(true); + expect(closeButtonEl.props('icon')).toBe('close'); + }); + + it('renders label search input element', () => { + const searchInputEl = wrapper.findComponent(GlSearchBoxByType); + + expect(searchInputEl.exists()).toBe(true); + }); + + it('renders label elements for all labels', () => { + expect(wrapper.findAllComponents(LabelItem)).toHaveLength(mockLabels.length); + }); + + it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + currentHighlightItem: 0, + }); + + await nextTick(); + const labelItemEl = findDropdownContent().findComponent(LabelItem); + + expect(labelItemEl.attributes('highlight')).toBe('true'); + }); + + it('renders element containing "No matching results" when `searchKey` does not match with any label', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + searchKey: 'abc', + }); + + await nextTick(); + const noMatchEl = findDropdownContent().find('li'); + + expect(noMatchEl.isVisible()).toBe(true); + expect(noMatchEl.text()).toContain('No matching results'); + }); + + it('renders empty content while loading', async () => { + wrapper.vm.$store.state.labelsFetchInProgress = true; + + await 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().findAllComponents(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`', async () => { + wrapper.vm.$store.state.allowLabelCreate = false; + + await nextTick(); + const createLabelLink = findDropdownFooter().findAllComponents(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('does not render footer list items when `allowLabelCreate` is false and `labelsManagePath` is null', () => { + createComponent({ + ...mockConfig, + allowLabelCreate: false, + labelsManagePath: null, + }); + 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/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js new file mode 100644 index 00000000000..e9ffda7c251 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js @@ -0,0 +1,68 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; + +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants'; +import DropdownContents from '~/sidebar/components/labels/labels_select_vue/dropdown_contents.vue'; +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig } from './mock_data'; + +Vue.use(Vuex); + +const createComponent = (initialState = mockConfig, propsData = {}) => { + const store = new Vuex.Store(labelsSelectModule()); + + store.dispatch('setInitialState', initialState); + + return shallowMount(DropdownContents, { + propsData, + 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/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js new file mode 100644 index 00000000000..6c3fda421ff --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js @@ -0,0 +1,59 @@ +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; + +import DropdownTitle from '~/sidebar/components/labels/labels_select_vue/dropdown_title.vue'; + +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig } from './mock_data'; + +Vue.use(Vuex); + +const createComponent = (initialState = mockConfig) => { + const store = new Vuex.Store(labelsSelectModule()); + + store.dispatch('setInitialState', initialState); + + return shallowMount(DropdownTitle, { + 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.findComponent(GlButton); + + expect(editBtnEl.exists()).toBe(true); + expect(editBtnEl.text()).toBe('Edit'); + }); + + it('renders loading icon element when `labelsSelectInProgress` prop is true', async () => { + wrapper.setProps({ + labelsSelectInProgress: true, + }); + + await nextTick(); + expect(wrapper.findComponent(GlLoadingIcon).isVisible()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js new file mode 100644 index 00000000000..56f25a1c6a4 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js @@ -0,0 +1,75 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import DropdownValueCollapsedComponent from '~/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue'; + +import { mockCollapsedLabels as mockLabels, mockRegularLabel } from './mock_data'; + +describe('DropdownValueCollapsedComponent', () => { + let wrapper; + + const defaultProps = { + labels: [], + }; + + const mockManyLabels = [...mockLabels, ...mockLabels, ...mockLabels]; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(DropdownValueCollapsedComponent, { + propsData: { ...defaultProps, ...props }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlIcon = () => wrapper.findComponent(GlIcon); + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip'); + + describe('template', () => { + it('renders tags icon element', () => { + createComponent(); + + expect(findGlIcon().exists()).toBe(true); + }); + + it('emits onValueClick event on click', async () => { + createComponent(); + + wrapper.trigger('click'); + + await nextTick(); + + expect(wrapper.emitted('onValueClick')[0]).toBeDefined(); + }); + + describe.each` + scenario | labels | expectedResult | expectedText + ${'`labels` is empty'} | ${[]} | ${'default text'} | ${'Labels'} + ${'`labels` has 1 item'} | ${[mockRegularLabel]} | ${'label name'} | ${'Foo Label'} + ${'`labels` has 2 items'} | ${mockLabels} | ${'comma separated label names'} | ${'Foo Label, Foo::Bar'} + ${'`labels` has more than 5 items'} | ${mockManyLabels} | ${'comma separated label names with "and more" phrase'} | ${'Foo Label, Foo::Bar, Foo Label, Foo::Bar, Foo Label, and 1 more'} + `('when $scenario', ({ labels, expectedResult, expectedText }) => { + beforeEach(() => { + createComponent({ + props: { + labels, + }, + }); + }); + + it('renders labels count', () => { + expect(wrapper.text()).toBe(`${labels.length}`); + }); + + it(`renders "${expectedResult}" as tooltip`, () => { + expect(getTooltip().value).toBe(expectedText); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js new file mode 100644 index 00000000000..a1ccc9d2ab1 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js @@ -0,0 +1,99 @@ +import { GlLabel } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; + +import DropdownValue from '~/sidebar/components/labels/labels_select_vue/dropdown_value.vue'; + +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig, mockLabels, mockRegularLabel, mockScopedLabel } from './mock_data'; + +Vue.use(Vuex); + +describe('DropdownValue', () => { + let wrapper; + + const findAllLabels = () => wrapper.findAllComponents(GlLabel); + const findLabel = (index) => findAllLabels().at(index).props('title'); + + const createComponent = (initialState = {}, slots = {}) => { + const store = new Vuex.Store(labelsSelectModule()); + + store.dispatch('setInitialState', { ...mockConfig, ...initialState }); + + wrapper = shallowMount(DropdownValue, { + store, + slots, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + 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(findAllLabels()).toHaveLength(2); + }); + + it('orders scoped labels first', () => { + createComponent({ selectedLabels: mockLabels }); + + expect(findAllLabels()).toHaveLength(mockLabels.length); + expect(findLabel(0)).toBe('Foo::Bar'); + expect(findLabel(1)).toBe('Boog'); + expect(findLabel(2)).toBe('Bug'); + expect(findLabel(3)).toBe('Foo Label'); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js new file mode 100644 index 00000000000..e14c0e308ce --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js @@ -0,0 +1,92 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import LabelItem from '~/sidebar/components/labels/labels_select_vue/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.findComponent(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.each` + isLabelSet | isLabelIndeterminate | testId | iconName + ${true} | ${false} | ${'checked-icon'} | ${'mobile-issue-close'} + ${false} | ${true} | ${'indeterminate-icon'} | ${'dash'} + `( + 'renders visible gl-icon component when `isLabelSet` prop is $isLabelSet and `isLabelIndeterminate` is $isLabelIndeterminate', + ({ isLabelSet, isLabelIndeterminate, testId, iconName }) => { + const wrapperTemp = createComponent({ + isLabelSet, + isLabelIndeterminate, + }); + + const iconEl = wrapperTemp.find(`[data-testid="${testId}"]`); + + expect(iconEl.isVisible()).toBe(true); + expect(iconEl.props('name')).toBe(iconName); + + 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/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js new file mode 100644 index 00000000000..a3b10c18374 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js @@ -0,0 +1,231 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; + +import { isInViewport } from '~/lib/utils/common_utils'; +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants'; +import DropdownButton from '~/sidebar/components/labels/labels_select_vue/dropdown_button.vue'; +import DropdownContents from '~/sidebar/components/labels/labels_select_vue/dropdown_contents.vue'; +import DropdownTitle from '~/sidebar/components/labels/labels_select_vue/dropdown_title.vue'; +import DropdownValue from '~/sidebar/components/labels/labels_select_vue/dropdown_value.vue'; +import DropdownValueCollapsed from '~/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue'; +import LabelsSelectRoot from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue'; + +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig } from './mock_data'; + +jest.mock('~/lib/utils/common_utils', () => ({ + isInViewport: jest.fn().mockReturnValue(true), +})); + +Vue.use(Vuex); + +describe('LabelsSelectRoot', () => { + let wrapper; + let store; + + const createComponent = (config = mockConfig, slots = {}) => { + wrapper = shallowMount(LabelsSelectRoot, { + slots, + store, + propsData: config, + stubs: { + 'dropdown-contents': DropdownContents, + }, + }); + }; + + beforeEach(() => { + store = new Vuex.Store(labelsSelectModule()); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('methods', () => { + describe('handleVuexActionDispatch', () => { + const touchedLabels = [ + { + id: 2, + touched: true, + }, + ]; + + it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => { + createComponent(); + + wrapper.vm.handleVuexActionDispatch( + { type: 'toggleDropdownContents' }, + { + showDropdownButton: false, + showDropdownContents: false, + labels: [{ id: 1 }, { id: 2, touched: true }], + }, + ); + + // We're utilizing `onDropdownClose` event emitted from the component to always include `touchedLabels` + // while the first param of the method is the labels list which were added/removed. + expect(wrapper.emitted('updateSelectedLabels')).toHaveLength(1); + expect(wrapper.emitted('updateSelectedLabels')[0]).toEqual([touchedLabels]); + expect(wrapper.emitted('onDropdownClose')).toHaveLength(1); + expect(wrapper.emitted('onDropdownClose')[0]).toEqual([touchedLabels]); + }); + + it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => { + createComponent({ + ...mockConfig, + variant: 'embedded', + }); + + wrapper.vm.handleVuexActionDispatch( + { type: 'toggleDropdownContents' }, + { + showDropdownButton: false, + showDropdownContents: false, + labels: [{ id: 1 }, { id: 2, set: true }], + }, + ); + + expect(wrapper.emitted('updateSelectedLabels')).toHaveLength(1); + expect(wrapper.emitted('updateSelectedLabels')[0]).toEqual([ + [ + { + id: 2, + set: true, + }, + ], + ]); + expect(wrapper.emitted('onDropdownClose')).toHaveLength(1); + expect(wrapper.emitted('onDropdownClose')[0]).toEqual([[]]); + }); + }); + + describe('handleCollapsedValueClick', () => { + it('emits `toggleCollapse` event on component', () => { + createComponent(); + wrapper.vm.handleCollapsedValueClick(); + expect(wrapper.emitted().toggleCollapse).toHaveLength(1); + }); + }); + }); + + 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"', + async ({ variant, cssClass }) => { + createComponent({ + ...mockConfig, + variant, + }); + + await nextTick(); + expect(wrapper.classes()).toContain(cssClass); + }, + ); + + it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { + createComponent(); + await nextTick(); + expect(wrapper.findComponent(DropdownValueCollapsed).exists()).toBe(true); + }); + + it('renders `dropdown-title` component', async () => { + createComponent(); + await nextTick(); + expect(wrapper.findComponent(DropdownTitle).exists()).toBe(true); + }); + + it('renders `dropdown-value` component', async () => { + createComponent(mockConfig, { + default: 'None', + }); + await nextTick(); + + const valueComp = wrapper.findComponent(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 nextTick(); + expect(wrapper.findComponent(DropdownButton).exists()).toBe(true); + }); + + it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => { + createComponent(); + wrapper.vm.$store.dispatch('toggleDropdownContents'); + await nextTick(); + expect(wrapper.findComponent(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', async () => { + isInViewport.mockImplementation(() => false); + wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); + + await nextTick(); + expect(wrapper.findComponent(DropdownContents).props('renderOnTop')).toBe(true); + }); + + it('does not set direction when inside of viewport', async () => { + isInViewport.mockImplementation(() => true); + wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); + + await nextTick(); + expect(wrapper.findComponent(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(); + }); + + it('calls updateLabelsSetState after selected labels were updated', async () => { + createComponent(); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + await wrapper.setProps({ selectedLabels: [] }); + jest.advanceTimersByTime(100); + + expect(store.dispatch).toHaveBeenCalledWith('updateLabelsSetState'); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/mock_data.js b/spec/frontend/sidebar/components/labels/labels_select_vue/mock_data.js new file mode 100644 index 00000000000..884bc4684ba --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/mock_data.js @@ -0,0 +1,92 @@ +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 = [ + { + id: 29, + title: 'Boog', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }, + { + id: 28, + title: 'Bug', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }, + mockRegularLabel, + mockScopedLabel, +]; + +export const mockCollapsedLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, + { + id: 27, + title: 'Foo::Bar', + description: 'Foobar', + color: '#0033CC', + text_color: '#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': 'Lavender', + '#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', +}; diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js new file mode 100644 index 00000000000..0e0024aa6c2 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js @@ -0,0 +1,265 @@ +import MockAdapter from 'axios-mock-adapter'; + +import testAction from 'helpers/vuex_action_helper'; +import { createAlert } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import * as actions from '~/sidebar/components/labels/labels_select_vue/store/actions'; +import * as types from '~/sidebar/components/labels/labels_select_vue/store/mutation_types'; +import defaultState from '~/sidebar/components/labels/labels_select_vue/store/state'; + +jest.mock('~/flash'); + +describe('LabelsSelect Actions', () => { + let state; + const mockInitialState = { + labels: [], + selectedLabels: [], + }; + + beforeEach(() => { + state = { ...defaultState() }; + }); + + describe('setInitialState', () => { + it('sets initial store state', () => { + return testAction( + actions.setInitialState, + mockInitialState, + state, + [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }], + [], + ); + }); + }); + + describe('toggleDropdownButton', () => { + it('toggles dropdown button', () => { + return testAction( + actions.toggleDropdownButton, + {}, + state, + [{ type: types.TOGGLE_DROPDOWN_BUTTON }], + [], + ); + }); + }); + + describe('toggleDropdownContents', () => { + it('toggles dropdown contents', () => { + return testAction( + actions.toggleDropdownContents, + {}, + state, + [{ type: types.TOGGLE_DROPDOWN_CONTENTS }], + [], + ); + }); + }); + + describe('toggleDropdownContentsCreateView', () => { + it('toggles dropdown create view', () => { + return testAction( + actions.toggleDropdownContentsCreateView, + {}, + state, + [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }], + [], + ); + }); + }); + + describe('requestLabels', () => { + it('sets value of `state.labelsFetchInProgress` to `true`', () => { + return testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], []); + }); + }); + + describe('receiveLabelsSuccess', () => { + it('sets provided labels to `state.labels`', () => { + const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + + return testAction( + actions.receiveLabelsSuccess, + labels, + state, + [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }], + [], + ); + }); + }); + + describe('receiveLabelsFailure', () => { + it('sets value `state.labelsFetchInProgress` to `false`', () => { + return testAction( + actions.receiveLabelsFailure, + {}, + state, + [{ type: types.RECEIVE_SET_LABELS_FAILURE }], + [], + ); + }); + + it('shows flash error', () => { + actions.receiveLabelsFailure({ commit: () => {} }); + + expect(createAlert).toHaveBeenCalledWith({ message: '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', () => { + const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + mock.onGet(/labels.json/).replyOnce(200, labels); + + return testAction( + actions.fetchLabels, + {}, + state, + [], + [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }], + ); + }); + }); + + describe('on failure', () => { + it('dispatches `requestLabels` & `receiveLabelsFailure` actions', () => { + mock.onGet(/labels.json/).replyOnce(500, {}); + + return testAction( + actions.fetchLabels, + {}, + state, + [], + [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }], + ); + }); + }); + }); + + describe('requestCreateLabel', () => { + it('sets value `state.labelCreateInProgress` to `true`', () => { + return testAction( + actions.requestCreateLabel, + {}, + state, + [{ type: types.REQUEST_CREATE_LABEL }], + [], + ); + }); + }); + + describe('receiveCreateLabelSuccess', () => { + it('sets value `state.labelCreateInProgress` to `false`', () => { + return testAction( + actions.receiveCreateLabelSuccess, + {}, + state, + [{ type: types.RECEIVE_CREATE_LABEL_SUCCESS }], + [], + ); + }); + }); + + describe('receiveCreateLabelFailure', () => { + it('sets value `state.labelCreateInProgress` to `false`', () => { + return testAction( + actions.receiveCreateLabelFailure, + {}, + state, + [{ type: types.RECEIVE_CREATE_LABEL_FAILURE }], + [], + ); + }); + + it('shows flash error', () => { + actions.receiveCreateLabelFailure({ commit: () => {} }); + + expect(createAlert).toHaveBeenCalledWith({ message: 'Error creating label.' }); + }); + }); + + describe('createLabel', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + state.labelsManagePath = 'labels.json'; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('on success', () => { + it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', () => { + const label = { id: 1 }; + mock.onPost(/labels.json/).replyOnce(200, label); + + return testAction( + actions.createLabel, + {}, + state, + [], + [ + { type: 'requestCreateLabel' }, + { payload: { refetch: true }, type: 'fetchLabels' }, + { type: 'receiveCreateLabelSuccess' }, + { type: 'toggleDropdownContentsCreateView' }, + ], + ); + }); + }); + + describe('on failure', () => { + it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', () => { + mock.onPost(/labels.json/).replyOnce(500, {}); + + return testAction( + actions.createLabel, + {}, + state, + [], + [{ type: 'requestCreateLabel' }, { type: 'receiveCreateLabelFailure' }], + ); + }); + }); + }); + + describe('updateSelectedLabels', () => { + it('updates `state.labels` based on provided `labels` param', () => { + const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + + return testAction( + actions.updateSelectedLabels, + labels, + state, + [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }], + [], + ); + }); + }); + + describe('updateLabelsSetState', () => { + it('updates labels `set` state to match `selectedLabels`', () => { + testAction( + actions.updateLabelsSetState, + {}, + state, + [{ type: types.UPDATE_LABELS_SET_STATE }], + [], + ); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/store/getters_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/getters_spec.js new file mode 100644 index 00000000000..e32256831a3 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/getters_spec.js @@ -0,0 +1,74 @@ +import * as getters from '~/sidebar/components/labels/labels_select_vue/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); + }, + ); + + describe.each` + dropdownVariant | isDropdownVariantSidebar | isDropdownVariantEmbedded + ${'sidebar'} | ${true} | ${false} + ${'embedded'} | ${false} | ${true} + `( + 'when dropdown variant is $dropdownVariant', + ({ isDropdownVariantSidebar, isDropdownVariantEmbedded }) => { + it('returns label title when state.labels has only 1 label', () => { + const labels = [{ id: 1, title: 'Foobar', set: true }]; + + expect( + getters.dropdownButtonText( + { labels }, + { isDropdownVariantSidebar, isDropdownVariantEmbedded }, + ), + ).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, isDropdownVariantEmbedded }, + ), + ).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/sidebar/components/labels/labels_select_vue/store/mutations_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/mutations_spec.js new file mode 100644 index 00000000000..cee5d2e77d1 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/mutations_spec.js @@ -0,0 +1,232 @@ +import { cloneDeep } from 'lodash'; +import * as types from '~/sidebar/components/labels/labels_select_vue/store/mutation_types'; +import mutations from '~/sidebar/components/labels/labels_select_vue/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, set: true }, + { id: 4, set: true }, + ]; + 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.REQUEST_CREATE_LABEL}`, () => { + it('sets value of `state.labelCreateInProgress` to true', () => { + const state = { + labelCreateInProgress: false, + }; + mutations[types.REQUEST_CREATE_LABEL](state); + + expect(state.labelCreateInProgress).toBe(true); + }); + }); + + describe(`${types.RECEIVE_CREATE_LABEL_SUCCESS}`, () => { + it('sets value of `state.labelCreateInProgress` to false', () => { + const state = { + labelCreateInProgress: false, + }; + mutations[types.RECEIVE_CREATE_LABEL_SUCCESS](state); + + expect(state.labelCreateInProgress).toBe(false); + }); + }); + + describe(`${types.RECEIVE_CREATE_LABEL_FAILURE}`, () => { + it('sets value of `state.labelCreateInProgress` to false', () => { + const state = { + labelCreateInProgress: false, + }; + mutations[types.RECEIVE_CREATE_LABEL_FAILURE](state); + + expect(state.labelCreateInProgress).toBe(false); + }); + }); + + describe(`${types.UPDATE_SELECTED_LABELS}`, () => { + const labels = [ + { id: 1, title: 'scoped' }, + { id: 2, title: 'scoped::label::one', set: false }, + { id: 3, title: 'scoped::label::two', set: false }, + { id: 4, title: 'scoped::label::three', set: true }, + { id: 5, title: 'scoped::one', set: false }, + { id: 6, title: 'scoped::two', set: false }, + { id: 7, title: 'scoped::three', set: true }, + { id: 8, title: '' }, + ]; + + it.each` + label | labelGroupIds + ${labels[0]} | ${[]} + ${labels[1]} | ${[labels[2], labels[3]]} + ${labels[2]} | ${[labels[1], labels[3]]} + ${labels[3]} | ${[labels[1], labels[2]]} + ${labels[4]} | ${[labels[5], labels[6]]} + ${labels[5]} | ${[labels[4], labels[6]]} + ${labels[6]} | ${[labels[4], labels[5]]} + ${labels[7]} | ${[]} + `('updates `touched` and `set` props for $label.title', ({ label, labelGroupIds }) => { + const state = { labels: cloneDeep(labels) }; + + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: label.id }] }); + + expect(state.labels[label.id - 1]).toMatchObject({ + touched: true, + set: !labels[label.id - 1].set, + }); + + labelGroupIds.forEach((l) => { + expect(state.labels[l.id - 1].touched).toBeUndefined(); + expect(state.labels[l.id - 1].set).toBe(false); + }); + }); + it('allows selection of multiple scoped labels', () => { + const state = { labels: cloneDeep(labels), allowMultipleScopedLabels: true }; + + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: labels[4].id }] }); + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: labels[5].id }] }); + + expect(state.labels[4].set).toBe(true); + expect(state.labels[5].set).toBe(true); + expect(state.labels[6].set).toBe(true); + }); + }); + + describe(`${types.UPDATE_LABELS_SET_STATE}`, () => { + it('updates labels `set` state to match selected labels', () => { + const state = { + labels: [ + { id: 1, title: 'scoped::test', set: false, indeterminate: false }, + { id: 2, title: 'scoped::one', set: true, indeterminate: false, touched: true }, + { id: 3, title: '', set: false, indeterminate: false }, + { id: 4, title: '', set: false, indeterminate: false }, + ], + selectedLabels: [ + { id: 1, set: true }, + { id: 3, set: true }, + ], + }; + mutations[types.UPDATE_LABELS_SET_STATE](state); + + expect(state.labels).toEqual([ + { id: 1, title: 'scoped::test', set: true, indeterminate: false }, + { id: 2, title: 'scoped::one', set: false, indeterminate: false, touched: true }, + { id: 3, title: '', set: true, indeterminate: false }, + { id: 4, title: '', set: false, indeterminate: false }, + ]); + }); + }); +}); |