Skip to content
Snippets Groups Projects
Commit e97eae05 authored by Natalia Tepluhina's avatar Natalia Tepluhina
Browse files

Merge branch 'dc-issue-list-task-modal' into 'master'

Open work items in a modal when in a modal

See merge request !106618



Merged-by: default avatarNatalia Tepluhina <ntepluhina@gitlab.com>
Approved-by: Nick Leonard's avatarNick Leonard <nleonard@gitlab.com>
Approved-by: default avatarNatalia Tepluhina <ntepluhina@gitlab.com>
Reviewed-by: default avatarKushal Pandya <kushalspandya@gmail.com>
Reviewed-by: default avatarNatalia Tepluhina <ntepluhina@gitlab.com>
Reviewed-by: Donald Cook's avatarDonald Cook <dcook@gitlab.com>
Reviewed-by: Nick Leonard's avatarNick Leonard <nleonard@gitlab.com>
Co-authored-by: Donald Cook's avatarDonald Cook <dcook@gitlab.com>
parents 47aa498e aa63be84
No related branches found
No related tags found
1 merge request!106618Open work items in a modal when in a modal
Pipeline #742835171 passed
Showing
with 165 additions and 13 deletions
......@@ -15,8 +15,11 @@ import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg
import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterByName } from '~/lib/utils/url_utility';
import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import {
sprintfWorkItem,
......@@ -54,6 +57,7 @@ import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
import WorkItemMilestone from './work_item_milestone.vue';
import WorkItemNotes from './work_item_notes.vue';
import WorkItemDetailModal from './work_item_detail_modal.vue';
export default {
i18n,
......@@ -84,6 +88,7 @@ export default {
WorkItemMilestone,
WorkItemTree,
WorkItemNotes,
WorkItemDetailModal,
},
mixins: [glFeatureFlagMixin()],
inject: ['fullPath'],
......@@ -110,11 +115,16 @@ export default {
},
},
data() {
const workItemId = getParameterByName('work_item_id');
return {
error: undefined,
updateError: undefined,
workItem: {},
updateInProgress: false,
modalWorkItemId: isPositiveInteger(workItemId)
? convertToGraphQLId(TYPE_WORK_ITEM, workItemId)
: null,
};
},
apollo: {
......@@ -299,6 +309,11 @@ export default {
return widgetHierarchy.children.nodes;
},
},
mounted() {
if (this.modalWorkItemId) {
this.openInModal(undefined, { id: this.modalWorkItemId });
}
},
methods: {
isWidgetPresent(type) {
return this.workItem?.widgets?.find((widget) => widget.type === type);
......@@ -424,6 +439,26 @@ export default {
Sentry.captureException(error);
}
},
updateUrl(modalWorkItemId) {
updateHistory({
url: setUrlParams({ work_item_id: getIdFromGraphQLId(modalWorkItemId) }),
replace: true,
});
},
openInModal(event, modalWorkItem) {
if (event) {
event.preventDefault();
this.updateUrl(modalWorkItem.id);
}
if (this.isModal) {
this.$emit('update-modal', event, modalWorkItem.id);
return;
}
this.modalWorkItemId = modalWorkItem.id;
this.$refs.modal.show();
},
},
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
};
......@@ -461,6 +496,7 @@ export default {
category="tertiary"
:href="parentUrl"
:title="parentWorkItem.title"
@click="openInModal($event, parentWorkItem)"
>{{ parentWorkItem.title }}</gl-button
>
<gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" />
......@@ -632,6 +668,7 @@ export default {
:confidential="workItem.confidential"
@addWorkItemChild="addChild"
@removeChild="removeChild"
@show-modal="openInModal"
/>
<template v-if="workItemsMvcEnabled">
<work-item-notes
......@@ -652,5 +689,12 @@ export default {
:svg-path="noAccessSvgPath"
/>
</template>
<work-item-detail-modal
v-if="!isModal"
ref="modal"
:work-item-id="modalWorkItemId"
:show="true"
@close="updateUrl"
/>
</section>
</template>
......@@ -3,7 +3,6 @@ import { GlAlert, GlModal } from '@gitlab/ui';
import { s__ } from '~/locale';
import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql';
import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
import WorkItemDetail from './work_item_detail.vue';
export default {
i18n: {
......@@ -12,7 +11,7 @@ export default {
components: {
GlAlert,
GlModal,
WorkItemDetail,
WorkItemDetail: () => import('./work_item_detail.vue'),
},
props: {
workItemId: {
......@@ -46,12 +45,18 @@ export default {
default: null,
},
},
emits: ['workItemDeleted', 'close'],
emits: ['workItemDeleted', 'close', 'update-modal'],
data() {
return {
error: undefined,
updatedWorkItemId: null,
};
},
computed: {
displayedWorkItemId() {
return this.updatedWorkItemId || this.workItemId;
},
},
methods: {
deleteWorkItem() {
if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) {
......@@ -116,6 +121,7 @@ export default {
});
},
closeModal() {
this.updatedWorkItemId = null;
this.error = '';
this.$emit('close');
},
......@@ -128,6 +134,10 @@ export default {
show() {
this.$refs.modal.show();
},
updateModal($event, workItemId) {
this.updatedWorkItemId = workItemId;
this.$emit('update-modal', $event, workItemId);
},
},
};
</script>
......@@ -149,11 +159,12 @@ export default {
<work-item-detail
is-modal
:work-item-parent-id="issueGid"
:work-item-id="workItemId"
:work-item-id="displayedWorkItemId"
:work-item-iid="workItemIid"
class="gl-p-5 gl-mt-n3"
@close="hide"
@deleteWorkItem="deleteWorkItem"
@update-modal="updateModal"
/>
</gl-modal>
</template>
......
......@@ -264,6 +264,7 @@ export default {
:work-item-type="workItemType"
:children="children"
@removeChild="fetchChildren"
@click="$emit('click', $event)"
/>
</div>
</template>
......@@ -251,6 +251,7 @@ export default {
@mouseover="prefetchWorkItem(child)"
@mouseout="clearPrefetching"
@removeChild="$emit('removeChild', $event)"
@click="$emit('show-modal', $event, $event.childItem || child)"
/>
</div>
</div>
......
......@@ -63,6 +63,7 @@ export default {
:child-item="child"
:work-item-type="workItemType"
@removeChild="updateWorkItem"
@click="$emit('click', Object.assign($event, { childItem: child }))"
/>
</div>
</template>
......@@ -4,10 +4,11 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import { stubComponent } from 'helpers/stub_component';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import deleteWorkItemFromTaskMutation from '~/work_items/graphql/delete_task_from_work_item.mutation.graphql';
import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import {
deleteWorkItemFromTaskMutationErrorResponse,
deleteWorkItemFromTaskMutationResponse,
......@@ -69,8 +70,14 @@ describe('WorkItemDetailModal component', () => {
error,
};
},
provide: {
fullPath: 'group/project',
},
stubs: {
GlModal,
WorkItemDetail: stubComponent(WorkItemDetail, {
apollo: {},
}),
},
});
};
......@@ -126,6 +133,15 @@ describe('WorkItemDetailModal component', () => {
expect(closeSpy).toHaveBeenCalled();
});
it('updates the work item when WorkItemDetail emits `update-modal` event', async () => {
createComponent();
findWorkItemDetail().vm.$emit('update-modal', null, 'updatedId');
await waitForPromises();
expect(findWorkItemDetail().props().workItemId).toEqual('updatedId');
});
describe('delete work item', () => {
describe('when there is task data', () => {
it('emits workItemDeleted and closes modal', async () => {
......
......@@ -12,6 +12,7 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import setWindowLocation from 'helpers/set_window_location_helper';
import { stubComponent } from 'helpers/stub_component';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
import WorkItemDescription from '~/work_items/components/work_item_description.vue';
......@@ -23,6 +24,7 @@ import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue';
import WorkItemNotes from '~/work_items/components/work_item_notes.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
......@@ -64,6 +66,7 @@ describe('WorkItemDetail component', () => {
const assigneesSubscriptionHandler = jest
.fn()
.mockResolvedValue(workItemAssigneesSubscriptionResponse);
const showModalHandler = jest.fn();
const findAlert = () => wrapper.findComponent(GlAlert);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
......@@ -83,6 +86,7 @@ describe('WorkItemDetail component', () => {
const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]');
const findHierarchyTree = () => wrapper.findComponent(WorkItemTree);
const findNotesWidget = () => wrapper.findComponent(WorkItemNotes);
const findModal = () => wrapper.findComponent(WorkItemDetailModal);
const createComponent = ({
isModal = false,
......@@ -131,6 +135,11 @@ describe('WorkItemDetail component', () => {
stubs: {
WorkItemWeight: true,
WorkItemIteration: true,
WorkItemDetailModal: stubComponent(WorkItemDetailModal, {
methods: {
show: showModalHandler,
},
}),
},
});
};
......@@ -654,19 +663,68 @@ describe('WorkItemDetail component', () => {
expect(findHierarchyTree().exists()).toBe(false);
});
it('renders children tree when work item is an Objective', async () => {
describe('work item has children', () => {
const objectiveWorkItem = workItemResponseFactory({
workItemType: objectiveType,
confidential: true,
});
const handler = jest.fn().mockResolvedValue(objectiveWorkItem);
createComponent({ handler });
await waitForPromises();
expect(findHierarchyTree().exists()).toBe(true);
expect(findHierarchyTree().props()).toMatchObject({
parentWorkItemType: objectiveType.name,
confidential: objectiveWorkItem.data.workItem.confidential,
it('renders children tree when work item is an Objective', async () => {
createComponent({ handler });
await waitForPromises();
expect(findHierarchyTree().exists()).toBe(true);
});
it('renders a modal', async () => {
createComponent({ handler });
await waitForPromises();
expect(findModal().exists()).toBe(true);
});
it('opens the modal with the child when `show-modal` is emitted', async () => {
createComponent({ handler });
await waitForPromises();
const event = {
preventDefault: jest.fn(),
};
findHierarchyTree().vm.$emit('show-modal', event, { id: 'childWorkItemId' });
await waitForPromises();
expect(wrapper.findComponent(WorkItemDetailModal).props().workItemId).toBe(
'childWorkItemId',
);
expect(showModalHandler).toHaveBeenCalled();
});
describe('work item is rendered in a modal and has children', () => {
beforeEach(async () => {
createComponent({
isModal: true,
handler,
});
await waitForPromises();
});
it('does not render a new modal', () => {
expect(findModal().exists()).toBe(false);
});
it('emits `update-modal` when `show-modal` is emitted', async () => {
const event = {
preventDefault: jest.fn(),
};
findHierarchyTree().vm.$emit('show-modal', event, { id: 'childWorkItemId' });
await waitForPromises();
expect(wrapper.emitted('update-modal')).toBeDefined();
});
});
});
});
......
......@@ -261,5 +261,14 @@ describe('WorkItemLinkChild', () => {
message: 'Something went wrong while fetching children.',
});
});
it('click event on child emits `click` event', async () => {
findExpandButton().vm.$emit('click');
await waitForPromises();
findTreeChildren().vm.$emit('click', 'event');
expect(wrapper.emitted('click')).toEqual([['event']]);
});
});
});
......@@ -134,6 +134,17 @@ describe('WorkItemTree', () => {
expect(wrapper.emitted('removeChild')).toEqual([['gid://gitlab/WorkItem/2']]);
});
it('emits `show-modal` on `click` event', () => {
const firstChild = findWorkItemLinkChildItems().at(0);
const event = {
childItem: 'gid://gitlab/WorkItem/2',
};
firstChild.vm.$emit('click', event);
expect(wrapper.emitted('show-modal')).toEqual([[event, event.childItem]]);
});
it.each`
description | workItemType | prefetch
${'prefetches'} | ${'Issue'} | ${true}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment