Commit 9266531e authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch...

Merge branch '340813-prevent-sending-a-labels-update-mutation-if-labels-list-is-not-changed' into 'master'

Prevent sending a labels update mutation if labels list is not changed

See merge request gitlab-org/gitlab!71740
parents e64d4b68 f2ecfe21
...@@ -3,7 +3,7 @@ import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui'; ...@@ -3,7 +3,7 @@ import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
import { isDropdownVariantStandalone } from './utils'; import { isDropdownVariantStandalone, isDropdownVariantSidebar } from './utils';
export default { export default {
components: { components: {
...@@ -52,11 +52,17 @@ export default { ...@@ -52,11 +52,17 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isVisible: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
showDropdownContentsCreateView: false, showDropdownContentsCreateView: false,
localSelectedLabels: [...this.selectedLabels], localSelectedLabels: [...this.selectedLabels],
isDirty: false,
}; };
}, },
computed: { computed: {
...@@ -87,6 +93,26 @@ export default { ...@@ -87,6 +93,26 @@ export default {
return isDropdownVariantStandalone(this.variant); return isDropdownVariantStandalone(this.variant);
}, },
}, },
watch: {
localSelectedLabels: {
handler() {
this.isDirty = true;
},
deep: true,
},
isVisible(newVal) {
if (newVal) {
this.$refs.dropdown.show();
this.isDirty = false;
} else {
this.$refs.dropdown.hide();
this.setLabels();
}
},
selectedLabels(newVal) {
this.localSelectedLabels = newVal;
},
},
methods: { methods: {
toggleDropdownContentsCreateView() { toggleDropdownContentsCreateView() {
this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView; this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView;
...@@ -98,12 +124,16 @@ export default { ...@@ -98,12 +124,16 @@ export default {
this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate(); this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate();
} }
}, },
showDropdown() { setLabels() {
this.$refs.dropdown.show(); if (!this.isDirty) {
}, return;
closeDropdown() { }
this.$emit('setLabels', this.localSelectedLabels); this.$emit('setLabels', this.localSelectedLabels);
this.$refs.dropdown.hide(); },
handleDropdownHide() {
if (!isDropdownVariantSidebar(this.variant)) {
this.setLabels();
}
}, },
}, },
}; };
...@@ -115,7 +145,7 @@ export default { ...@@ -115,7 +145,7 @@ export default {
:text="buttonText" :text="buttonText"
class="gl-w-full gl-mt-2" class="gl-w-full gl-mt-2"
data-qa-selector="labels_dropdown_content" data-qa-selector="labels_dropdown_content"
@hide="$emit('setLabels', localSelectedLabels)" @hide="handleDropdownHide"
> >
<template #header> <template #header>
<div <div
...@@ -141,7 +171,7 @@ export default { ...@@ -141,7 +171,7 @@ export default {
class="dropdown-header-button gl-p-0!" class="dropdown-header-button gl-p-0!"
icon="close" icon="close"
data-testid="close-button" data-testid="close-button"
@click="closeDropdown" @click="$emit('closeDropdown')"
/> />
</div> </div>
</template> </template>
......
...@@ -50,6 +50,7 @@ export default { ...@@ -50,6 +50,7 @@ export default {
return { return {
searchKey: '', searchKey: '',
labels: [], labels: [],
isVisible: false,
}; };
}, },
apollo: { apollo: {
...@@ -64,13 +65,13 @@ export default { ...@@ -64,13 +65,13 @@ export default {
}; };
}, },
skip() { skip() {
return this.searchKey.length === 1; return this.searchKey.length === 1 || !this.isVisible;
}, },
update: (data) => data.workspace?.labels?.nodes || [], update: (data) => data.workspace?.labels?.nodes || [],
async result() { async result() {
if (this.$refs.searchInput) { if (this.$refs.searchInput) {
await this.$nextTick; await this.$nextTick;
this.focusInputField(); this.$refs.searchInput.focusInput();
} }
}, },
error() { error() {
...@@ -150,7 +151,8 @@ export default { ...@@ -150,7 +151,8 @@ export default {
setSearchKey(value) { setSearchKey(value) {
this.searchKey = value; this.searchKey = value;
}, },
focusInputField() { onDropdownAppear() {
this.isVisible = true;
this.$refs.searchInput.focusInput(); this.$refs.searchInput.focusInput();
}, },
}, },
...@@ -158,7 +160,7 @@ export default { ...@@ -158,7 +160,7 @@ export default {
</script> </script>
<template> <template>
<gl-intersection-observer @appear="focusInputField"> <gl-intersection-observer @appear="onDropdownAppear">
<gl-dropdown-form class="labels-select-contents-list js-labels-list"> <gl-dropdown-form class="labels-select-contents-list js-labels-list">
<gl-search-box-by-type <gl-search-box-by-type
ref="searchInput" ref="searchInput"
......
...@@ -136,14 +136,14 @@ export default { ...@@ -136,14 +136,14 @@ export default {
methods: { methods: {
handleDropdownClose(labels) { handleDropdownClose(labels) {
this.$emit('updateSelectedLabels', labels); this.$emit('updateSelectedLabels', labels);
this.collapseEditableItem();
},
collapseEditableItem() {
this.$refs.editable?.collapse(); this.$refs.editable?.collapse();
}, },
handleCollapsedValueClick() { handleCollapsedValueClick() {
this.$emit('toggleCollapse'); this.$emit('toggleCollapse');
}, },
showDropdownContents() {
this.$refs.dropdownContents.showDropdown();
},
isDropdownVariantSidebar, isDropdownVariantSidebar,
isDropdownVariantStandalone, isDropdownVariantStandalone,
isDropdownVariantEmbedded, isDropdownVariantEmbedded,
...@@ -170,7 +170,6 @@ export default { ...@@ -170,7 +170,6 @@ export default {
:title="__('Labels')" :title="__('Labels')"
:loading="isLoading" :loading="isLoading"
:can-edit="allowLabelEdit" :can-edit="allowLabelEdit"
@open="showDropdownContents"
> >
<template #collapsed> <template #collapsed>
<dropdown-value <dropdown-value
...@@ -184,7 +183,7 @@ export default { ...@@ -184,7 +183,7 @@ export default {
<slot></slot> <slot></slot>
</dropdown-value> </dropdown-value>
</template> </template>
<template #default> <template #default="{ edit }">
<dropdown-value <dropdown-value
:disable-labels="labelsSelectInProgress" :disable-labels="labelsSelectInProgress"
:selected-labels="issuableLabels" :selected-labels="issuableLabels"
...@@ -197,7 +196,6 @@ export default { ...@@ -197,7 +196,6 @@ export default {
<slot></slot> <slot></slot>
</dropdown-value> </dropdown-value>
<dropdown-contents <dropdown-contents
ref="dropdownContents"
:dropdown-button-text="dropdownButtonText" :dropdown-button-text="dropdownButtonText"
:allow-multiselect="allowMultiselect" :allow-multiselect="allowMultiselect"
:labels-list-title="labelsListTitle" :labels-list-title="labelsListTitle"
...@@ -207,7 +205,9 @@ export default { ...@@ -207,7 +205,9 @@ export default {
:selected-labels="selectedLabels" :selected-labels="selectedLabels"
:variant="variant" :variant="variant"
:issuable-type="issuableType" :issuable-type="issuableType"
:is-visible="edit"
@setLabels="handleDropdownClose" @setLabels="handleDropdownClose"
@closeDropdown="collapseEditableItem"
/> />
</template> </template>
</sidebar-editable-item> </sidebar-editable-item>
......
import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; import {
GlLoadingIcon,
GlSearchBoxByType,
GlDropdownItem,
GlIntersectionObserver,
} from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
...@@ -68,23 +73,31 @@ describe('DropdownContentsLabelsView', () => { ...@@ -68,23 +73,31 @@ describe('DropdownContentsLabelsView', () => {
const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType); const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
const findLabels = () => wrapper.findAllComponents(LabelItem); const findLabels = () => wrapper.findAllComponents(LabelItem);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findObserver = () => wrapper.findComponent(GlIntersectionObserver);
const findLabelsList = () => wrapper.find('[data-testid="labels-list"]'); const findLabelsList = () => wrapper.find('[data-testid="labels-list"]');
const findNoResultsMessage = () => wrapper.find('[data-testid="no-results"]'); const findNoResultsMessage = () => wrapper.find('[data-testid="no-results"]');
async function makeObserverAppear() {
await findObserver().vm.$emit('appear');
}
describe('when loading labels', () => { describe('when loading labels', () => {
it('renders disabled search input field', async () => { it('renders disabled search input field', async () => {
createComponent(); createComponent();
await makeObserverAppear();
expect(findSearchInput().props('disabled')).toBe(true); expect(findSearchInput().props('disabled')).toBe(true);
}); });
it('renders loading icon', async () => { it('renders loading icon', async () => {
createComponent(); createComponent();
await makeObserverAppear();
expect(findLoadingIcon().exists()).toBe(true); expect(findLoadingIcon().exists()).toBe(true);
}); });
it('does not render labels list', async () => { it('does not render labels list', async () => {
createComponent(); createComponent();
await makeObserverAppear();
expect(findLabelsList().exists()).toBe(false); expect(findLabelsList().exists()).toBe(false);
}); });
}); });
...@@ -92,6 +105,7 @@ describe('DropdownContentsLabelsView', () => { ...@@ -92,6 +105,7 @@ describe('DropdownContentsLabelsView', () => {
describe('when labels are loaded', () => { describe('when labels are loaded', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent(); createComponent();
await makeObserverAppear();
await waitForPromises(); await waitForPromises();
}); });
...@@ -121,6 +135,7 @@ describe('DropdownContentsLabelsView', () => { ...@@ -121,6 +135,7 @@ describe('DropdownContentsLabelsView', () => {
}, },
}), }),
}); });
await makeObserverAppear();
findSearchInput().vm.$emit('input', '123'); findSearchInput().vm.$emit('input', '123');
await waitForPromises(); await waitForPromises();
await nextTick(); await nextTick();
...@@ -130,6 +145,7 @@ describe('DropdownContentsLabelsView', () => { ...@@ -130,6 +145,7 @@ describe('DropdownContentsLabelsView', () => {
it('calls `createFlash` when fetching labels failed', async () => { it('calls `createFlash` when fetching labels failed', async () => {
createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') }); createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') });
await makeObserverAppear();
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await waitForPromises(); await waitForPromises();
...@@ -138,9 +154,17 @@ describe('DropdownContentsLabelsView', () => { ...@@ -138,9 +154,17 @@ describe('DropdownContentsLabelsView', () => {
it('emits an `input` event on label click', async () => { it('emits an `input` event on label click', async () => {
createComponent(); createComponent();
await makeObserverAppear();
await waitForPromises(); await waitForPromises();
findFirstLabel().trigger('click'); findFirstLabel().trigger('click');
expect(wrapper.emitted('input')[0][0]).toEqual(expect.arrayContaining(localSelectedLabels)); expect(wrapper.emitted('input')[0][0]).toEqual(expect.arrayContaining(localSelectedLabels));
}); });
it('does not trigger query when component did not appear', () => {
createComponent();
expect(findLoadingIcon().exists()).toBe(false);
expect(findLabelsList().exists()).toBe(false);
expect(successfulQueryHandler).not.toHaveBeenCalled();
});
}); });
...@@ -19,6 +19,7 @@ const GlDropdownStub = { ...@@ -19,6 +19,7 @@ const GlDropdownStub = {
`, `,
methods: { methods: {
show: showDropdown, show: showDropdown,
hide: jest.fn(),
}, },
}; };
...@@ -68,18 +69,52 @@ describe('DropdownContent', () => { ...@@ -68,18 +69,52 @@ describe('DropdownContent', () => {
const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]');
const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]'); const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]');
it('emits `show` for dropdown on call showDropdown', () => { it('calls dropdown `show` method on `isVisible` prop change', async () => {
createComponent(); createComponent();
wrapper.vm.showDropdown(); await wrapper.setProps({
isVisible: true,
});
expect(findDropdown().emitted('show')).toBeUndefined(); expect(findDropdown().emitted('show')).toBeUndefined();
}); });
it('emits `setLabels` event on dropdown hide', () => { it('does not emit `setLabels` event on dropdown hide if labels did not change', () => {
createComponent(); createComponent();
findDropdown().vm.$emit('hide'); findDropdown().vm.$emit('hide');
expect(wrapper.emitted('setLabels')).toEqual([[mockLabels]]); expect(wrapper.emitted('setLabels')).toBeUndefined();
});
it('emits `setLabels` event on dropdown hide if labels changed on non-sidebar widget', async () => {
createComponent({ props: { variant: DropdownVariant.Standalone } });
const updatedLabel = {
id: 28,
title: 'Bug',
description: 'Label for bugs',
color: '#FF0000',
textColor: '#FFFFFF',
};
findLabelsView().vm.$emit('input', [updatedLabel]);
await nextTick();
findDropdown().vm.$emit('hide');
expect(wrapper.emitted('setLabels')).toEqual([[[updatedLabel]]]);
});
it('emits `setLabels` event on visibility change if labels changed on sidebar widget', async () => {
createComponent({ props: { variant: DropdownVariant.Standalone, isVisible: true } });
const updatedLabel = {
id: 28,
title: 'Bug',
description: 'Label for bugs',
color: '#FF0000',
textColor: '#FFFFFF',
};
findLabelsView().vm.$emit('input', [updatedLabel]);
wrapper.setProps({ isVisible: false });
await nextTick();
expect(wrapper.emitted('setLabels')).toEqual([[[updatedLabel]]]);
}); });
it('does not render header on standalone variant', () => { it('does not render header on standalone variant', () => {
......
...@@ -27,8 +27,6 @@ describe('LabelsSelectRoot', () => { ...@@ -27,8 +27,6 @@ describe('LabelsSelectRoot', () => {
const findDropdownValue = () => wrapper.findComponent(DropdownValue); const findDropdownValue = () => wrapper.findComponent(DropdownValue);
const findDropdownContents = () => wrapper.findComponent(DropdownContents); const findDropdownContents = () => wrapper.findComponent(DropdownContents);
const expandDropdown = () => wrapper.vm.$refs.editable.expand();
const createComponent = ({ const createComponent = ({
config = mockConfig, config = mockConfig,
slots = {}, slots = {},
...@@ -125,23 +123,11 @@ describe('LabelsSelectRoot', () => { ...@@ -125,23 +123,11 @@ describe('LabelsSelectRoot', () => {
}); });
}); });
it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if there are labels to update', async () => { it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event', async () => {
const label = { id: 'gid://gitlab/ProjectLabel/1' }; const label = { id: 'gid://gitlab/ProjectLabel/1' };
createComponent(); createComponent();
wrapper.vm.$refs.dropdownContents = {
showDropdown: jest.fn(),
};
const showSpy = jest.spyOn(wrapper.vm.$refs.dropdownContents, 'showDropdown');
findDropdownContents().vm.$refs.dropdown = {
show: jest.fn(),
};
await waitForPromises();
expandDropdown();
await nextTick();
findDropdownContents().vm.$emit('setLabels', [label]); findDropdownContents().vm.$emit('setLabels', [label]);
expect(wrapper.emitted('updateSelectedLabels')).toEqual([[[label]]]); expect(wrapper.emitted('updateSelectedLabels')).toEqual([[[label]]]);
expect(showSpy).toHaveBeenCalled();
}); });
}); });
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