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 {
this.$store.subscribeAction({
after: this.handleVuexActionDispatch,
});
document.addEventListener('click', this.handleDocumentClick);
},
beforeDestroy() {
document.removeEventListener('click', this.handleDocumentClick);
},
methods: {
...mapActions(['setInitialState']),
...mapActions(['setInitialState', 'toggleDropdownContents']),
/**
* This method differentiates between
* dispatched actions and calls necessary method.
......@@ -138,6 +143,22 @@ export default {
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) {
// Only emit label updates if there are any labels to update
// on UI.
......@@ -156,6 +177,7 @@ export default {
<div v-if="!dropdownOnly">
<dropdown-value-collapsed
v-if="allowLabelCreate"
ref="dropdownButtonCollapsed"
:labels="selectedLabels"
@onValueClick="handleCollapsedValueClick"
/>
......@@ -167,7 +189,7 @@ export default {
<slot></slot>
</dropdown-value>
<dropdown-button v-show="showDropdownButton" />
<dropdown-contents v-if="showDropdownButton && showDropdownContents" />
<dropdown-contents v-if="showDropdownButton && showDropdownContents" ref="dropdownContents" />
</div>
</div>
</template>
......@@ -21,7 +21,10 @@ export default class ShortcutsEpic extends ShortcutsIssuable {
if (parseBoolean(Cookies.get('collapsed_gutter'))) {
document.dispatchEvent(new Event('toggleSidebarRevealLabelsDropdown'));
} 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';
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 {
components: {
LabelsSelect,
LabelsSelectVue,
},
props: {
canUpdate: {
......@@ -27,6 +27,7 @@ export default {
},
computed: {
...mapState([
'epicId',
'labels',
'namespace',
'updateEndpoint',
......@@ -35,6 +36,7 @@ export default {
'epicsWebUrl',
'scopedLabels',
'scopedLabelsDocumentationLink',
'epicLabelsSelectInProgress',
]),
epicContext() {
return {
......@@ -55,7 +57,7 @@ export default {
);
},
methods: {
...mapActions(['toggleSidebar']),
...mapActions(['toggleSidebar', 'updateEpicLabels']),
toggleSidebarRevealLabelsDropdown() {
const contentContainer = this.$el.closest('.page-with-contextual-sidebar');
this.toggleSidebar({ sidebarCollapsed: this.sidebarCollapsed });
......@@ -99,26 +101,28 @@ export default {
}
}
},
handleUpdateSelectedLabels(labels) {
this.updateEpicLabels(labels);
},
},
};
</script>
<template>
<labels-select
:can-edit="canUpdate"
:context="epicContext"
:namespace="namespace"
:update-path="updateEndpoint"
:labels-path="labelsPath"
:labels-web-url="labelsWebUrl"
:label-filter-base-path="epicsWebUrl"
:show-create="true"
:enable-scoped-labels="scopedLabels"
:scoped-labels-documentation-link="scopedLabelsDocumentationLink"
ability-name="epic"
@onLabelClick="handleLabelClick"
<labels-select-vue
:allow-label-edit="canUpdate"
:allow-label-create="true"
:allow-scoped-labels="scopedLabels"
:selected-labels="labels"
:labels-select-in-progress="epicLabelsSelectInProgress"
:labels-fetch-path="labelsPath"
:labels-manage-path="labelsWebUrl"
:labels-filter-base-path="epicsWebUrl"
:scoped-labels-documentation-path="scopedLabelsDocumentationLink"
class="block labels js-labels-block"
@updateSelectedLabels="handleUpdateSelectedLabels"
@onDropdownClose="handleDropdownClose"
@toggleCollapse="toggleSidebarRevealLabelsDropdown"
>{{ __('None') }}</labels-select
>{{ __('None') }}</labels-select-vue
>
</template>
......@@ -4,6 +4,7 @@ import { mapActions } from 'vuex';
import Cookies from 'js-cookie';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/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 EpicApp from './components/epic_app.vue';
......@@ -17,6 +18,7 @@ export default (epicCreate = false) => {
}
const store = createStore();
store.registerModule('labelsSelect', labelsSelectModule());
if (epicCreate) {
return new Vue({
......
......@@ -20,12 +20,14 @@ export default class SidebarContext {
// which requires us to use `display: none;`
// in `labels_select/base.vue` as well.
// see: https://gitlab.com/gitlab-org/gitlab/merge_requests/4773#note_61844731
const isVisible = Boolean($selectbox.get(0).offsetParent);
$selectbox.toggle(!isVisible);
$block.find('.js-value').toggle(isVisible);
if ($selectbox.length) {
const isVisible = Boolean($selectbox.get(0).offsetParent);
$selectbox.toggle(!isVisible);
$block.find('.js-value').toggle(isVisible);
if ($selectbox.get(0).offsetParent) {
setTimeout(() => $block.find('.js-label-select').trigger('click'), 0);
if ($selectbox.get(0).offsetParent) {
setTimeout(() => $block.find('.js-label-select').trigger('click'), 0);
}
}
});
......
......@@ -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
*/
......
......@@ -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 REQUEST_EPIC_CREATE = 'REQUEST_EPIC_CREATE';
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 {
[types.REQUEST_EPIC_CREATE_FAILURE](state) {
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 () => ({
epicTodoToggleInProgress: false,
epicStartDateSaveInProgress: false,
epicDueDateSaveInProgress: false,
epicLabelsSelectInProgress: false,
epicSubscriptionToggleInProgress: false,
epicCreateInProgress: false,
sidebarCollapsed: false,
......
......@@ -51,7 +51,7 @@ describe 'Assign labels to an epic', :js do
it 'opens labels dropdown' 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
......
......@@ -30,7 +30,7 @@ describe 'Epic shortcuts', :js do
it "opens labels dropdown for editing" do
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
......
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import SidebarLabels from 'ee/epic/components/sidebar_items/sidebar_labels.vue';
import createStore from 'ee/epic/store';
import { mockEpicMeta, mockEpicData, mockLabels } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('SidebarLabelsComponent', () => {
let wrapper;
let store;
beforeEach(() => {
const Component = Vue.extend(SidebarLabels);
store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta);
store.dispatch('setEpicData', mockEpicData);
wrapper = mount(Component, {
wrapper = mount(SidebarLabels, {
propsData: { canUpdate: false, sidebarCollapsed: false },
store,
stubs: {
......@@ -63,7 +65,9 @@ describe('SidebarLabelsComponent', () => {
it('calls `toggleSidebar` action only when `sidebarExpandedOnClick` prop is true', () => {
jest.spyOn(wrapper.vm, 'toggleSidebar');
wrapper.vm.sidebarExpandedOnClick = true;
wrapper.setData({
sidebarExpandedOnClick: true,
});
wrapper.vm.handleDropdownClose();
......@@ -117,7 +121,7 @@ describe('SidebarLabelsComponent', () => {
describe('template', () => {
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', () => {
});
});
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', () => {
it('should set `state.epicSubscriptionToggleInProgress` flag to `true`', done => {
testAction(
......
......@@ -343,4 +343,69 @@ describe('Epic Store Mutations', () => {
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 ""
msgid "Epics|An error occurred while saving the %{epicDateType} date"
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}?"
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