Commit 527cdf20 authored by Eulyeon Ko's avatar Eulyeon Ko Committed by Brandon Labuschagne

Include iteration in the swimlane sidebar

Create 'board_sidebar_iteration_select.vue'
to support iteration in the swimlane sidebar.

'board_sidebar_iteration_select.vue'
is a modified COPY of 'iteration_select.vue'
(used for the existing issue sidebar.)

As we widgetize the sidebar components,
(https://gitlab.com/groups/gitlab-org/-/epics/5302)
we will refactor and merge 'board_sidebar_iteration.vue'
and 'iteration_selct.vue' into a sharable, reusable widget.
parent d4036b14
...@@ -317,6 +317,7 @@ As in other list types, click the trash icon to remove a list. ...@@ -317,6 +317,7 @@ As in other list types, click the trash icon to remove a list.
> - Grouping by epic [introduced](https://gitlab.com/groups/gitlab-org/-/epics/3352) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.6. > - Grouping by epic [introduced](https://gitlab.com/groups/gitlab-org/-/epics/3352) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.6.
> - Editing issue titles in the issue sidebar [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/232745) in GitLab 13.8. > - Editing issue titles in the issue sidebar [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/232745) in GitLab 13.8.
> - Editing iteration in the issue sidebar [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/290232) in GitLab 13.9.
With swimlanes you can visualize issues grouped by epic. With swimlanes you can visualize issues grouped by epic.
Your issue board keeps all the other features, but with a different visual organization of issues. Your issue board keeps all the other features, but with a different visual organization of issues.
......
...@@ -11,6 +11,7 @@ import { contentTop } from '~/lib/utils/common_utils'; ...@@ -11,6 +11,7 @@ import { contentTop } from '~/lib/utils/common_utils';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; 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 BoardSidebarIterationSelect from './sidebar/board_sidebar_iteration_select.vue';
import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue'; import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue';
...@@ -27,6 +28,7 @@ export default { ...@@ -27,6 +28,7 @@ export default {
BoardSidebarDueDate, BoardSidebarDueDate,
BoardSidebarSubscription, BoardSidebarSubscription,
BoardSidebarMilestoneSelect, BoardSidebarMilestoneSelect,
BoardSidebarIterationSelect,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
computed: { computed: {
...@@ -54,7 +56,6 @@ export default { ...@@ -54,7 +56,6 @@ export default {
@close="unsetActiveId" @close="unsetActiveId"
> >
<template #header>{{ __('Issue details') }}</template> <template #header>{{ __('Issue details') }}</template>
<template #default> <template #default>
<board-sidebar-issue-title /> <board-sidebar-issue-title />
<sidebar-assignees-widget <sidebar-assignees-widget
...@@ -64,7 +65,10 @@ export default { ...@@ -64,7 +65,10 @@ export default {
@assignees-updated="updateAssignees" @assignees-updated="updateAssignees"
/> />
<board-sidebar-epic-select /> <board-sidebar-epic-select />
<board-sidebar-milestone-select /> <div>
<board-sidebar-milestone-select />
<board-sidebar-iteration-select class="gl-mt-5" />
</div>
<board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
<board-sidebar-due-date /> <board-sidebar-due-date />
<board-sidebar-labels-select /> <board-sidebar-labels-select />
......
<script>
import {
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
GlDropdownDivider,
GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import { mapGetters } from 'vuex';
import {
iterationSelectTextMap,
iterationDisplayState,
noIteration,
edit,
none,
} from 'ee/sidebar/constants';
import groupIterationsQuery from 'ee/sidebar/queries/group_iterations.query.graphql';
import currentIterationQuery from 'ee/sidebar/queries/issue_iteration.query.graphql';
import setIssueIterationMutation from 'ee/sidebar/queries/set_iteration_on_issue.mutation.graphql';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import createFlash from '~/flash';
import * as Sentry from '~/sentry/wrapper';
const debounceValue = 250;
export default {
noIteration,
i18n: {
iteration: iterationSelectTextMap.iteration,
noIteration: iterationSelectTextMap.noIteration,
assignIteration: iterationSelectTextMap.assignIteration,
iterationSelectFail: iterationSelectTextMap.iterationSelectFail,
noIterationsFound: iterationSelectTextMap.noIterationsFound,
currentIterationFetchError: iterationSelectTextMap.currentIterationFetchError,
iterationsFetchError: iterationSelectTextMap.iterationsFetchError,
edit,
none,
},
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
BoardEditableItem,
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlDropdownDivider,
GlSearchBoxByType,
GlLoadingIcon,
},
apollo: {
currentIteration: {
query: currentIterationQuery,
variables() {
return {
fullPath: this.projectPathForActiveIssue,
iid: this.activeIssue.iid,
};
},
update(data) {
return data?.project?.issue.iteration;
},
error(error) {
createFlash({ message: this.$options.i18n.currentIterationFetchError });
Sentry.captureException(error);
},
},
iterations: {
query: groupIterationsQuery,
skip() {
return !this.editing;
},
debounce: debounceValue,
variables() {
const search = this.searchTerm === '' ? '' : `"${this.searchTerm}"`;
return {
fullPath: this.groupPathForActiveIssue,
title: search,
state: iterationDisplayState,
};
},
update(data) {
return data?.group?.iterations.nodes || [];
},
error(error) {
createFlash({ message: this.$options.i18n.iterationsFetchError });
Sentry.captureException(error);
},
},
},
data() {
return {
searchTerm: '',
editing: false,
updating: false,
selectedTitle: null,
currentIteration: null,
iterations: [],
};
},
computed: {
...mapGetters(['activeIssue', 'projectPathForActiveIssue', 'groupPathForActiveIssue']),
showCurrentIteration() {
return this.currentIteration !== null && !this.editing;
},
iteration() {
return this.findIteration(this.currentIteration);
},
iterationTitle() {
return this.currentIteration?.title;
},
iterationUrl() {
return this.currentIteration?.webUrl;
},
dropdownText() {
return this.currentIteration ? this.currentIteration?.title : this.$options.i18n.iteration;
},
showNoIterationContent() {
return !this.updating && !this.currentIteration;
},
loading() {
return this.updating || this.$apollo.queries.currentIteration.loading;
},
noIterations() {
return this.iterations.length === 0;
},
},
methods: {
handleOpen() {
this.editing = true;
this.$refs.dropdown.show();
},
handleClose() {
this.$refs.editableItem.collapse();
},
findIteration(iterationId) {
return this.iterations.find(({ id }) => id === iterationId);
},
setIteration(iterationId) {
this.editing = false;
if (iterationId === this.currentIteration?.id) return;
this.updating = true;
const selectedIteration = this.findIteration(iterationId);
this.selectedTitle = selectedIteration ? selectedIteration.title : this.$options.i18n.none;
this.$apollo
.mutate({
mutation: setIssueIterationMutation,
variables: {
projectPath: this.projectPathForActiveIssue,
iterationId,
iid: this.activeIssue.iid,
},
})
.then(({ data }) => {
if (data.issueSetIteration?.errors?.length) {
createFlash(data.issueSetIteration.errors[0]);
}
})
.catch(() => {
const { iterationSelectFail } = iterationSelectTextMap;
createFlash(iterationSelectFail);
})
.finally(() => {
this.updating = false;
this.searchTerm = '';
this.selectedTitle = null;
this.editing = false;
});
},
isIterationChecked(iterationId = undefined) {
return (
iterationId === this.currentIteration?.id || (!this.currentIteration?.id && !iterationId)
);
},
},
};
</script>
<template>
<board-editable-item
ref="editableItem"
:title="$options.i18n.iteration"
:loading="loading"
data-testid="iteration"
@open="handleOpen"
@close="handleClose"
>
<template #collapsed>
<gl-link v-if="showCurrentIteration" :href="iterationUrl"
><strong class="gl-text-gray-900">{{ iterationTitle }}</strong></gl-link
>
</template>
<gl-dropdown
ref="dropdown"
lazy
:header-text="$options.i18n.assignIteration"
:text="dropdownText"
:loading="loading"
class="gl-w-full"
@hide="handleClose"
>
<gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item
data-testid="no-iteration-item"
:is-check-item="true"
:is-checked="isIterationChecked($options.noIteration)"
@click="setIteration($options.noIteration)"
>
{{ $options.i18n.noIteration }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-loading-icon
v-if="$apollo.queries.iterations.loading"
class="gl-py-4"
data-testid="loading-icon-dropdown"
/>
<template v-else>
<gl-dropdown-text v-if="noIterations">
{{ $options.i18n.noIterationsFound }}
</gl-dropdown-text>
<gl-dropdown-item
v-for="iterationItem in iterations"
:key="iterationItem.id"
:is-check-item="true"
:is-checked="isIterationChecked(iterationItem.id)"
data-testid="iteration-items"
@click="setIteration(iterationItem.id)"
>{{ iterationItem.title }}</gl-dropdown-item
>
</template>
</gl-dropdown>
</board-editable-item>
</template>
...@@ -6,6 +6,9 @@ export const healthStatus = { ...@@ -6,6 +6,9 @@ export const healthStatus = {
AT_RISK: 'atRisk', AT_RISK: 'atRisk',
}; };
export const edit = __('Edit');
export const none = __('None');
export const healthStatusTextMap = { export const healthStatusTextMap = {
[healthStatus.ON_TRACK]: __('On track'), [healthStatus.ON_TRACK]: __('On track'),
[healthStatus.NEEDS_ATTENTION]: __('Needs attention'), [healthStatus.NEEDS_ATTENTION]: __('Needs attention'),
......
---
title: Add iteration to the sidebar in epics swimlanes
merge_request: 53695
author:
type: added
import { GlDrawer } from '@gitlab/ui'; import { GlDrawer } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import BoardContentSidebar from 'ee_component/boards/components/board_content_sidebar.vue'; import BoardContentSidebar from 'ee_component/boards/components/board_content_sidebar.vue';
import BoardSidebarIterationSelect from 'ee_component/boards/components/sidebar/board_sidebar_iteration_select.vue';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
...@@ -83,6 +84,10 @@ describe('ee/BoardContentSidebar', () => { ...@@ -83,6 +84,10 @@ describe('ee/BoardContentSidebar', () => {
expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true); expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true);
}); });
it('renders BoardSidebarIterationSelect', () => {
expect(wrapper.find(BoardSidebarIterationSelect).exists()).toBe(true);
});
describe('when we emit close', () => { describe('when we emit close', () => {
it('hides GlDrawer', async () => { it('hides GlDrawer', async () => {
expect(wrapper.find(GlDrawer).props('open')).toBe(true); expect(wrapper.find(GlDrawer).props('open')).toBe(true);
......
export const mockGroupPath = 'gitlab-org';
export const mockProjectPath = `${mockGroupPath}/some-project`;
export const mockIssue = { export const mockIssue = {
projectPath: 'gitlab-org/some-project', projectPath: mockProjectPath,
iid: '1',
groupPath: mockGroupPath,
};
// This mock issue has a different format b/c
// it is used in board_sidebar_iteration_select_spec.js (swimlane sidebar)
export const mockIssue2 = {
referencePath: `${mockProjectPath}#1`,
iid: '1', iid: '1',
groupPath: 'gitlab-org',
}; };
export const mockIssueId = 'gid://gitlab/Issue/1'; export const mockIssueId = 'gid://gitlab/Issue/1';
......
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