Commit d6e24584 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '198431-use-vue-labels-dropdown-epics-sidebar' into 'master'

Use Vue Labels Select for Epics sidebar

Closes #198431

See merge request gitlab-org/gitlab!26768
parents 0a136b97 43646d64
...@@ -122,9 +122,14 @@ export default { ...@@ -122,9 +122,14 @@ export default {
this.$store.subscribeAction({ this.$store.subscribeAction({
after: this.handleVuexActionDispatch, after: this.handleVuexActionDispatch,
}); });
document.addEventListener('click', this.handleDocumentClick);
},
beforeDestroy() {
document.removeEventListener('click', this.handleDocumentClick);
}, },
methods: { methods: {
...mapActions(['setInitialState']), ...mapActions(['setInitialState', 'toggleDropdownContents']),
/** /**
* This method differentiates between * This method differentiates between
* dispatched actions and calls necessary method. * dispatched actions and calls necessary method.
...@@ -138,6 +143,22 @@ export default { ...@@ -138,6 +143,22 @@ export default {
this.handleDropdownClose(state.labels.filter(label => label.touched)); this.handleDropdownClose(state.labels.filter(label => label.touched));
} }
}, },
/**
* This method listens for document-wide click event
* and toggle dropdown if user clicks anywhere outside
* the dropdown while dropdown is visible.
*/
handleDocumentClick({ target }) {
if (
this.showDropdownButton &&
this.showDropdownContents &&
!target?.classList.contains('js-sidebar-dropdown-toggle') &&
!this.$refs.dropdownButtonCollapsed?.$el.contains(target) &&
!this.$refs.dropdownContents?.$el.contains(target)
) {
this.toggleDropdownContents();
}
},
handleDropdownClose(labels) { handleDropdownClose(labels) {
// Only emit label updates if there are any labels to update // Only emit label updates if there are any labels to update
// on UI. // on UI.
...@@ -156,6 +177,7 @@ export default { ...@@ -156,6 +177,7 @@ export default {
<div v-if="!dropdownOnly"> <div v-if="!dropdownOnly">
<dropdown-value-collapsed <dropdown-value-collapsed
v-if="allowLabelCreate" v-if="allowLabelCreate"
ref="dropdownButtonCollapsed"
:labels="selectedLabels" :labels="selectedLabels"
@onValueClick="handleCollapsedValueClick" @onValueClick="handleCollapsedValueClick"
/> />
...@@ -167,7 +189,7 @@ export default { ...@@ -167,7 +189,7 @@ export default {
<slot></slot> <slot></slot>
</dropdown-value> </dropdown-value>
<dropdown-button v-show="showDropdownButton" /> <dropdown-button v-show="showDropdownButton" />
<dropdown-contents v-if="showDropdownButton && showDropdownContents" /> <dropdown-contents v-if="showDropdownButton && showDropdownContents" ref="dropdownContents" />
</div> </div>
</div> </div>
</template> </template>
...@@ -21,7 +21,10 @@ export default class ShortcutsEpic extends ShortcutsIssuable { ...@@ -21,7 +21,10 @@ export default class ShortcutsEpic extends ShortcutsIssuable {
if (parseBoolean(Cookies.get('collapsed_gutter'))) { if (parseBoolean(Cookies.get('collapsed_gutter'))) {
document.dispatchEvent(new Event('toggleSidebarRevealLabelsDropdown')); document.dispatchEvent(new Event('toggleSidebarRevealLabelsDropdown'));
} else { } else {
$block.find('.js-sidebar-dropdown-toggle').trigger('click'); $block
.find('.js-sidebar-dropdown-toggle')
.get(0)
.dispatchEvent(new Event('click'));
} }
} }
} }
...@@ -4,11 +4,11 @@ import _ from 'underscore'; ...@@ -4,11 +4,11 @@ import _ from 'underscore';
import ListLabel from '../../models/label'; import ListLabel from '../../models/label';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select/base.vue'; import LabelsSelectVue from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
export default { export default {
components: { components: {
LabelsSelect, LabelsSelectVue,
}, },
props: { props: {
canUpdate: { canUpdate: {
...@@ -27,6 +27,7 @@ export default { ...@@ -27,6 +27,7 @@ export default {
}, },
computed: { computed: {
...mapState([ ...mapState([
'epicId',
'labels', 'labels',
'namespace', 'namespace',
'updateEndpoint', 'updateEndpoint',
...@@ -35,6 +36,7 @@ export default { ...@@ -35,6 +36,7 @@ export default {
'epicsWebUrl', 'epicsWebUrl',
'scopedLabels', 'scopedLabels',
'scopedLabelsDocumentationLink', 'scopedLabelsDocumentationLink',
'epicLabelsSelectInProgress',
]), ]),
epicContext() { epicContext() {
return { return {
...@@ -55,7 +57,7 @@ export default { ...@@ -55,7 +57,7 @@ export default {
); );
}, },
methods: { methods: {
...mapActions(['toggleSidebar']), ...mapActions(['toggleSidebar', 'updateEpicLabels']),
toggleSidebarRevealLabelsDropdown() { toggleSidebarRevealLabelsDropdown() {
const contentContainer = this.$el.closest('.page-with-contextual-sidebar'); const contentContainer = this.$el.closest('.page-with-contextual-sidebar');
this.toggleSidebar({ sidebarCollapsed: this.sidebarCollapsed }); this.toggleSidebar({ sidebarCollapsed: this.sidebarCollapsed });
...@@ -99,26 +101,28 @@ export default { ...@@ -99,26 +101,28 @@ export default {
} }
} }
}, },
handleUpdateSelectedLabels(labels) {
this.updateEpicLabels(labels);
},
}, },
}; };
</script> </script>
<template> <template>
<labels-select <labels-select-vue
:can-edit="canUpdate" :allow-label-edit="canUpdate"
:context="epicContext" :allow-label-create="true"
:namespace="namespace" :allow-scoped-labels="scopedLabels"
:update-path="updateEndpoint" :selected-labels="labels"
:labels-path="labelsPath" :labels-select-in-progress="epicLabelsSelectInProgress"
:labels-web-url="labelsWebUrl" :labels-fetch-path="labelsPath"
:label-filter-base-path="epicsWebUrl" :labels-manage-path="labelsWebUrl"
:show-create="true" :labels-filter-base-path="epicsWebUrl"
:enable-scoped-labels="scopedLabels" :scoped-labels-documentation-path="scopedLabelsDocumentationLink"
:scoped-labels-documentation-link="scopedLabelsDocumentationLink" class="block labels js-labels-block"
ability-name="epic" @updateSelectedLabels="handleUpdateSelectedLabels"
@onLabelClick="handleLabelClick"
@onDropdownClose="handleDropdownClose" @onDropdownClose="handleDropdownClose"
@toggleCollapse="toggleSidebarRevealLabelsDropdown" @toggleCollapse="toggleSidebarRevealLabelsDropdown"
>{{ __('None') }}</labels-select >{{ __('None') }}</labels-select-vue
> >
</template> </template>
...@@ -4,6 +4,7 @@ import { mapActions } from 'vuex'; ...@@ -4,6 +4,7 @@ import { mapActions } from 'vuex';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import createStore from './store'; import createStore from './store';
import EpicApp from './components/epic_app.vue'; import EpicApp from './components/epic_app.vue';
...@@ -17,6 +18,7 @@ export default (epicCreate = false) => { ...@@ -17,6 +18,7 @@ export default (epicCreate = false) => {
} }
const store = createStore(); const store = createStore();
store.registerModule('labelsSelect', labelsSelectModule());
if (epicCreate) { if (epicCreate) {
return new Vue({ return new Vue({
......
...@@ -20,12 +20,14 @@ export default class SidebarContext { ...@@ -20,12 +20,14 @@ export default class SidebarContext {
// which requires us to use `display: none;` // which requires us to use `display: none;`
// in `labels_select/base.vue` as well. // in `labels_select/base.vue` as well.
// see: https://gitlab.com/gitlab-org/gitlab/merge_requests/4773#note_61844731 // see: https://gitlab.com/gitlab-org/gitlab/merge_requests/4773#note_61844731
const isVisible = Boolean($selectbox.get(0).offsetParent); if ($selectbox.length) {
$selectbox.toggle(!isVisible); const isVisible = Boolean($selectbox.get(0).offsetParent);
$block.find('.js-value').toggle(isVisible); $selectbox.toggle(!isVisible);
$block.find('.js-value').toggle(isVisible);
if ($selectbox.get(0).offsetParent) { if ($selectbox.get(0).offsetParent) {
setTimeout(() => $block.find('.js-label-select').trigger('click'), 0); setTimeout(() => $block.find('.js-label-select').trigger('click'), 0);
}
} }
}); });
......
...@@ -194,6 +194,47 @@ export const saveDate = ({ state, dispatch }, { dateType, dateTypeIsFixed, newDa ...@@ -194,6 +194,47 @@ export const saveDate = ({ state, dispatch }, { dateType, dateTypeIsFixed, newDa
}); });
}; };
/**
* Methods to handle Epic labels selection from sidebar
*/
export const requestEpicLabelsSelect = ({ commit }) => commit(types.REQUEST_EPIC_LABELS_SELECT);
export const receiveEpicLabelsSelectSuccess = ({ commit }, labels) =>
commit(types.RECEIVE_EPIC_LABELS_SELECT_SUCCESS, labels);
export const receiveEpicLabelsSelectFailure = ({ commit }) => {
commit(types.RECEIVE_EPIC_LABELS_SELECT_FAILURE);
flash(s__('Epics|An error occurred while updating labels.'));
};
export const updateEpicLabels = ({ dispatch, state }, labels) => {
const addLabelIds = labels.filter(label => label.set).map(label => label.id);
const removeLabelIds = labels.filter(label => !label.set).map(label => label.id);
const updateEpicInput = {
iid: `${state.epicIid}`,
groupPath: state.fullPath,
addLabelIds,
removeLabelIds,
};
dispatch('requestEpicLabelsSelect');
epicUtils.gqClient
.mutate({
mutation: updateEpic,
variables: {
updateEpicInput,
},
})
.then(({ data }) => {
if (!data?.updateEpic?.errors.length) {
dispatch('receiveEpicLabelsSelectSuccess', labels);
} else {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
throw new Error('An error occurred while updating labels');
}
})
.catch(() => {
dispatch('receiveEpicLabelsSelectFailure');
});
};
/** /**
* Methods to handle Epic subscription (AKA Notifications) toggle from sidebar * Methods to handle Epic subscription (AKA Notifications) toggle from sidebar
*/ */
......
...@@ -25,3 +25,7 @@ export const REQUEST_EPIC_SUBSCRIPTION_TOGGLE_FAILURE = 'REQUEST_EPIC_SUBSCRIPTI ...@@ -25,3 +25,7 @@ export const REQUEST_EPIC_SUBSCRIPTION_TOGGLE_FAILURE = 'REQUEST_EPIC_SUBSCRIPTI
export const SET_EPIC_CREATE_TITLE = 'SET_EPIC_CREATE_TITLE'; export const SET_EPIC_CREATE_TITLE = 'SET_EPIC_CREATE_TITLE';
export const REQUEST_EPIC_CREATE = 'REQUEST_EPIC_CREATE'; export const REQUEST_EPIC_CREATE = 'REQUEST_EPIC_CREATE';
export const REQUEST_EPIC_CREATE_FAILURE = 'REQUEST_EPIC_CREATE_FAILURE'; export const REQUEST_EPIC_CREATE_FAILURE = 'REQUEST_EPIC_CREATE_FAILURE';
export const REQUEST_EPIC_LABELS_SELECT = 'REQUEST_EPIC_LABELS_SELECT';
export const RECEIVE_EPIC_LABELS_SELECT_SUCCESS = 'RECEIVE_EPIC_LABELS_SELECT_SUCCESS';
export const RECEIVE_EPIC_LABELS_SELECT_FAILURE = 'RECEIVE_EPIC_LABELS_SELECT_FAILURE';
...@@ -102,4 +102,20 @@ export default { ...@@ -102,4 +102,20 @@ export default {
[types.REQUEST_EPIC_CREATE_FAILURE](state) { [types.REQUEST_EPIC_CREATE_FAILURE](state) {
state.epicCreateInProgress = false; state.epicCreateInProgress = false;
}, },
[types.REQUEST_EPIC_LABELS_SELECT](state) {
state.epicLabelsSelectInProgress = true;
},
[types.RECEIVE_EPIC_LABELS_SELECT_SUCCESS](state, labels) {
const addedLabels = labels.filter(label => label.set);
const removeLabelIds = labels.filter(label => !label.set).map(label => label.id);
const updatedLabels = state.labels.filter(label => !removeLabelIds.includes(label.id));
updatedLabels.push(...addedLabels);
state.epicLabelsSelectInProgress = false;
state.labels = updatedLabels;
},
[types.RECEIVE_EPIC_LABELS_SELECT_FAILURE](state) {
state.epicLabelsSelectInProgress = false;
},
}; };
...@@ -66,6 +66,7 @@ export default () => ({ ...@@ -66,6 +66,7 @@ export default () => ({
epicTodoToggleInProgress: false, epicTodoToggleInProgress: false,
epicStartDateSaveInProgress: false, epicStartDateSaveInProgress: false,
epicDueDateSaveInProgress: false, epicDueDateSaveInProgress: false,
epicLabelsSelectInProgress: false,
epicSubscriptionToggleInProgress: false, epicSubscriptionToggleInProgress: false,
epicCreateInProgress: false, epicCreateInProgress: false,
sidebarCollapsed: false, sidebarCollapsed: false,
......
...@@ -51,7 +51,7 @@ describe 'Assign labels to an epic', :js do ...@@ -51,7 +51,7 @@ describe 'Assign labels to an epic', :js do
it 'opens labels dropdown' do it 'opens labels dropdown' do
page.within('aside.right-sidebar') do page.within('aside.right-sidebar') do
expect(page).to have_css('.js-selectbox .dropdown.show') expect(page).to have_css('.js-labels-block .labels-select-dropdown-contents')
end end
end end
......
...@@ -30,7 +30,7 @@ describe 'Epic shortcuts', :js do ...@@ -30,7 +30,7 @@ describe 'Epic shortcuts', :js do
it "opens labels dropdown for editing" do it "opens labels dropdown for editing" do
find('body').native.send_key('l') find('body').native.send_key('l')
expect(find('.js-labels-block')).to have_selector('.dropdown-menu-labels.show') expect(find('.js-labels-block')).to have_selector('.labels-select-dropdown-contents')
end end
end end
......
import Vue from 'vue'; import Vuex from 'vuex';
import { mount } from '@vue/test-utils'; import { mount, createLocalVue } from '@vue/test-utils';
import SidebarLabels from 'ee/epic/components/sidebar_items/sidebar_labels.vue'; import SidebarLabels from 'ee/epic/components/sidebar_items/sidebar_labels.vue';
import createStore from 'ee/epic/store'; import createStore from 'ee/epic/store';
import { mockEpicMeta, mockEpicData, mockLabels } from '../../mock_data'; import { mockEpicMeta, mockEpicData, mockLabels } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('SidebarLabelsComponent', () => { describe('SidebarLabelsComponent', () => {
let wrapper; let wrapper;
let store; let store;
beforeEach(() => { beforeEach(() => {
const Component = Vue.extend(SidebarLabels);
store = createStore(); store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta); store.dispatch('setEpicMeta', mockEpicMeta);
store.dispatch('setEpicData', mockEpicData); store.dispatch('setEpicData', mockEpicData);
wrapper = mount(Component, { wrapper = mount(SidebarLabels, {
propsData: { canUpdate: false, sidebarCollapsed: false }, propsData: { canUpdate: false, sidebarCollapsed: false },
store, store,
stubs: { stubs: {
...@@ -63,7 +65,9 @@ describe('SidebarLabelsComponent', () => { ...@@ -63,7 +65,9 @@ describe('SidebarLabelsComponent', () => {
it('calls `toggleSidebar` action only when `sidebarExpandedOnClick` prop is true', () => { it('calls `toggleSidebar` action only when `sidebarExpandedOnClick` prop is true', () => {
jest.spyOn(wrapper.vm, 'toggleSidebar'); jest.spyOn(wrapper.vm, 'toggleSidebar');
wrapper.vm.sidebarExpandedOnClick = true; wrapper.setData({
sidebarExpandedOnClick: true,
});
wrapper.vm.handleDropdownClose(); wrapper.vm.handleDropdownClose();
...@@ -117,7 +121,7 @@ describe('SidebarLabelsComponent', () => { ...@@ -117,7 +121,7 @@ describe('SidebarLabelsComponent', () => {
describe('template', () => { describe('template', () => {
it('renders labels select element container', () => { it('renders labels select element container', () => {
expect(wrapper.vm.$el.classList.contains('js-labels-block')).toBe(true); expect(wrapper.classes('js-labels-block')).toBe(true);
}); });
}); });
}); });
...@@ -801,6 +801,130 @@ describe('Epic Store Actions', () => { ...@@ -801,6 +801,130 @@ describe('Epic Store Actions', () => {
}); });
}); });
describe('requestEpicLabelsSelect', () => {
it('should set `state.epicLabelsSelectInProgress` flag to `true`', done => {
testAction(
actions.requestEpicLabelsSelect,
{},
state,
[{ type: 'REQUEST_EPIC_LABELS_SELECT' }],
[],
done,
);
});
});
describe('receiveEpicLabelsSelectSuccess', () => {
it('should set provided labels param to `state.labels`', done => {
const labels = [{ id: 1, set: false }, { id: 2, set: true }];
testAction(
actions.receiveEpicLabelsSelectSuccess,
labels,
state,
[
{
type: 'RECEIVE_EPIC_LABELS_SELECT_SUCCESS',
payload: labels,
},
],
[],
done,
);
});
});
describe('receiveEpicLabelsSelectFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('should set `state.epicLabelsSelectInProgress` flag to `false`', done => {
testAction(
actions.receiveEpicLabelsSelectFailure,
{},
state,
[{ type: 'RECEIVE_EPIC_LABELS_SELECT_FAILURE' }],
[],
done,
);
});
it('should show flash error with message "An error occurred while updating labels."', () => {
actions.receiveEpicLabelsSelectFailure(
{
commit: () => {},
},
{},
);
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
'An error occurred while updating labels.',
);
});
});
describe('updateEpicLabels', () => {
const labels = [{ id: 1, set: false }, { id: 2, set: true }];
it('dispatches `requestEpicLabelsSelect` and `receiveEpicLabelsSelectSuccess` actions when request succeeds', done => {
jest.spyOn(epicUtils.gqClient, 'mutate').mockReturnValue(
Promise.resolve({
data: {
updateEpic: {
errors: [],
},
},
}),
);
testAction(
actions.updateEpicLabels,
labels,
state,
[],
[
{
type: 'requestEpicLabelsSelect',
},
{
type: 'receiveEpicLabelsSelectSuccess',
payload: labels,
},
],
done,
);
});
it('dispatches `requestEpicLabelsSelect` and `receiveEpicLabelsSelectFailure` actions when request fails', done => {
jest.spyOn(epicUtils.gqClient, 'mutate').mockReturnValue(
Promise.resolve({
data: {
updateEpic: {
errors: [{ foo: 1 }],
},
},
}),
);
testAction(
actions.updateEpicLabels,
labels,
state,
[],
[
{
type: 'requestEpicLabelsSelect',
},
{
type: 'receiveEpicLabelsSelectFailure',
},
],
done,
);
});
});
describe('requestEpicSubscriptionToggle', () => { describe('requestEpicSubscriptionToggle', () => {
it('should set `state.epicSubscriptionToggleInProgress` flag to `true`', done => { it('should set `state.epicSubscriptionToggleInProgress` flag to `true`', done => {
testAction( testAction(
......
...@@ -343,4 +343,69 @@ describe('Epic Store Mutations', () => { ...@@ -343,4 +343,69 @@ describe('Epic Store Mutations', () => {
expect(state.epicCreateInProgress).toBe(false); expect(state.epicCreateInProgress).toBe(false);
}); });
}); });
describe('REQUEST_EPIC_LABELS_SELECT', () => {
it('Should set `epicLabelsSelectInProgress` flag on state to `true`', () => {
const state = {
epicLabelsSelectInProgress: false,
};
mutations[types.REQUEST_EPIC_LABELS_SELECT](state);
expect(state.epicLabelsSelectInProgress).toBe(true);
});
});
describe('RECEIVE_EPIC_LABELS_SELECT_SUCCESS', () => {
it('Should update `labels` array on state when new labels are added', () => {
const addedLabels = [{ id: 1, set: true }, { id: 2, set: true }];
const state = {
labels: [],
};
mutations[types.RECEIVE_EPIC_LABELS_SELECT_SUCCESS](state, addedLabels);
expect(state.labels).toEqual(expect.arrayContaining(addedLabels));
});
it('Should update `labels` array on state when existing labels are removed', () => {
const removedLabels = [{ id: 1, set: false }];
const state = {
labels: [{ id: 1, set: true }, { id: 2, set: true }],
};
mutations[types.RECEIVE_EPIC_LABELS_SELECT_SUCCESS](state, removedLabels);
expect(state.labels).toEqual(expect.arrayContaining([{ id: 2, set: true }]));
});
it('Should update `labels` array on state when some labels are added and some are removed', () => {
const removedLabels = [{ id: 1, set: false }];
const addedLabels = [{ id: 3, set: true }];
const state = {
labels: [{ id: 1, set: true }, { id: 2, set: true }],
};
mutations[types.RECEIVE_EPIC_LABELS_SELECT_SUCCESS](state, [
...addedLabels,
...removedLabels,
]);
expect(state.labels).toEqual(
expect.arrayContaining([{ id: 2, set: true }, { id: 3, set: true }]),
);
});
});
describe('RECEIVE_EPIC_LABELS_SELECT_FAILURE', () => {
it('Should set `epicLabelsSelectInProgress` flag on state to `false`', () => {
const state = {
epicLabelsSelectInProgress: true,
};
mutations[types.RECEIVE_EPIC_LABELS_SELECT_FAILURE](state);
expect(state.epicLabelsSelectInProgress).toBe(false);
});
});
}); });
...@@ -7795,6 +7795,9 @@ msgstr "" ...@@ -7795,6 +7795,9 @@ msgstr ""
msgid "Epics|An error occurred while saving the %{epicDateType} date" msgid "Epics|An error occurred while saving the %{epicDateType} date"
msgstr "" msgstr ""
msgid "Epics|An error occurred while updating labels."
msgstr ""
msgid "Epics|Are you sure you want to remove %{bStart}%{targetIssueTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?" msgid "Epics|Are you sure you want to remove %{bStart}%{targetIssueTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?"
msgstr "" msgstr ""
......
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