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.
> - 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 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.
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';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
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 BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue';
......@@ -27,6 +28,7 @@ export default {
BoardSidebarDueDate,
BoardSidebarSubscription,
BoardSidebarMilestoneSelect,
BoardSidebarIterationSelect,
},
mixins: [glFeatureFlagsMixin()],
computed: {
......@@ -54,7 +56,6 @@ export default {
@close="unsetActiveId"
>
<template #header>{{ __('Issue details') }}</template>
<template #default>
<board-sidebar-issue-title />
<sidebar-assignees-widget
......@@ -64,7 +65,10 @@ export default {
@assignees-updated="updateAssignees"
/>
<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-due-date />
<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 = {
AT_RISK: 'atRisk',
};
export const edit = __('Edit');
export const none = __('None');
export const healthStatusTextMap = {
[healthStatus.ON_TRACK]: __('On track'),
[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 { shallowMount } from '@vue/test-utils';
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 waitForPromises from 'helpers/wait_for_promises';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
......@@ -83,6 +84,10 @@ describe('ee/BoardContentSidebar', () => {
expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true);
});
it('renders BoardSidebarIterationSelect', () => {
expect(wrapper.find(BoardSidebarIterationSelect).exists()).toBe(true);
});
describe('when we emit close', () => {
it('hides GlDrawer', async () => {
expect(wrapper.find(GlDrawer).props('open')).toBe(true);
......
import { GlDropdown, GlDropdownItem, GlDropdownText, GlLink, GlSearchBoxByType } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import BoardSidebarIterationSelect from 'ee/boards/components/sidebar/board_sidebar_iteration_select.vue';
import { iterationSelectTextMap, iterationDisplayState } 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 createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import getters from '~/boards/stores/getters';
import createFlash from '~/flash';
import * as Sentry from '~/sentry/wrapper';
import {
mockIssue2 as mockIssue,
mockProjectPath,
mockGroupPath,
mockIterationsResponse,
mockIteration2,
mockMutationResponse,
emptyIterationsResponse,
noCurrentIterationResponse,
} from '../../../sidebar/mock_data';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(Vuex);
describe('BoardSidebarIterationSelect', () => {
let wrapper;
let store;
let mockApollo;
const promiseData = { issueSetIteration: { issue: { iteration: { id: '123' } } } };
const firstErrorMsg = 'first error';
const promiseWithErrors = {
...promiseData,
issueSetIteration: { ...promiseData.issueSetIteration, errors: [firstErrorMsg] },
};
const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData });
const mutationError = () => jest.fn().mockRejectedValue();
const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors });
const findGlLink = () => wrapper.find(GlLink);
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownText = () => wrapper.find(GlDropdownText);
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findDropdownItemWithText = (text) =>
findAllDropdownItems().wrappers.find((x) => x.text() === text);
const findBoardEditableItem = () => wrapper.find(BoardEditableItem);
const findIterationItems = () => wrapper.findByTestId('iteration-items');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findNoIterationItem = () => wrapper.findByTestId('no-iteration-item');
const findLoadingIconDropdown = () => wrapper.findByTestId('loading-icon-dropdown');
const clickEdit = async () => {
findBoardEditableItem().vm.$emit('open');
await wrapper.vm.$nextTick();
};
const createStore = ({
initialState = {
activeId: mockIssue.id,
issues: { [mockIssue.id]: { ...mockIssue } },
},
} = {}) => {
store = new Vuex.Store({
state: initialState,
getters,
});
};
const createComponentWithApollo = async ({
requestHandlers = [],
currentIterationSpy = jest.fn().mockResolvedValue(noCurrentIterationResponse),
groupIterationsSpy = jest.fn().mockResolvedValue(mockIterationsResponse),
} = {}) => {
localVue.use(VueApollo);
mockApollo = createMockApollo([
[currentIterationQuery, currentIterationSpy],
[groupIterationsQuery, groupIterationsSpy],
...requestHandlers,
]);
wrapper = extendedWrapper(
shallowMount(BoardSidebarIterationSelect, {
localVue,
store,
apolloProvider: mockApollo,
provide: {
canUpdate: true,
},
stubs: {
BoardEditableItem,
},
}),
);
wrapper.vm.$refs.dropdown.show = jest.fn();
};
const createComponent = ({
data = {},
mutationPromise = mutationSuccess,
queries = {},
stubs = { GlSearchBoxByType },
} = {}) => {
createStore();
wrapper = extendedWrapper(
shallowMount(BoardSidebarIterationSelect, {
localVue,
store,
data() {
return data;
},
provide: {
canUpdate: true,
},
mocks: {
$apollo: {
mutate: mutationPromise(),
queries: {
currentIteration: { loading: false },
iterations: { loading: false },
...queries,
},
},
},
stubs: {
BoardEditableItem,
...stubs,
},
}),
);
wrapper.vm.$refs.dropdown.show = jest.fn();
wrapper.vm.$refs.editableItem.collapse = jest.fn();
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when not editing', () => {
beforeEach(() => {
createComponent({
data: {
currentIteration: { id: 'id', title: 'title', webUrl: 'webUrl' },
},
stubs: {
GlDropdown,
},
});
});
it('shows the current iteration', () => {
expect(findCollapsed().text()).toBe('title');
});
it('links to the current iteration', () => {
expect(findGlLink().attributes().href).toBe('webUrl');
});
describe('when current iteration does not exist', () => {
it('renders "None" as the selected iteration title', () => {
createComponent({
stubs: {
GlDropdown,
},
});
expect(findCollapsed().text()).toBe('None');
});
});
it('expands the dropdown on clicking edit', async () => {
createComponent();
await clickEdit();
expect(wrapper.vm.$refs.dropdown.show).toHaveBeenCalledTimes(1);
});
});
describe('when user is editing', () => {
describe('when rendering the dropdown', () => {
it('collapses BoardEditableItem on clicking edit', async () => {
createComponent();
await findBoardEditableItem().vm.$emit('close');
expect(wrapper.vm.$refs.editableItem.collapse).toHaveBeenCalledTimes(1);
});
it('collapses BoardEditableItem on hiding dropdown', async () => {
createComponent();
await findDropdown().vm.$emit('hide');
expect(wrapper.vm.$refs.editableItem.collapse).toHaveBeenCalledTimes(1);
});
it('shows a loading spinner while fetching a list of iterations', async () => {
createComponent({
queries: {
iterations: { loading: true },
},
});
await clickEdit();
expect(findLoadingIconDropdown().exists()).toBe(true);
});
describe('GlDropdownItem with the right title and id', () => {
const id = 'id';
const title = 'title';
beforeEach(async () => {
createComponent({
data: { iterations: [{ id, title }], currentIteration: { id, title } },
});
await clickEdit();
});
it('does not show a loading spinner', () => {
expect(findLoadingIconDropdown().exists()).toBe(false);
});
it('renders title $title', () => {
expect(findDropdownItemWithText(title).text()).toBe(title);
});
it('checks the correct dropdown item', () => {
expect(
findAllDropdownItems()
.filter((w) => w.props('isChecked') === true)
.at(0)
.text(),
).toBe(title);
});
});
describe('when no data is assigned', () => {
beforeEach(async () => {
createComponent();
await clickEdit();
});
it('finds GlDropdownItem with "No iteration"', () => {
expect(findNoIterationItem().text()).toBe('No iteration');
});
it('"No iteration" is checked', () => {
expect(findNoIterationItem().props('isChecked')).toBe(true);
});
it('does not render any dropdown item', () => {
expect(findIterationItems().exists()).toBe(false);
});
});
describe('when clicking on dropdown item', () => {
describe('when currentIteration is equal to iteration id', () => {
it('does not call setIssueIteration mutation', async () => {
createComponent({
data: {
iterations: [{ id: 'id', title: 'title' }],
currentIteration: { id: 'id', title: 'title' },
},
});
await clickEdit();
findDropdownItemWithText('title').vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0);
});
});
describe('when currentIteration is not equal to iteration id', () => {
describe('when error', () => {
const bootstrapComponent = (mutationResp) => {
createComponent({
data: {
iterations: [
{ id: '123', title: '123' },
{ id: 'id', title: 'title' },
],
currentIteration: '123',
},
mutationPromise: mutationResp,
});
};
describe.each`
description | mutationResp | expectedMsg
${'top-level error'} | ${mutationError} | ${iterationSelectTextMap.iterationSelectFail}
${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg}
`(`$description`, ({ mutationResp, expectedMsg }) => {
beforeEach(async () => {
bootstrapComponent(mutationResp);
await clickEdit();
findDropdownItemWithText('title').vm.$emit('click');
});
it('calls createFlash with $expectedMsg', async () => {
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith(expectedMsg);
});
});
});
});
});
});
describe('when a user is searching', () => {
describe('when search result is not found', () => {
it('renders "No iterations found"', async () => {
createComponent();
await clickEdit();
findSearchBox().vm.$emit('input', 'non existing iterations');
await wrapper.vm.$nextTick();
expect(findDropdownText().text()).toBe('No iterations found');
});
});
});
});
describe('With mock apollo', () => {
let error;
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
error = new Error('mayday');
});
describe('when clicking on dropdown item', () => {
describe('when currentIteration is not equal to iteration id', () => {
let setIssueIterationSpy;
describe('when update is successful', () => {
setIssueIterationSpy = jest.fn().mockResolvedValue(mockMutationResponse);
beforeEach(async () => {
createComponentWithApollo({
requestHandlers: [[setIssueIterationMutation, setIssueIterationSpy]],
});
await clickEdit();
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
findDropdownItemWithText(mockIteration2.title).vm.$emit('click');
});
it('calls setIssueIteration mutation', () => {
expect(setIssueIterationSpy).toHaveBeenCalledWith({
iid: mockIssue.iid,
iterationId: mockIteration2.id,
projectPath: mockProjectPath,
});
});
it('sets the value returned from the mutation to currentIteration', async () => {
expect(findCollapsed().text()).toBe(mockIteration2.title);
});
});
});
});
describe('currentIterations', () => {
it('should call createFlash and Sentry if currentIterations query fails', async () => {
createComponentWithApollo({
currentIterationSpy: jest.fn().mockRejectedValue(error),
});
await waitForPromises();
expect(createFlash).toHaveBeenNthCalledWith(1, {
message: wrapper.vm.$options.i18n.currentIterationFetchError,
});
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
});
});
describe('iterations', () => {
let groupIterationsSpy;
it('should call createFlash and Sentry if iterations query fails', async () => {
createComponentWithApollo({
groupIterationsSpy: jest.fn().mockRejectedValue(error),
});
await clickEdit();
jest.runOnlyPendingTimers();
await waitForPromises();
expect(createFlash).toHaveBeenNthCalledWith(1, {
message: wrapper.vm.$options.i18n.iterationsFetchError,
});
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
});
it('only fetches iterations when dropdown is opened', async () => {
groupIterationsSpy = jest.fn().mockResolvedValueOnce(emptyIterationsResponse);
createComponentWithApollo({ groupIterationsSpy });
await wrapper.vm.$nextTick();
jest.runOnlyPendingTimers();
expect(groupIterationsSpy).not.toHaveBeenCalled();
await clickEdit();
jest.runOnlyPendingTimers();
expect(groupIterationsSpy).toHaveBeenCalled();
});
describe('when a user is searching', () => {
const mockSearchTerm = 'foobar';
beforeEach(async () => {
groupIterationsSpy = jest.fn().mockResolvedValueOnce(emptyIterationsResponse);
createComponentWithApollo({ groupIterationsSpy });
await clickEdit();
});
it('sends a groupIterations query with the entered search term "foo"', async () => {
findSearchBox().vm.$emit('input', mockSearchTerm);
await wrapper.vm.$nextTick();
jest.runOnlyPendingTimers();
expect(groupIterationsSpy).toHaveBeenNthCalledWith(1, {
fullPath: mockGroupPath,
title: `"${mockSearchTerm}"`,
state: iterationDisplayState,
});
});
});
});
});
});
export const mockGroupPath = 'gitlab-org';
export const mockProjectPath = `${mockGroupPath}/some-project`;
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',
groupPath: 'gitlab-org',
};
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