Commit 88c8a089 authored by Axel Garcia's avatar Axel Garcia Committed by Phil Hughes

Add weight component to swimlanes board sidebar

It uses gitlab/ui to render the weight form and
doesn't fetch any data as the weight is provided
by the stored issue.
parent 803d87fe
...@@ -39,13 +39,15 @@ export default { ...@@ -39,13 +39,15 @@ export default {
this.$emit('open'); this.$emit('open');
window.addEventListener('click', this.collapseWhenOffClick); window.addEventListener('click', this.collapseWhenOffClick);
}, },
collapse() { collapse({ emitEvent = true } = {}) {
if (!this.edit) { if (!this.edit) {
return; return;
} }
this.edit = false; this.edit = false;
if (emitEvent) {
this.$emit('close'); this.$emit('close');
}
window.removeEventListener('click', this.collapseWhenOffClick); window.removeEventListener('click', this.collapseWhenOffClick);
}, },
}, },
......
...@@ -5,7 +5,9 @@ import { ISSUABLE } from '~/boards/constants'; ...@@ -5,7 +5,9 @@ import { ISSUABLE } from '~/boards/constants';
import { contentTop } from '~/lib/utils/common_utils'; import { contentTop } from '~/lib/utils/common_utils';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import IssuableTitle from '~/boards/components/issuable_title.vue'; import IssuableTitle from '~/boards/components/issuable_title.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue'; import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue';
import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue';
export default { export default {
headerHeight: `${contentTop()}px`, headerHeight: `${contentTop()}px`,
...@@ -14,7 +16,9 @@ export default { ...@@ -14,7 +16,9 @@ export default {
GlDrawer, GlDrawer,
IssuableTitle, IssuableTitle,
BoardSidebarEpicSelect, BoardSidebarEpicSelect,
BoardSidebarWeightInput,
}, },
mixins: [glFeatureFlagsMixin()],
computed: { computed: {
...mapGetters(['isSidebarOpen', 'getActiveIssue']), ...mapGetters(['isSidebarOpen', 'getActiveIssue']),
...mapState(['sidebarType']), ...mapState(['sidebarType']),
...@@ -42,6 +46,7 @@ export default { ...@@ -42,6 +46,7 @@ export default {
<template> <template>
<issuable-assignees :users="getActiveIssue.assignees" /> <issuable-assignees :users="getActiveIssue.assignees" />
<board-sidebar-epic-select /> <board-sidebar-epic-select />
<board-sidebar-weight-input v-if="glFeatures.issueWeights" />
</template> </template>
</gl-drawer> </gl-drawer>
</template> </template>
<script>
import { mapGetters, mapActions } from 'vuex';
import { GlButton, GlForm, GlFormInput } from '@gitlab/ui';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import createFlash from '~/flash';
import { __ } from '~/locale';
export default {
components: {
BoardEditableItem,
GlForm,
GlButton,
GlFormInput,
},
directives: {
autofocusonshow,
},
data() {
return {
weight: null,
loading: false,
};
},
computed: {
...mapGetters({ issue: 'getActiveIssue' }),
hasWeight() {
return this.issue.weight > 0;
},
projectPath() {
const { referencePath = '' } = this.issue;
return referencePath.slice(0, referencePath.indexOf('#'));
},
},
watch: {
issue: {
handler(updatedIssue) {
this.weight = updatedIssue.weight;
},
immediate: true,
},
},
methods: {
...mapActions(['setActiveIssueWeight']),
handleFormSubmit() {
this.$refs.sidebarItem.collapse({ emitEvent: false });
this.setWeight();
},
async setWeight(provided) {
const weight = provided ?? this.weight;
if (this.loading || weight === this.issue.weight) {
return;
}
this.loading = true;
try {
await this.setActiveIssueWeight({ weight, projectPath: this.projectPath });
this.weight = weight;
} catch (e) {
this.weight = this.issue.weight;
createFlash({ message: __('An error occurred when updating the issue weight') });
} finally {
this.loading = false;
}
},
},
};
</script>
<template>
<board-editable-item
ref="sidebarItem"
:title="__('Weight')"
:loading="loading"
@close="setWeight()"
>
<template v-if="hasWeight" #collapsed>
<div class="gl-display-flex gl-align-items-center">
<strong class="gl-text-gray-900">{{ issue.weight }}</strong>
<span class="gl-mx-2">-</span>
<gl-button
variant="link"
class="gl-text-gray-400!"
data-testid="reset-button"
:disabled="loading"
@click="setWeight(0)"
>
{{ __('remove weight') }}
</gl-button>
</div>
</template>
<template>
<gl-form @submit.prevent="handleFormSubmit()">
<gl-form-input
v-model.number="weight"
v-autofocusonshow
type="number"
min="0"
:placeholder="__('Enter a number')"
/>
</gl-form>
</template>
</board-editable-item>
</template>
mutation issueSetWeight($input: IssueSetWeightInput!) {
issueSetWeight(input: $input) {
issue {
weight
}
errors
}
}
...@@ -9,6 +9,7 @@ import { BoardType, ListType } from '~/boards/constants'; ...@@ -9,6 +9,7 @@ import { BoardType, ListType } from '~/boards/constants';
import { EpicFilterType } from '../constants'; import { EpicFilterType } from '../constants';
import boardsStoreEE from './boards_store_ee'; import boardsStoreEE from './boards_store_ee';
import * as types from './mutation_types'; import * as types from './mutation_types';
import * as typesCE from '~/boards/stores/mutation_types';
import { fullEpicId } from '../boards_util'; import { fullEpicId } from '../boards_util';
import { formatListIssues, formatListsPageInfo, fullBoardId } from '~/boards/boards_util'; import { formatListIssues, formatListsPageInfo, fullBoardId } from '~/boards/boards_util';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
...@@ -17,6 +18,7 @@ import eventHub from '~/boards/eventhub'; ...@@ -17,6 +18,7 @@ import eventHub from '~/boards/eventhub';
import createGqClient, { fetchPolicies } from '~/lib/graphql'; import createGqClient, { fetchPolicies } from '~/lib/graphql';
import epicsSwimlanesQuery from '../queries/epics_swimlanes.query.graphql'; import epicsSwimlanesQuery from '../queries/epics_swimlanes.query.graphql';
import issueSetEpic from '../queries/issue_set_epic.mutation.graphql'; import issueSetEpic from '../queries/issue_set_epic.mutation.graphql';
import issueSetWeight from '../queries/issue_set_weight.mutation.graphql';
import listsIssuesQuery from '~/boards/queries/lists_issues.query.graphql'; import listsIssuesQuery from '~/boards/queries/lists_issues.query.graphql';
import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql'; import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
...@@ -267,6 +269,29 @@ export default { ...@@ -267,6 +269,29 @@ export default {
return data.issueSetEpic.issue.epic; return data.issueSetEpic.issue.epic;
}, },
setActiveIssueWeight: async ({ commit, getters }, input) => {
const { data } = await gqlClient.mutate({
mutation: issueSetWeight,
variables: {
input: {
iid: String(getters.getActiveIssue.iid),
weight: input.weight,
projectPath: input.projectPath,
},
},
});
if (!data.issueSetWeight || data.issueSetWeight?.errors?.length > 0) {
throw new Error(data.issueSetWeight?.errors);
}
commit(typesCE.UPDATE_ISSUE_BY_ID, {
issueId: getters.getActiveIssue.id,
prop: 'weight',
value: data.issueSetWeight.issue.weight,
});
},
moveIssue: ( moveIssue: (
{ state, commit }, { state, commit },
{ issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId, epicId }, { issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId, epicId },
......
...@@ -14,6 +14,7 @@ module EE ...@@ -14,6 +14,7 @@ module EE
# This is pushing a licensed Feature to the frontend. # This is pushing a licensed Feature to the frontend.
push_frontend_feature_flag(:wip_limits, type: :licensed, default_enabled: true) if parent.feature_available?(:wip_limits) push_frontend_feature_flag(:wip_limits, type: :licensed, default_enabled: true) if parent.feature_available?(:wip_limits)
push_frontend_feature_flag(:swimlanes, type: :licensed, default_enabled: true) if parent.feature_available?(:swimlanes) push_frontend_feature_flag(:swimlanes, type: :licensed, default_enabled: true) if parent.feature_available?(:swimlanes)
push_frontend_feature_flag(:issue_weights, type: :licensed, default_enabled: true) if parent.feature_available?(:issue_weights)
end end
end end
end end
...@@ -19,6 +19,7 @@ describe('ee/BoardContentSidebar', () => { ...@@ -19,6 +19,7 @@ describe('ee/BoardContentSidebar', () => {
store, store,
stubs: { stubs: {
'board-sidebar-epic-select': '<div></div>', 'board-sidebar-epic-select': '<div></div>',
'board-sidebar-weight-input': '<div></div>',
}, },
}); });
}; };
......
import { shallowMount } from '@vue/test-utils';
import { GlFormInput, GlForm } from '@gitlab/ui';
import BoardSidebarWeightInput from 'ee/boards/components/sidebar/board_sidebar_weight_input.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import createFlash from '~/flash';
import { createStore } from '~/boards/stores';
const TEST_WEIGHT = 1;
const TEST_ISSUE = { id: 'gid://gitlab/Issue/1', iid: 9, weight: 0, referencePath: 'h/b#2' };
jest.mock('~/flash');
describe('ee/boards/components/sidebar/board_sidebar_weight_input.vue', () => {
let wrapper;
let store;
afterEach(() => {
wrapper.destroy();
store = null;
wrapper = null;
});
const createWrapper = ({ weight = 0 } = {}) => {
store = createStore();
store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, weight } };
store.state.activeId = TEST_ISSUE.id;
wrapper = shallowMount(BoardSidebarWeightInput, {
store,
provide: {
canUpdate: true,
},
stubs: {
'board-editable-item': BoardEditableItem,
},
});
};
const findWeightForm = () => wrapper.find(GlForm);
const findWeightInput = () => wrapper.find(GlFormInput);
const findResetButton = () => wrapper.find('[data-testid="reset-button"]');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
it('renders "None" when no weight is selected', () => {
createWrapper();
expect(findCollapsed().text()).toBe('None');
});
it('renders weight with reset button when weight is set', () => {
createWrapper({ weight: TEST_WEIGHT });
expect(findCollapsed().text()).toContain(TEST_WEIGHT);
expect(findResetButton().exists()).toBe(true);
});
describe('when weight is submitted', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueWeight');
findWeightInput().vm.$emit('input', TEST_WEIGHT);
findWeightForm().vm.$emit('submit', { preventDefault: () => {} });
store.state.issues[TEST_ISSUE.id].weight = TEST_WEIGHT;
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders weight with reset button', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toContain(TEST_WEIGHT);
expect(findResetButton().exists()).toBe(true);
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueWeight).toHaveBeenCalledWith({
weight: TEST_WEIGHT,
projectPath: 'h/b',
});
});
});
describe('when weight is set to 0', () => {
beforeEach(async () => {
createWrapper({ weight: TEST_WEIGHT });
jest.spyOn(wrapper.vm, 'setActiveIssueWeight');
findWeightInput().vm.$emit('input', 0);
findWeightForm().vm.$emit('submit', { preventDefault: () => {} });
store.state.issues[TEST_ISSUE.id].weight = 0;
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders "None"', () => {
expect(wrapper.vm.setActiveIssueWeight).toHaveBeenCalled();
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe('None');
});
});
describe('when weight is resetted', () => {
beforeEach(async () => {
createWrapper({ weight: TEST_WEIGHT });
jest.spyOn(wrapper.vm, 'setActiveIssueWeight');
findResetButton().vm.$emit('click');
store.state.issues[TEST_ISSUE.id].weight = 0;
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders "None"', () => {
expect(wrapper.vm.setActiveIssueWeight).toHaveBeenCalled();
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe('None');
});
});
describe('when the mutation fails', () => {
beforeEach(async () => {
createWrapper({ weight: TEST_WEIGHT });
jest.spyOn(wrapper.vm, 'setActiveIssueWeight').mockImplementation(() => {
throw new Error(['failed mutation']);
});
findWeightInput().vm.$emit('input', -1);
findWeightForm().vm.$emit('submit', { preventDefault: () => {} });
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders former issue weight', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toContain(TEST_WEIGHT);
expect(createFlash).toHaveBeenCalled();
});
});
});
...@@ -4,6 +4,7 @@ import boardsStoreEE from 'ee/boards/stores/boards_store_ee'; ...@@ -4,6 +4,7 @@ import boardsStoreEE from 'ee/boards/stores/boards_store_ee';
import actions, { gqlClient } from 'ee/boards/stores/actions'; import actions, { gqlClient } from 'ee/boards/stores/actions';
import * as types from 'ee/boards/stores/mutation_types'; import * as types from 'ee/boards/stores/mutation_types';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import * as typesCE from '~/boards/stores/mutation_types';
import { ListType } from '~/boards/constants'; import { ListType } from '~/boards/constants';
import { formatListIssues } from '~/boards/boards_util'; import { formatListIssues } from '~/boards/boards_util';
import { import {
...@@ -390,6 +391,57 @@ describe('setActiveIssueEpic', () => { ...@@ -390,6 +391,57 @@ describe('setActiveIssueEpic', () => {
}); });
}); });
describe('setActiveIssueWeight', () => {
const state = { issues: { [mockIssue.id]: mockIssue } };
const getters = { getActiveIssue: mockIssue };
const testWeight = mockIssue.weight + 1;
const input = {
weight: testWeight,
projectPath: 'h/b',
};
it('should commit weight after setting the issue', done => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
issueSetWeight: {
issue: {
weight: testWeight,
},
errors: [],
},
},
});
const payload = {
issueId: getters.getActiveIssue.id,
prop: 'weight',
value: testWeight,
};
testAction(
actions.setActiveIssueWeight,
input,
{ ...state, ...getters },
[
{
type: typesCE.UPDATE_ISSUE_BY_ID,
payload,
},
],
[],
done,
);
});
it('throws error if fails', async () => {
jest
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { issueSetWeight: { errors: ['failed mutation'] } } });
await expect(actions.setActiveIssueWeight({ getters }, input)).rejects.toThrow(Error);
});
});
describe('moveIssue', () => { describe('moveIssue', () => {
const epicId = 'gid://gitlab/Epic/1'; const epicId = 'gid://gitlab/Epic/1';
......
...@@ -114,4 +114,16 @@ describe('boards sidebar remove issue', () => { ...@@ -114,4 +114,16 @@ describe('boards sidebar remove issue', () => {
expect(wrapper.emitted().open.length).toBe(1); expect(wrapper.emitted().open.length).toBe(1);
}); });
it('does not emits events when collapsing with false `emitEvent`', async () => {
createComponent({ canUpdate: true });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick();
wrapper.vm.collapse({ emitEvent: false });
expect(wrapper.emitted().close).toBeUndefined();
});
}); });
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