Commit 65cb4d96 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch...

Merge branch '332257-refactor-dropdowncontentseditview-component-to-use-graphql-apollo' into 'master'

Refactor DropdownContentsEditView component to use GraphQL + Apollo

See merge request gitlab-org/gitlab!65440
parents 4640daaf 7523392c
......@@ -55,12 +55,13 @@ export default {
},
getUpdateVariables(dropdownLabels) {
const currentLabelIds = this.selectedLabels.map((label) => label.id);
const userAddedLabelIds = dropdownLabels
.filter((label) => label.set)
.map((label) => label.id);
const userRemovedLabelIds = dropdownLabels
.filter((label) => !label.set)
.map((label) => label.id);
const dropdownLabelIds = dropdownLabels.map((label) => label.id);
const userAddedLabelIds = this.glFeatures.labelsWidget
? difference(dropdownLabelIds, currentLabelIds)
: dropdownLabels.filter((label) => label.set).map((label) => label.id);
const userRemovedLabelIds = this.glFeatures.labelsWidget
? difference(currentLabelIds, dropdownLabelIds)
: dropdownLabels.filter((label) => !label.set).map((label) => label.id);
const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds);
......@@ -155,7 +156,7 @@ export default {
:labels-manage-path="labelsManagePath"
:labels-select-in-progress="isLabelsSelectInProgress"
:selected-labels="selectedLabels"
:variant="$options.sidebar"
:variant="$options.variant"
data-qa-selector="labels_block"
@onDropdownClose="handleDropdownClose"
@onLabelRemove="handleLabelRemove"
......
......@@ -24,6 +24,7 @@ import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import { apolloProvider } from '~/sidebar/graphql';
import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
......@@ -256,6 +257,7 @@ export function mountSidebarLabels() {
allowLabelEdit: parseBoolean(el.dataset.canEdit),
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels),
variant: DropdownVariant.Sidebar,
},
render: (createElement) => createElement(SidebarLabels),
});
......
......@@ -21,9 +21,29 @@ export default {
type: String,
required: true,
},
selectedLabels: {
type: Array,
required: true,
},
allowMultiselect: {
type: Boolean,
required: true,
},
labelsListTitle: {
type: String,
required: true,
},
footerCreateLabelTitle: {
type: String,
required: true,
},
footerManageLabelTitle: {
type: String,
required: true,
},
},
computed: {
...mapState(['showDropdownContentsCreateView', 'labelsListTitle']),
...mapState(['showDropdownContentsCreateView']),
...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
dropdownContentsView() {
if (this.showDropdownContentsCreateView) {
......@@ -75,6 +95,16 @@ export default {
@click="toggleDropdownContents"
/>
</div>
<component :is="dropdownContentsView" @hideCreateView="toggleDropdownContentsCreateView" />
<component
:is="dropdownContentsView"
:selected-labels="selectedLabels"
:allow-multiselect="allowMultiselect"
:labels-list-title="labelsListTitle"
:footer-create-label-title="footerCreateLabelTitle"
:footer-manage-label-title="footerManageLabelTitle"
@hideCreateView="toggleDropdownContentsCreateView"
@closeDropdown="$emit('closeDropdown', $event)"
@toggleDropdownContentsCreateView="toggleDropdownContentsCreateView"
/>
</div>
</template>
<script>
import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import { GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { mapState, mapGetters, mapActions } from 'vuex';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import { __ } from '~/locale';
import { DropdownVariant } from './constants';
import projectLabelsQuery from './graphql/project_labels.query.graphql';
import LabelItem from './label_item.vue';
export default {
components: {
GlIntersectionObserver,
GlLoadingIcon,
GlSearchBoxByType,
GlLink,
LabelItem,
},
inject: ['projectPath', 'allowLabelCreate', 'labelsManagePath', 'variant'],
props: {
selectedLabels: {
type: Array,
required: true,
},
allowMultiselect: {
type: Boolean,
required: true,
},
labelsListTitle: {
type: String,
required: true,
},
footerCreateLabelTitle: {
type: String,
required: true,
},
footerManageLabelTitle: {
type: String,
required: true,
},
},
data() {
return {
searchKey: '',
labels: [],
currentHighlightItem: -1,
localSelectedLabels: [...this.selectedLabels],
};
},
apollo: {
labels: {
query: projectLabelsQuery,
variables() {
return {
fullPath: this.projectPath,
searchTerm: this.searchKey,
};
},
skip() {
return this.searchKey.length === 1;
},
update: (data) => data.workspace?.labels?.nodes || [],
async result() {
if (this.$refs.searchInput) {
await this.$nextTick();
this.$refs.searchInput.focusInput();
}
},
error() {
createFlash({ message: __('Error fetching labels.') });
},
},
},
computed: {
...mapState([
'allowLabelCreate',
'allowMultiselect',
'labelsManagePath',
'labels',
'labelsFetchInProgress',
'labelsListTitle',
'footerCreateLabelTitle',
'footerManageLabelTitle',
]),
...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
isDropdownVariantSidebar() {
return this.variant === DropdownVariant.Sidebar;
},
isDropdownVariantEmbedded() {
return this.variant === DropdownVariant.Embedded;
},
labelsFetchInProgress() {
return this.$apollo.queries.labels.loading;
},
localSelectedLabelsIds() {
return this.localSelectedLabels.map((label) => label.id);
},
visibleLabels() {
if (this.searchKey) {
return fuzzaldrinPlus.filter(this.labels, this.searchKey, {
......@@ -55,17 +108,16 @@ export default {
}
},
},
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
beforeDestroy() {
this.$emit('closeDropdown', this.localSelectedLabels);
this.debouncedSearchKeyUpdate.cancel();
},
methods: {
...mapActions([
'toggleDropdownContents',
'toggleDropdownContentsCreateView',
'fetchLabels',
'receiveLabelsSuccess',
'updateSelectedLabels',
'toggleDropdownContents',
]),
isLabelSelected(label) {
return this.selectedLabelsList.includes(label.id);
return this.localSelectedLabelsIds.includes(getIdFromGraphQLId(label.id));
},
/**
* This method scrolls item from dropdown into
......@@ -86,23 +138,17 @@ export default {
}
}
},
handleComponentAppear() {
// We can avoid putting `catch` block here
// as failure is handled within actions.js already.
return this.fetchLabels().then(() => {
this.$refs.searchInput.focusInput();
});
},
/**
* We want to remove loaded labels to ensure component
* fetches fresh set of labels every time when shown.
*/
handleComponentDisappear() {
this.receiveLabelsSuccess([]);
},
handleCreateLabelClick() {
this.receiveLabelsSuccess([]);
this.toggleDropdownContentsCreateView();
updateSelectedLabels(label) {
if (this.isLabelSelected(label)) {
this.localSelectedLabels = this.localSelectedLabels.filter(
({ id }) => id !== getIdFromGraphQLId(label.id),
);
} else {
this.localSelectedLabels.push({
...label,
id: getIdFromGraphQLId(label.id),
});
}
},
/**
* This method enables keyboard navigation support for
......@@ -117,10 +163,10 @@ export default {
) {
this.currentHighlightItem += 1;
} else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) {
this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]);
this.updateSelectedLabels(this.visibleLabels[this.currentHighlightItem]);
this.searchKey = '';
} else if (e.keyCode === ESC_KEY_CODE) {
this.toggleDropdownContents();
this.$emit('closeDropdown', this.localSelectedLabels);
}
if (e.keyCode !== ESC_KEY_CODE) {
......@@ -132,68 +178,82 @@ export default {
}
},
handleLabelClick(label) {
this.updateSelectedLabels([label]);
if (!this.allowMultiselect) this.toggleDropdownContents();
this.updateSelectedLabels(label);
if (!this.allowMultiselect) {
this.$emit('closeDropdown', this.localSelectedLabels);
}
},
setSearchKey(value) {
this.searchKey = value;
},
},
};
</script>
<template>
<gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear">
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type
ref="searchInput"
v-model="searchKey"
:disabled="labelsFetchInProgress"
data-qa-selector="dropdown_input_field"
/>
</div>
<div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content">
<gl-loading-icon
v-if="labelsFetchInProgress"
class="labels-fetch-loading gl-align-items-center w-100 h-100"
size="md"
<div
class="labels-select-contents-list js-labels-list"
data-testid="dropdown-wrapper"
@keydown="handleKeyDown"
>
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type
ref="searchInput"
:value="searchKey"
:disabled="labelsFetchInProgress"
data-qa-selector="dropdown_input_field"
data-testid="dropdown-input-field"
@input="debouncedSearchKeyUpdate"
/>
</div>
<div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content">
<gl-loading-icon
v-if="labelsFetchInProgress"
class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full"
size="md"
/>
<ul v-else class="list-unstyled gl-mb-0 gl-word-break-word" data-testid="labels-list">
<label-item
v-for="(label, index) in visibleLabels"
:key="label.id"
:label="label"
:is-label-set="isLabelSelected(label)"
:highlight="index === currentHighlightItem"
@clickLabel="handleLabelClick(label)"
/>
<ul v-else class="list-unstyled gl-mb-0 gl-word-break-word">
<label-item
v-for="(label, index) in visibleLabels"
:key="label.id"
:label="label"
:is-label-set="label.set"
:highlight="index === currentHighlightItem"
@clickLabel="handleLabelClick(label)"
/>
<li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center">
{{ __('No matching results') }}
</li>
</ul>
</div>
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
class="dropdown-footer"
data-testid="dropdown-footer"
>
<ul class="list-unstyled">
<li v-if="allowLabelCreate">
<gl-link
class="gl-display-flex w-100 flex-row text-break-word label-item"
@click="handleCreateLabelClick"
>
{{ footerCreateLabelTitle }}
</gl-link>
</li>
<li>
<gl-link
:href="labelsManagePath"
class="gl-display-flex flex-row text-break-word label-item"
>
{{ footerManageLabelTitle }}
</gl-link>
</li>
</ul>
</div>
<li
v-show="showNoMatchingResultsMessage"
class="gl-p-3 gl-text-center"
data-testid="no-results"
>
{{ __('No matching results') }}
</li>
</ul>
</div>
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
class="dropdown-footer"
data-testid="dropdown-footer"
>
<ul class="list-unstyled">
<li v-if="allowLabelCreate">
<gl-link
class="gl-display-flex gl-flex-direction-row gl-w-full gl-overflow-break-word label-item"
data-testid="create-label-button"
@click="$emit('toggleDropdownContentsCreateView')"
>
{{ footerCreateLabelTitle }}
</gl-link>
</li>
<li>
<gl-link
:href="labelsManagePath"
class="gl-display-flex gl-flex-direction-row gl-w-full gl-overflow-break-word label-item"
>
{{ footerManageLabelTitle }}
</gl-link>
</li>
</ul>
</div>
</gl-intersection-observer>
</div>
</template>
query projectLabels($fullPath: ID!, $searchTerm: String) {
workspace: project(fullPath: $fullPath) {
labels(searchTerm: $searchTerm, includeAncestorGroups: true) {
nodes {
id
title
color
description
}
}
}
}
......@@ -196,23 +196,6 @@ export default {
},
methods: {
...mapActions(['setInitialState', 'toggleDropdownContents']),
/**
* This method differentiates between
* dispatched actions and calls necessary method.
*/
handleVuexActionDispatch(action, state) {
if (
action.type === 'toggleDropdownContents' &&
!state.showDropdownButton &&
!state.showDropdownContents
) {
let filterFn = (label) => label.touched;
if (this.isDropdownVariantEmbedded) {
filterFn = (label) => label.set;
}
this.handleDropdownClose(state.labels.filter(filterFn));
}
},
/**
* This method stores a mousedown event's target.
* Required by the click listener because the click
......@@ -276,6 +259,9 @@ export default {
handleDropdownClose(labels) {
// Only emit label updates if there are any labels to update
// on UI.
if (this.showDropdownContents) {
this.toggleDropdownContents();
}
if (labels.length) this.$emit('updateSelectedLabels', labels);
this.$emit('onDropdownClose');
},
......@@ -332,8 +318,14 @@ export default {
<dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents"
ref="dropdownContents"
:allow-multiselect="allowMultiselect"
:labels-list-title="labelsListTitle"
:footer-create-label-title="footerCreateLabelTitle"
:footer-manage-label-title="footerManageLabelTitle"
:render-on-top="!contentIsOnViewport"
:labels-create-title="labelsCreateTitle"
:selected-labels="selectedLabels"
@closeDropdown="handleDropdownClose"
/>
</template>
<template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
......@@ -341,7 +333,13 @@ export default {
<dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents"
ref="dropdownContents"
:allow-multiselect="allowMultiselect"
:labels-list-title="labelsListTitle"
:footer-create-label-title="footerCreateLabelTitle"
:footer-manage-label-title="footerManageLabelTitle"
:render-on-top="!contentIsOnViewport"
:selected-labels="selectedLabels"
@closeDropdown="handleDropdownClose"
/>
</template>
</div>
......
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import * as types from './mutation_types';
export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props);
......@@ -11,24 +8,5 @@ export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDO
export const toggleDropdownContentsCreateView = ({ commit }) =>
commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW);
export const requestLabels = ({ commit }) => commit(types.REQUEST_LABELS);
export const receiveLabelsSuccess = ({ commit }, labels) =>
commit(types.RECEIVE_SET_LABELS_SUCCESS, labels);
export const receiveLabelsFailure = ({ commit }) => {
commit(types.RECEIVE_SET_LABELS_FAILURE);
createFlash({
message: __('Error fetching labels.'),
});
};
export const fetchLabels = ({ state, dispatch }) => {
dispatch('requestLabels');
return axios
.get(state.labelsFetchPath)
.then(({ data }) => {
dispatch('receiveLabelsSuccess', data);
})
.catch(() => dispatch('receiveLabelsFailure'));
};
export const updateSelectedLabels = ({ commit }, labels) =>
commit(types.UPDATE_SELECTED_LABELS, { labels });
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const REQUEST_LABELS = 'REQUEST_LABELS';
export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE';
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 TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
......
......@@ -26,27 +26,6 @@ export default {
[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) {
state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView;
},
[types.REQUEST_LABELS](state) {
state.labelsFetchInProgress = true;
},
[types.RECEIVE_SET_LABELS_SUCCESS](state, labels) {
// Iterate over every label and add a `set` prop
// to determine whether it is already a part of
// selectedLabels array.
const selectedLabelIds = state.selectedLabels.map((label) => label.id);
state.labelsFetchInProgress = false;
state.labels = labels.reduce((allLabels, label) => {
allLabels.push({
...label,
set: selectedLabelIds.includes(label.id),
});
return allLabels;
}, []);
},
[types.RECEIVE_SET_LABELS_FAILURE](state) {
state.labelsFetchInProgress = 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.
......
import { GlLoadingIcon, GlLink } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -14,7 +14,7 @@ jest.mock('~/flash');
const colors = Object.keys(mockSuggestedColors);
const localVue = createLocalVue();
Vue.use(VueApollo);
localVue.use(VueApollo);
const userRecoverableError = {
...createLabelSuccessfulResponse,
......
import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import { GlLoadingIcon, GlSearchBoxByType } 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 { 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 { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue';
import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
import { mockConfig, labelsQueryResponse } from './mock_data';
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';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueApollo);
const selectedLabels = [
{
id: 28,
title: 'Bug',
description: 'Label for bugs',
color: '#FF0000',
textColor: '#FFFFFF',
},
];
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(),
},
});
const successfulQueryHandler = jest.fn().mockResolvedValue(labelsQueryResponse);
store.dispatch('setInitialState', initialState);
store.dispatch('receiveLabelsSuccess', mockLabels);
const createComponent = ({
initialState = mockConfig,
queryHandler = successfulQueryHandler,
injected = {},
} = {}) => {
const mockApollo = createMockApollo([[projectLabelsQuery, queryHandler]]);
wrapper = shallowMount(DropdownContentsLabelsView, {
localVue,
store,
apolloProvider: mockApollo,
provide: {
projectPath: 'test',
iid: 1,
allowLabelCreate: true,
labelsManagePath: '/gitlab-org/my-project/-/labels',
variant: DropdownVariant.Sidebar,
...injected,
},
propsData: {
...initialState,
selectedLabels,
},
stubs: {
GlSearchBoxByType,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]');
const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
const findLabels = () => wrapper.findAllComponents(LabelItem);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findLabelsList = () => wrapper.find('[data-testid="labels-list"]');
const findDropdownWrapper = () => wrapper.find('[data-testid="dropdown-wrapper"]');
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);
});
});
const findNoResultsMessage = () => wrapper.find('[data-testid="no-results"]');
const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]');
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('when loading labels', () => {
it('renders disabled search input field', async () => {
createComponent();
expect(findSearchInput().props('disabled')).toBe(true);
});
});
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);
});
it('renders loading icon', async () => {
createComponent();
expect(findLoadingIcon().exists()).toBe(true);
});
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();
});
it('does not render labels list', async () => {
createComponent();
expect(findLabelsList().exists()).toBe(false);
});
});
describe('handleComponentDisappear', () => {
it('calls action `receiveLabelsSuccess` with empty array', () => {
jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
wrapper.vm.handleComponentDisappear();
expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
});
describe('when labels are loaded', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
});
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();
});
it('renders enabled search input field', async () => {
expect(findSearchInput().props('disabled')).toBe(false);
});
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();
});
});
it('does not render loading icon', async () => {
expect(findLoadingIcon().exists()).toBe(false);
});
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('renders labels list', async () => {
expect(findLabelsList().exists()).toBe(true);
expect(findLabels()).toHaveLength(2);
});
it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => {
jest.spyOn(wrapper.vm, 'toggleDropdownContents');
wrapper.vm.$store.state.allowMultiselect = false;
it('changes highlighted label correctly on pressing down button', async () => {
expect(findLabels().at(0).attributes('highlight')).toBeUndefined();
wrapper.vm.handleLabelClick(mockRegularLabel);
await findDropdownWrapper().trigger('keydown.down');
expect(findLabels().at(0).attributes('highlight')).toBe('true');
expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
});
await findDropdownWrapper().trigger('keydown.down');
expect(findLabels().at(1).attributes('highlight')).toBe('true');
expect(findLabels().at(0).attributes('highlight')).toBeUndefined();
});
});
describe('template', () => {
it('renders gl-intersection-observer as component root', () => {
expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true);
});
it('changes highlighted label correctly on pressing up button', async () => {
await findDropdownWrapper().trigger('keydown.down');
await findDropdownWrapper().trigger('keydown.down');
expect(findLabels().at(1).attributes('highlight')).toBe('true');
it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => {
wrapper.vm.$store.dispatch('requestLabels');
await findDropdownWrapper().trigger('keydown.up');
expect(findLabels().at(0).attributes('highlight')).toBe('true');
});
return wrapper.vm.$nextTick(() => {
const loadingIconEl = findLoadingIcon();
it('changes label selected state when Enter is pressed', async () => {
expect(findLabels().at(0).attributes('islabelset')).toBeUndefined();
await findDropdownWrapper().trigger('keydown.down');
await findDropdownWrapper().trigger('keydown.enter');
expect(loadingIconEl.exists()).toBe(true);
expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading');
});
expect(findLabels().at(0).attributes('islabelset')).toBe('true');
});
it('renders label search input element', () => {
const searchInputEl = wrapper.find(GlSearchBoxByType);
it('emits `closeDropdown event` when Esc button is pressed', () => {
findDropdownWrapper().trigger('keydown.esc');
expect(searchInputEl.exists()).toBe(true);
expect(wrapper.emitted('closeDropdown')).toEqual([[selectedLabels]]);
});
});
it('renders label elements for all labels', () => {
expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length);
it('when search returns 0 results', async () => {
createComponent({
queryHandler: jest.fn().mockResolvedValue({
data: {
workspace: {
labels: {
nodes: [],
},
},
},
}),
});
findSearchInput().vm.$emit('input', '123');
await waitForPromises();
await nextTick();
it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => {
wrapper.setData({
currentHighlightItem: 0,
});
expect(findNoResultsMessage().isVisible()).toBe(true);
});
return wrapper.vm.$nextTick(() => {
const labelItemEl = findDropdownContent().find(LabelItem);
it('calls `createFlash` when fetching labels failed', async () => {
createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') });
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
});
expect(labelItemEl.attributes('highlight')).toBe('true');
});
});
it('does not render footer on standalone dropdown', () => {
createComponent({ injected: { variant: DropdownVariant.Standalone } });
it('renders element containing "No matching results" when `searchKey` does not match with any label', () => {
wrapper.setData({
searchKey: 'abc',
});
expect(findDropdownFooter().exists()).toBe(false);
});
return wrapper.vm.$nextTick(() => {
const noMatchEl = findDropdownContent().find('li');
it('renders footer on sidebar dropdown', () => {
createComponent();
expect(noMatchEl.isVisible()).toBe(true);
expect(noMatchEl.text()).toContain('No matching results');
});
});
expect(findDropdownFooter().exists()).toBe(true);
});
it('renders empty content while loading', () => {
wrapper.vm.$store.state.labelsFetchInProgress = true;
it('renders footer on embedded dropdown', () => {
createComponent({ injected: { variant: DropdownVariant.Embedded } });
return wrapper.vm.$nextTick(() => {
const dropdownContent = findDropdownContent();
const loadingIcon = findLoadingIcon();
expect(findDropdownFooter().exists()).toBe(true);
});
expect(dropdownContent.exists()).toBe(true);
expect(dropdownContent.isVisible()).toBe(true);
expect(loadingIcon.exists()).toBe(true);
expect(loadingIcon.isVisible()).toBe(true);
});
});
it('does not render create label button if `allowLabelCreate` is false', () => {
createComponent({ injected: { allowLabelCreate: false } });
it('renders footer list items', () => {
const footerLinks = findDropdownFooter().findAll(GlLink);
const createLabelLink = footerLinks.at(0);
const manageLabelsLink = footerLinks.at(1);
expect(findCreateLabelButton().exists()).toBe(false);
});
expect(createLabelLink.exists()).toBe(true);
expect(createLabelLink.text()).toBe('Create label');
expect(manageLabelsLink.exists()).toBe(true);
expect(manageLabelsLink.text()).toBe('Manage labels');
describe('when `allowLabelCreate` is true', () => {
beforeEach(() => {
createComponent();
});
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('renders create label button', () => {
expect(findCreateLabelButton().exists()).toBe(true);
});
it('does not render footer list items when `state.variant` is "standalone"', () => {
createComponent({ ...mockConfig, variant: 'standalone' });
expect(findDropdownFooter().exists()).toBe(false);
});
it('emits `toggleDropdownContentsCreateView` event on create label button click', () => {
findCreateLabelButton().vm.$emit('click');
it('renders footer list items when `state.variant` is "embedded"', () => {
expect(findDropdownFooter().exists()).toBe(true);
expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]);
});
});
});
......@@ -5,7 +5,7 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_w
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';
import { mockConfig, mockLabels } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -19,6 +19,11 @@ const createComponent = (initialState = mockConfig, defaultProps = {}) => {
propsData: {
...defaultProps,
labelsCreateTitle: 'test',
selectedLabels: mockLabels,
allowMultiselect: true,
labelsListTitle: 'Assign labels',
footerCreateLabelTitle: 'create',
footerManageLabelTitle: 'manage',
},
localVue,
store,
......
......@@ -50,58 +50,6 @@ describe('LabelsSelectRoot', () => {
});
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();
......
......@@ -48,6 +48,8 @@ export const mockConfig = {
labelsManagePath: '/gitlab-org/my-project/-/labels',
labelsFilterBasePath: '/gitlab-org/my-project/issues',
labelsFilterParam: 'label_name',
footerCreateLabelTitle: 'create',
footerManageLabelTitle: 'manage',
};
export const mockSuggestedColors = {
......@@ -91,3 +93,26 @@ export const createLabelSuccessfulResponse = {
},
},
};
export const labelsQueryResponse = {
data: {
workspace: {
labels: {
nodes: [
{
color: '#330066',
description: null,
id: 'gid://gitlab/ProjectLabel/1',
title: 'Label1',
},
{
color: '#2f7b2e',
description: null,
id: 'gid://gitlab/ProjectLabel/2',
title: 'Label2',
},
],
},
},
},
};
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
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';
......@@ -72,90 +68,6 @@ describe('LabelsSelect Actions', () => {
});
});
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', () => {
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(createFlash).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', (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 }];
......
......@@ -67,58 +67,6 @@ describe('LabelsSelect Mutations', () => {
});
});
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}`, () => {
let labels;
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment