Commit 854c64da authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch '332258-replace-dropdowntitle-component-with-sidebareditableitem' into 'master'

Replace `DropdownTitle` component with `SidebarEditableItem`

See merge request gitlab-org/gitlab!67966
parents 3c83847b a689dbbf
......@@ -258,6 +258,7 @@ export function mountSidebarLabels() {
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels),
variant: DropdownVariant.Sidebar,
canUpdate: parseBoolean(el.dataset.canEdit),
},
render: (createElement) => createElement(SidebarLabels),
});
......
......@@ -60,7 +60,7 @@ export default {
},
},
methods: {
...mapActions(['toggleDropdownContentsCreateView', 'toggleDropdownContents']),
...mapActions(['toggleDropdownContentsCreateView']),
},
};
</script>
......@@ -83,7 +83,7 @@ export default {
size="small"
class="js-btn-back dropdown-header-button p-0"
icon="arrow-left"
@click="toggleDropdownContentsCreateView"
@click.stop="toggleDropdownContentsCreateView"
/>
<span class="flex-grow-1">{{ dropdownTitle }}</span>
<gl-button
......@@ -92,7 +92,7 @@ export default {
size="small"
class="dropdown-header-button gl-p-0!"
icon="close"
@click="toggleDropdownContents"
@click="$emit('closeDropdown')"
/>
</div>
<component
......@@ -103,7 +103,7 @@ export default {
:footer-create-label-title="footerCreateLabelTitle"
:footer-manage-label-title="footerManageLabelTitle"
@hideCreateView="toggleDropdownContentsCreateView"
@closeDropdown="$emit('closeDropdown', $event)"
@setLabels="$emit('setLabels', $event)"
@toggleDropdownContentsCreateView="toggleDropdownContentsCreateView"
/>
</div>
......
<script>
import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import produce from 'immer';
import createFlash from '~/flash';
import { __ } from '~/locale';
import createLabelMutation from './graphql/create_label.mutation.graphql';
import projectLabelsQuery from './graphql/project_labels.query.graphql';
const errorMessage = __('Error creating label.');
......@@ -47,6 +49,25 @@ export default {
handleColorClick(color) {
this.selectedColor = this.getColorCode(color);
},
updateLabelsInCache(store, label) {
const sourceData = store.readQuery({
query: projectLabelsQuery,
variables: { fullPath: this.projectPath, searchTerm: '' },
});
const collator = new Intl.Collator('en');
const data = produce(sourceData, (draftData) => {
const { nodes } = draftData.workspace.labels;
nodes.push(label);
nodes.sort((a, b) => collator.compare(a.title, b.title));
});
store.writeQuery({
query: projectLabelsQuery,
variables: { fullPath: this.projectPath, searchTerm: '' },
data,
});
},
async createLabel() {
this.labelCreateInProgress = true;
try {
......@@ -59,6 +80,14 @@ export default {
color: this.selectedColor,
projectPath: this.projectPath,
},
update: (
store,
{
data: {
labelCreate: { label },
},
},
) => this.updateLabelsInCache(store, label),
});
if (labelCreate.errors.length) {
createFlash({ message: errorMessage });
......
......@@ -112,7 +112,7 @@ export default {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
beforeDestroy() {
this.$emit('closeDropdown', this.localSelectedLabels);
this.$emit('setLabels', this.localSelectedLabels);
this.debouncedSearchKeyUpdate.cancel();
},
methods: {
......@@ -166,7 +166,7 @@ export default {
this.updateSelectedLabels(this.visibleLabels[this.currentHighlightItem]);
this.searchKey = '';
} else if (e.keyCode === ESC_KEY_CODE) {
this.$emit('closeDropdown', this.localSelectedLabels);
this.$emit('setLabels', this.localSelectedLabels);
}
if (e.keyCode !== ESC_KEY_CODE) {
......@@ -180,7 +180,7 @@ export default {
handleLabelClick(label) {
this.updateSelectedLabels(label);
if (!this.allowMultiselect) {
this.$emit('closeDropdown', this.localSelectedLabels);
this.$emit('setLabels', this.localSelectedLabels);
}
},
setSearchKey(value) {
......@@ -240,7 +240,7 @@ export default {
<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')"
@click.stop="$emit('toggleDropdownContentsCreateView')"
>
{{ footerCreateLabelTitle }}
</gl-link>
......
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
export default {
components: {
GlButton,
GlLoadingIcon,
},
props: {
labelsSelectInProgress: {
type: Boolean,
required: true,
},
},
computed: {
...mapState(['allowLabelEdit', 'labelsFetchInProgress']),
},
methods: {
...mapActions(['toggleDropdownContents']),
},
};
</script>
<template>
<div class="title hide-collapsed gl-mb-3">
{{ __('Labels') }}
<template v-if="allowLabelEdit">
<gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline />
<gl-button
category="tertiary"
size="small"
class="float-right js-sidebar-dropdown-toggle gl-mr-n2"
data-qa-selector="labels_edit_button"
@click="toggleDropdownContents"
>{{ __('Edit') }}</gl-button
>
</template>
</div>
</template>
......@@ -6,9 +6,7 @@ mutation createLabel($title: String!, $color: String, $projectPath: ID, $groupPa
id
color
description
descriptionHtml
title
textColor
}
errors
}
......
<script>
import $ from 'jquery';
import Vue from 'vue';
import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { DropdownVariant } from './constants';
import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import issueLabelsQuery from './graphql/issue_labels.query.graphql';
......@@ -19,11 +17,11 @@ Vue.use(Vuex);
export default {
store: new Vuex.Store(labelsSelectModule()),
components: {
DropdownTitle,
DropdownValue,
DropdownButton,
DropdownContents,
DropdownValueCollapsed,
SidebarEditableItem,
},
inject: ['iid', 'projectPath'],
props: {
......@@ -139,15 +137,12 @@ export default {
},
},
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
...mapState(['showDropdownContents']),
...mapGetters([
'isDropdownVariantSidebar',
'isDropdownVariantStandalone',
'isDropdownVariantEmbedded',
]),
dropdownButtonVisible() {
return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
},
},
watch: {
selectedLabels(selectedLabels) {
......@@ -182,99 +177,20 @@ export default {
footerCreateLabelTitle: this.footerCreateLabelTitle,
footerManageLabelTitle: this.footerManageLabelTitle,
});
this.$store.subscribeAction({
after: this.handleVuexActionDispatch,
});
document.addEventListener('mousedown', this.handleDocumentMousedown);
document.addEventListener('click', this.handleDocumentClick);
},
beforeDestroy() {
document.removeEventListener('mousedown', this.handleDocumentMousedown);
document.removeEventListener('click', this.handleDocumentClick);
},
methods: {
...mapActions(['setInitialState', 'toggleDropdownContents']),
/**
* This method stores a mousedown event's target.
* Required by the click listener because the click
* event itself has no reference to this element.
*/
handleDocumentMousedown({ target }) {
this.mousedownTarget = target;
},
/**
* This method listens for document-wide click event
* and toggle dropdown if user clicks anywhere outside
* the dropdown while dropdown is visible.
*/
handleDocumentClick({ target }) {
// We also perform the toggle exception check for the
// last mousedown event's target to avoid hiding the
// box when the mousedown happened inside the box and
// only the mouseup did not.
if (
this.showDropdownContents &&
!this.preventDropdownToggleOnClick(target) &&
!this.preventDropdownToggleOnClick(this.mousedownTarget)
) {
this.toggleDropdownContents();
}
},
/**
* This method checks whether a given click target
* should prevent the dropdown from being toggled.
*/
preventDropdownToggleOnClick(target) {
// This approach of element detection is needed
// as the dropdown wrapper is not using `GlDropdown` as
// it will also require us to use `BDropdownForm`
// which is yet to be implemented in GitLab UI.
const hasExceptionClass = [
'js-dropdown-button',
'js-btn-cancel-create',
'js-sidebar-dropdown-toggle',
].some(
(className) =>
target?.classList.contains(className) ||
target?.parentElement?.classList.contains(className),
);
const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
(className) => $(target).parents(className).length,
);
const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target);
const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target);
return (
hasExceptionClass ||
hasExceptionParent ||
isInDropdownButtonCollapsed ||
isInDropdownContents
);
},
...mapActions(['setInitialState']),
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');
},
collapseDropdown() {
this.$refs.editable.collapse();
},
handleCollapsedValueClick() {
this.$emit('toggleCollapse');
},
setContentIsOnViewport(showDropdownContents) {
if (!showDropdownContents) {
this.contentIsOnViewport = true;
return;
}
setContentIsOnViewport() {
this.$nextTick(() => {
if (this.$refs.dropdownContents) {
this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el);
......@@ -299,10 +215,14 @@ export default {
:labels="issueLabels"
@onValueClick="handleCollapsedValueClick"
/>
<dropdown-title
:allow-label-edit="allowLabelEdit"
:labels-select-in-progress="labelsSelectInProgress"
/>
<sidebar-editable-item
ref="editable"
:title="__('Labels')"
:loading="labelsSelectInProgress"
@open="setContentIsOnViewport"
@close="contentIsOnViewport = true"
>
<template #collapsed>
<dropdown-value
:disable-labels="labelsSelectInProgress"
:selected-labels="issueLabels"
......@@ -314,9 +234,23 @@ export default {
>
<slot></slot>
</dropdown-value>
<dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
</template>
<template #default="{ edit }">
<dropdown-value
:disable-labels="labelsSelectInProgress"
:selected-labels="issueLabels"
:allow-label-remove="allowLabelRemove"
:allow-scoped-labels="allowScopedLabels"
:labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam"
class="gl-mb-2"
@onLabelRemove="$emit('onLabelRemove', $event)"
>
<slot></slot>
</dropdown-value>
<dropdown-button />
<dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents"
v-if="edit"
ref="dropdownContents"
:allow-multiselect="allowMultiselect"
:labels-list-title="labelsListTitle"
......@@ -325,22 +259,11 @@ export default {
:render-on-top="!contentIsOnViewport"
:labels-create-title="labelsCreateTitle"
:selected-labels="selectedLabels"
@closeDropdown="handleDropdownClose"
@closeDropdown="collapseDropdown"
@setLabels="handleDropdownClose"
/>
</template>
<template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
<dropdown-button v-show="dropdownButtonVisible" />
<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"
/>
</sidebar-editable-item>
</template>
</div>
</template>
......@@ -7,7 +7,12 @@ import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue';
import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql';
import { mockSuggestedColors, createLabelSuccessfulResponse } from './mock_data';
import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
import {
mockSuggestedColors,
createLabelSuccessfulResponse,
labelsQueryResponse,
} from './mock_data';
jest.mock('~/flash');
......@@ -44,6 +49,14 @@ describe('DropdownContentsCreateView', () => {
const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => {
const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]);
mockApollo.clients.defaultClient.cache.writeQuery({
query: projectLabelsQuery,
data: labelsQueryResponse.data,
variables: {
fullPath: '',
searchTerm: '',
},
});
wrapper = shallowMount(DropdownContentsCreateView, {
localVue,
......
......@@ -137,12 +137,6 @@ describe('DropdownContentsLabelsView', () => {
expect(findLabels().at(0).attributes('islabelset')).toBe('true');
});
it('emits `closeDropdown event` when Esc button is pressed', () => {
findDropdownWrapper().trigger('keydown.esc');
expect(wrapper.emitted('closeDropdown')).toEqual([[selectedLabels]]);
});
});
it('when search returns 0 results', async () => {
......@@ -205,7 +199,7 @@ describe('DropdownContentsLabelsView', () => {
});
it('emits `toggleDropdownContentsCreateView` event on create label button click', () => {
findCreateLabelButton().vm.$emit('click');
findCreateLabelButton().vm.$emit('click', new MouseEvent('click'));
expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]);
});
......
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
import { mockConfig } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelsSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownTitle, {
localVue,
store,
propsData: {
labelsSelectInProgress: false,
},
});
};
describe('DropdownTitle', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders component container element with string "Labels"', () => {
expect(wrapper.text()).toContain('Labels');
});
it('renders edit link', () => {
const editBtnEl = wrapper.find(GlButton);
expect(editBtnEl.exists()).toBe(true);
expect(editBtnEl.text()).toBe('Edit');
});
it('renders loading icon element when `labelsSelectInProgress` prop is true', () => {
wrapper.setProps({
labelsSelectInProgress: true,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue';
import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue';
import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
......@@ -32,11 +28,13 @@ describe('LabelsSelectRoot', () => {
store,
propsData: config,
stubs: {
'dropdown-contents': DropdownContents,
DropdownContents,
SidebarEditableItem,
},
provide: {
iid: '1',
projectPath: 'test',
canUpdate: true,
},
});
};
......@@ -49,41 +47,9 @@ describe('LabelsSelectRoot', () => {
wrapper.destroy();
});
describe('methods', () => {
describe('handleDropdownClose', () => {
beforeEach(() => {
createComponent();
});
it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => {
wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]);
expect(wrapper.emitted().updateSelectedLabels).toBeTruthy();
expect(wrapper.emitted().onDropdownClose).toBeTruthy();
});
it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => {
wrapper.vm.handleDropdownClose([]);
expect(wrapper.emitted().updateSelectedLabels).toBeFalsy();
expect(wrapper.emitted().onDropdownClose).toBeTruthy();
});
});
describe('handleCollapsedValueClick', () => {
it('emits `toggleCollapse` event on component', () => {
createComponent();
wrapper.vm.handleCollapsedValueClick();
expect(wrapper.emitted().toggleCollapse).toBeTruthy();
});
});
});
describe('template', () => {
it('renders component with classes `labels-select-wrapper position-relative`', () => {
createComponent();
expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
expect(wrapper.classes()).toEqual(['labels-select-wrapper', 'position-relative']);
});
it.each`
......@@ -110,12 +76,6 @@ describe('LabelsSelectRoot', () => {
expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
});
it('renders `dropdown-title` component', async () => {
createComponent();
await wrapper.vm.$nextTick;
expect(wrapper.find(DropdownTitle).exists()).toBe(true);
});
it('renders `dropdown-value` component', async () => {
createComponent(mockConfig, {
default: 'None',
......@@ -127,67 +87,4 @@ describe('LabelsSelectRoot', () => {
expect(valueComp.exists()).toBe(true);
expect(valueComp.text()).toBe('None');
});
it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => {
createComponent();
wrapper.vm.$store.dispatch('toggleDropdownButton');
await wrapper.vm.$nextTick;
expect(wrapper.find(DropdownButton).exists()).toBe(true);
});
it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => {
createComponent();
wrapper.vm.$store.dispatch('toggleDropdownContents');
await wrapper.vm.$nextTick;
expect(wrapper.find(DropdownContents).exists()).toBe(true);
});
describe('sets content direction based on viewport', () => {
describe.each(Object.values(DropdownVariant))(
'when labels variant is "%s"',
({ variant }) => {
beforeEach(() => {
createComponent({ ...mockConfig, variant });
wrapper.vm.$store.dispatch('toggleDropdownContents');
});
it('set direction when out of viewport', () => {
isInViewport.mockImplementation(() => false);
wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true);
});
});
it('does not set direction when inside of viewport', () => {
isInViewport.mockImplementation(() => true);
wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
});
});
},
);
});
});
it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => {
createComponent();
jest.spyOn(store, 'dispatch').mockResolvedValue();
await wrapper.setProps({ isEditing: true });
expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents');
});
it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => {
createComponent();
jest.spyOn(store, 'dispatch').mockResolvedValue();
await wrapper.setProps({ isEditing: false });
expect(store.dispatch).not.toHaveBeenCalled();
});
});
......@@ -83,9 +83,7 @@ export const createLabelSuccessfulResponse = {
id: 'gid://gitlab/ProjectLabel/126',
color: '#dc143c',
description: null,
descriptionHtml: '',
title: 'ewrwrwer',
textColor: '#FFFFFF',
__typename: 'Label',
},
errors: [],
......
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