Commit 0060fe98 authored by Simon Knox's avatar Simon Knox

Merge branch 'ntepluhina-create-task-integration' into 'master'

Replace local mutation for creating a task with the real mutation

See merge request gitlab-org/gitlab!81294
parents e30cd780 6ea43cb2
...@@ -19,3 +19,4 @@ export const TYPE_SCANNER_PROFILE = 'DastScannerProfile'; ...@@ -19,3 +19,4 @@ export const TYPE_SCANNER_PROFILE = 'DastScannerProfile';
export const TYPE_SITE_PROFILE = 'DastSiteProfile'; export const TYPE_SITE_PROFILE = 'DastSiteProfile';
export const TYPE_USER = 'User'; export const TYPE_USER = 'User';
export const TYPE_VULNERABILITY = 'Vulnerability'; export const TYPE_VULNERABILITY = 'Vulnerability';
export const TYPE_WORK_ITEM = 'WorkItem';
...@@ -185,6 +185,11 @@ export default { ...@@ -185,6 +185,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
issueId: {
type: Number,
required: false,
default: null,
},
}, },
data() { data() {
const store = new Store({ const store = new Store({
...@@ -534,6 +539,7 @@ export default { ...@@ -534,6 +539,7 @@ export default {
<component <component
:is="descriptionComponent" :is="descriptionComponent"
:issue-id="issueId"
:can-update="canUpdate" :can-update="canUpdate"
:description-html="state.descriptionHtml" :description-html="state.descriptionHtml"
:description-text="state.descriptionText" :description-text="state.descriptionText"
...@@ -545,6 +551,7 @@ export default { ...@@ -545,6 +551,7 @@ export default {
@taskListUpdateStarted="taskListUpdateStarted" @taskListUpdateStarted="taskListUpdateStarted"
@taskListUpdateSucceeded="taskListUpdateSucceeded" @taskListUpdateSucceeded="taskListUpdateSucceeded"
@taskListUpdateFailed="taskListUpdateFailed" @taskListUpdateFailed="taskListUpdateFailed"
@updateDescription="state.descriptionHtml = $event"
/> />
<edited-component <edited-component
......
...@@ -7,6 +7,8 @@ import { ...@@ -7,6 +7,8 @@ import {
GlButton, GlButton,
} from '@gitlab/ui'; } from '@gitlab/ui';
import $ from 'jquery'; import $ from 'jquery';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import TaskList from '~/task_list'; import TaskList from '~/task_list';
...@@ -63,6 +65,11 @@ export default { ...@@ -63,6 +65,11 @@ export default {
required: false, required: false,
default: 0, default: 0,
}, },
issueId: {
type: Number,
required: false,
default: null,
},
}, },
data() { data() {
return { return {
...@@ -81,6 +88,9 @@ export default { ...@@ -81,6 +88,9 @@ export default {
workItemsEnabled() { workItemsEnabled() {
return this.glFeatures.workItems; return this.glFeatures.workItems;
}, },
issueGid() {
return this.issueId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issueId) : null;
},
}, },
watch: { watch: {
descriptionHtml(newDescription, oldDescription) { descriptionHtml(newDescription, oldDescription) {
...@@ -92,6 +102,9 @@ export default { ...@@ -92,6 +102,9 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.renderGFM(); this.renderGFM();
if (this.workItemsEnabled) {
this.renderTaskActions();
}
}); });
}, },
taskStatus() { taskStatus() {
...@@ -168,9 +181,24 @@ export default { ...@@ -168,9 +181,24 @@ export default {
return; return;
} }
this.taskButtons = [];
const taskListFields = this.$el.querySelectorAll('.task-list-item'); const taskListFields = this.$el.querySelectorAll('.task-list-item');
taskListFields.forEach((item, index) => { taskListFields.forEach((item, index) => {
const taskLink = item.querySelector('.gfm-issue');
if (taskLink) {
const { issue, referenceType } = taskLink.dataset;
taskLink.addEventListener('click', (e) => {
e.preventDefault();
this.workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue);
this.track('viewed_work_item_from_modal', {
category: 'workItems:show',
label: 'work_item_view',
property: `type_${referenceType}`,
});
});
return;
}
const button = document.createElement('button'); const button = document.createElement('button');
button.classList.add( button.classList.add(
'btn', 'btn',
...@@ -195,7 +223,14 @@ export default { ...@@ -195,7 +223,14 @@ export default {
}); });
}, },
openCreateTaskModal(id) { openCreateTaskModal(id) {
this.activeTask = { id, title: this.$el.querySelector(`#${id}`).parentElement.innerText }; const { parentElement } = this.$el.querySelector(`#${id}`);
const lineNumbers = parentElement.getAttribute('data-sourcepos').match(/\b\d+(?=:)/g);
this.activeTask = {
id,
title: parentElement.innerText,
lineNumberStart: lineNumbers[0],
lineNumberEnd: lineNumbers[1],
};
this.$refs.modal.show(); this.$refs.modal.show();
}, },
closeCreateTaskModal() { closeCreateTaskModal() {
...@@ -207,38 +242,10 @@ export default { ...@@ -207,38 +242,10 @@ export default {
handleWorkItemDetailModalError(message) { handleWorkItemDetailModalError(message) {
createFlash({ message }); createFlash({ message });
}, },
handleCreateTask({ id, title, type }) { handleCreateTask(description) {
const listItem = this.$el.querySelector(`#${this.activeTask.id}`).parentElement; this.$emit('updateDescription', description);
const taskBadge = document.createElement('span');
taskBadge.innerHTML = `
<svg data-testid="issue-open-m-icon" role="img" aria-hidden="true" class="gl-icon gl-fill-green-500 s12">
<use href="${gon.sprite_icons}#issue-open-m"></use>
</svg>
<span class="badge badge-info badge-pill gl-badge sm gl-mr-1">
${__('Task')}
</span>
`;
const button = this.createWorkItemDetailButton(id, title, type);
taskBadge.append(button);
listItem.insertBefore(taskBadge, listItem.lastChild);
listItem.removeChild(listItem.lastChild);
this.closeCreateTaskModal(); this.closeCreateTaskModal();
}, },
createWorkItemDetailButton(id, title, type) {
const button = document.createElement('button');
button.addEventListener('click', () => {
this.workItemId = id;
this.track('viewed_work_item_from_modal', {
category: 'workItems:show',
label: 'work_item_view',
property: `type_${type}`,
});
});
button.classList.add('btn-link');
button.innerText = title;
return button;
},
focusButton() { focusButton() {
this.$refs.convertButton[0].$el.focus(); this.$refs.convertButton[0].$el.focus();
}, },
...@@ -287,6 +294,10 @@ export default { ...@@ -287,6 +294,10 @@ export default {
<create-work-item <create-work-item
:is-modal="true" :is-modal="true"
:initial-title="activeTask.title" :initial-title="activeTask.title"
:issue-gid="issueGid"
:lock-version="lockVersion"
:line-number-start="activeTask.lineNumberStart"
:line-number-end="activeTask.lineNumberEnd"
@closeModal="closeCreateTaskModal" @closeModal="closeCreateTaskModal"
@onCreate="handleCreateTask" @onCreate="handleCreateTask"
/> />
......
...@@ -102,7 +102,7 @@ export function initIssueApp(issueData, store) { ...@@ -102,7 +102,7 @@ export function initIssueApp(issueData, store) {
isConfidential: this.getNoteableData?.confidential, isConfidential: this.getNoteableData?.confidential,
isLocked: this.getNoteableData?.discussion_locked, isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state, issuableStatus: this.getNoteableData?.state,
id: this.getNoteableData?.id, issueId: this.getNoteableData?.id,
}, },
}); });
}, },
......
<script> <script>
import { GlModal } from '@gitlab/ui'; import { GlModal, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import workItemQuery from '../graphql/work_item.query.graphql'; import workItemQuery from '../graphql/work_item.query.graphql';
import ItemTitle from './item_title.vue'; import ItemTitle from './item_title.vue';
...@@ -7,6 +7,7 @@ import ItemTitle from './item_title.vue'; ...@@ -7,6 +7,7 @@ import ItemTitle from './item_title.vue';
export default { export default {
components: { components: {
GlModal, GlModal,
GlLoadingIcon,
ItemTitle, ItemTitle,
}, },
props: { props: {
...@@ -57,6 +58,7 @@ export default { ...@@ -57,6 +58,7 @@ export default {
<template> <template>
<gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="$emit('close')"> <gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="$emit('close')">
<item-title class="gl-m-0!" :initial-title="workItemTitle" /> <gl-loading-icon v-if="$apollo.queries.workItem.loading" size="md" />
<item-title v-else class="gl-m-0!" :initial-title="workItemTitle" />
</gl-modal> </gl-modal>
</template> </template>
mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) {
workItemCreateFromTask(input: $input) {
workItem {
id
descriptionHtml
}
errors
}
}
<script> <script>
import { GlButton, GlAlert, GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import workItemQuery from '../graphql/work_item.query.graphql'; import workItemQuery from '../graphql/work_item.query.graphql';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql';
import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
import ItemTitle from '../components/item_title.vue'; import ItemTitle from '../components/item_title.vue';
export default { export default {
createErrorText: s__('WorkItem|Something went wrong when creating a work item. Please try again'),
fetchTypesErrorText: s__(
'WorkItem|Something went wrong when fetching work item types. Please try again',
),
components: { components: {
GlButton, GlButton,
GlAlert, GlAlert,
GlLoadingIcon, GlLoadingIcon,
GlDropdown,
GlDropdownItem,
ItemTitle, ItemTitle,
GlFormSelect,
}, },
inject: ['fullPath'], inject: ['fullPath'],
props: { props: {
...@@ -29,6 +33,26 @@ export default { ...@@ -29,6 +33,26 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
issueGid: {
type: String,
required: false,
default: '',
},
lockVersion: {
type: Number,
required: false,
default: null,
},
lineNumberStart: {
type: String,
required: false,
default: null,
},
lineNumberEnd: {
type: String,
required: false,
default: null,
},
}, },
data() { data() {
return { return {
...@@ -36,6 +60,7 @@ export default { ...@@ -36,6 +60,7 @@ export default {
error: null, error: null,
workItemTypes: [], workItemTypes: [],
selectedWorkItemType: null, selectedWorkItemType: null,
loading: false,
}; };
}, },
apollo: { apollo: {
...@@ -47,12 +72,13 @@ export default { ...@@ -47,12 +72,13 @@ export default {
}; };
}, },
update(data) { update(data) {
return data.workspace?.workItemTypes?.nodes; return data.workspace?.workItemTypes?.nodes.map((node) => ({
value: node.id,
text: node.name,
}));
}, },
error() { error() {
this.error = s__( this.error = this.$options.fetchTypesErrorText;
'WorkItem|Something went wrong when fetching work item types. Please try again',
);
}, },
}, },
}, },
...@@ -60,9 +86,27 @@ export default { ...@@ -60,9 +86,27 @@ export default {
dropdownButtonText() { dropdownButtonText() {
return this.selectedWorkItemType?.name || s__('WorkItem|Type'); return this.selectedWorkItemType?.name || s__('WorkItem|Type');
}, },
formOptions() {
return [
{ value: null, text: s__('WorkItem|Please select work item type') },
...this.workItemTypes,
];
},
isButtonDisabled() {
return this.title.trim().length === 0 || !this.selectedWorkItemType;
},
}, },
methods: { methods: {
async createWorkItem() { async createWorkItem() {
this.loading = true;
if (this.isModal) {
await this.createWorkItemFromTask();
} else {
await this.createStandaloneWorkItem();
}
this.loading = false;
},
async createStandaloneWorkItem() {
try { try {
const response = await this.$apollo.mutate({ const response = await this.$apollo.mutate({
mutation: createWorkItemMutation, mutation: createWorkItemMutation,
...@@ -70,7 +114,7 @@ export default { ...@@ -70,7 +114,7 @@ export default {
input: { input: {
title: this.title, title: this.title,
projectPath: this.fullPath, projectPath: this.fullPath,
workItemTypeId: this.selectedWorkItemType?.id, workItemTypeId: this.selectedWorkItemType,
}, },
}, },
update(store, { data: { workItemCreate } }) { update(store, { data: { workItemCreate } }) {
...@@ -96,23 +140,38 @@ export default { ...@@ -96,23 +140,38 @@ export default {
}); });
}, },
}); });
const { const {
data: { data: {
workItemCreate: { workItemCreate: {
workItem: { id, type }, workItem: { id },
}, },
}, },
} = response; } = response;
if (!this.isModal) {
this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } }); this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } });
} else { } catch {
this.$emit('onCreate', { id, title: this.title, type }); this.error = this.$options.createErrorText;
} }
},
async createWorkItemFromTask() {
try {
const { data } = await this.$apollo.mutate({
mutation: createWorkItemFromTaskMutation,
variables: {
input: {
id: this.issueGid,
workItemData: {
lockVersion: this.lockVersion,
title: this.title,
lineNumberStart: Number(this.lineNumberStart),
lineNumberEnd: Number(this.lineNumberEnd),
workItemTypeId: this.selectedWorkItemType,
},
},
},
});
this.$emit('onCreate', data.workItemCreateFromTask.workItem.descriptionHtml);
} catch { } catch {
this.error = s__( this.error = this.$options.createErrorText;
'WorkItem|Something went wrong when creating a work item. Please try again',
);
} }
}, },
handleTitleInput(title) { handleTitleInput(title) {
...@@ -125,9 +184,6 @@ export default { ...@@ -125,9 +184,6 @@ export default {
} }
this.$emit('closeModal'); this.$emit('closeModal');
}, },
selectWorkItemType(type) {
this.selectedWorkItemType = type;
},
}, },
}; };
</script> </script>
...@@ -142,22 +198,17 @@ export default { ...@@ -142,22 +198,17 @@ export default {
@title-input="handleTitleInput" @title-input="handleTitleInput"
/> />
<div> <div>
<gl-dropdown :text="dropdownButtonText">
<gl-loading-icon <gl-loading-icon
v-if="$apollo.queries.workItemTypes.loading" v-if="$apollo.queries.workItemTypes.loading"
size="md" size="md"
data-testid="loading-types" data-testid="loading-types"
/> />
<template v-else> <gl-form-select
<gl-dropdown-item v-else
v-for="type in workItemTypes" v-model="selectedWorkItemType"
:key="type.id" :options="formOptions"
@click="selectWorkItemType(type)" class="gl-max-w-26"
> />
{{ type.name }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</div> </div>
</div> </div>
<div <div
...@@ -166,8 +217,9 @@ export default { ...@@ -166,8 +217,9 @@ export default {
> >
<gl-button <gl-button
variant="confirm" variant="confirm"
:disabled="title.length === 0" :disabled="isButtonDisabled"
:class="{ 'gl-mr-3': !isModal }" :class="{ 'gl-mr-3': !isModal }"
:loading="loading"
data-testid="create-button" data-testid="create-button"
type="submit" type="submit"
> >
......
...@@ -42218,6 +42218,9 @@ msgstr "" ...@@ -42218,6 +42218,9 @@ msgstr ""
msgid "WorkItem|New Task" msgid "WorkItem|New Task"
msgstr "" msgstr ""
msgid "WorkItem|Please select work item type"
msgstr ""
msgid "WorkItem|Something went wrong when creating a work item. Please try again" msgid "WorkItem|Something went wrong when creating a work item. Please try again"
msgstr "" msgstr ""
......
...@@ -2,11 +2,14 @@ import { GlIntersectionObserver } from '@gitlab/ui'; ...@@ -2,11 +2,14 @@ import { GlIntersectionObserver } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import '~/behaviors/markdown/render_gfm'; import '~/behaviors/markdown/render_gfm';
import { IssuableStatus, IssuableStatusText } from '~/issues/constants'; import { IssuableStatus, IssuableStatusText } from '~/issues/constants';
import IssuableApp from '~/issues/show/components/app.vue'; import IssuableApp from '~/issues/show/components/app.vue';
import DescriptionComponent from '~/issues/show/components/description.vue'; import DescriptionComponent from '~/issues/show/components/description.vue';
import EditedComponent from '~/issues/show/components/edited.vue';
import FormComponent from '~/issues/show/components/form.vue';
import TitleComponent from '~/issues/show/components/title.vue';
import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue'; import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue';
import PinnedLinks from '~/issues/show/components/pinned_links.vue'; import PinnedLinks from '~/issues/show/components/pinned_links.vue';
import { POLLING_DELAY } from '~/issues/show/constants'; import { POLLING_DELAY } from '~/issues/show/constants';
...@@ -21,10 +24,6 @@ import { ...@@ -21,10 +24,6 @@ import {
zoomMeetingUrl, zoomMeetingUrl,
} from '../mock_data/mock_data'; } from '../mock_data/mock_data';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
}
jest.mock('~/lib/utils/url_utility'); jest.mock('~/lib/utils/url_utility');
jest.mock('~/issues/show/event_hub'); jest.mock('~/issues/show/event_hub');
...@@ -39,10 +38,15 @@ describe('Issuable output', () => { ...@@ -39,10 +38,15 @@ describe('Issuable output', () => {
const findLockedBadge = () => wrapper.findByTestId('locked'); const findLockedBadge = () => wrapper.findByTestId('locked');
const findConfidentialBadge = () => wrapper.findByTestId('confidential'); const findConfidentialBadge = () => wrapper.findByTestId('confidential');
const findHiddenBadge = () => wrapper.findByTestId('hidden'); const findHiddenBadge = () => wrapper.findByTestId('hidden');
const findAlert = () => wrapper.find('.alert');
const findTitle = () => wrapper.findComponent(TitleComponent);
const findDescription = () => wrapper.findComponent(DescriptionComponent);
const findEdited = () => wrapper.findComponent(EditedComponent);
const findForm = () => wrapper.findComponent(FormComponent);
const findPinnedLinks = () => wrapper.findComponent(PinnedLinks);
const mountComponent = (props = {}, options = {}, data = {}) => { const mountComponent = (props = {}, options = {}, data = {}) => {
wrapper = mountExtended(IssuableApp, { wrapper = shallowMountExtended(IssuableApp, {
directives: { directives: {
GlTooltip: createMockDirective(), GlTooltip: createMockDirective(),
}, },
...@@ -104,23 +108,15 @@ describe('Issuable output', () => { ...@@ -104,23 +108,15 @@ describe('Issuable output', () => {
}); });
it('should render a title/description/edited and update title/description/edited on update', () => { it('should render a title/description/edited and update title/description/edited on update', () => {
let editedText;
return axios return axios
.waitForAll() .waitForAll()
.then(() => { .then(() => {
editedText = wrapper.find('.edited-text'); expect(findTitle().props('titleText')).toContain('this is a title');
}) expect(findDescription().props('descriptionText')).toContain('this is a description');
.then(() => {
expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
expect(wrapper.find('.title').text()).toContain('this is a title');
expect(wrapper.find('.md').text()).toContain('this is a description!');
expect(wrapper.find('.js-task-list-field').element.value).toContain(
'this is a description',
);
expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/); expect(findEdited().exists()).toBe(true);
expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/); expect(findEdited().props('updatedByPath')).toMatch(/\/some_user$/);
expect(editedText.find('time').text()).toBeTruthy(); expect(findEdited().props('updatedAt')).toBeTruthy();
expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version); expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version);
}) })
.then(() => { .then(() => {
...@@ -128,20 +124,13 @@ describe('Issuable output', () => { ...@@ -128,20 +124,13 @@ describe('Issuable output', () => {
return axios.waitForAll(); return axios.waitForAll();
}) })
.then(() => { .then(() => {
expect(document.querySelector('title').innerText).toContain('2 (#1)'); expect(findTitle().props('titleText')).toContain('2');
expect(wrapper.find('.title').text()).toContain('2'); expect(findDescription().props('descriptionText')).toContain('42');
expect(wrapper.find('.md').text()).toContain('42');
expect(wrapper.find('.js-task-list-field').element.value).toContain('42');
expect(wrapper.find('.edited-text').text()).toBeTruthy();
expect(formatText(wrapper.find('.edited-text').text())).toMatch(
/Edited[\s\S]+?by Other User/,
);
expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/); expect(findEdited().exists()).toBe(true);
expect(editedText.find('time').text()).toBeTruthy(); expect(findEdited().props('updatedByName')).toBe('Other User');
// As the lock_version value does not differ from the server, expect(findEdited().props('updatedByPath')).toMatch(/\/other_user$/);
// we should not see an alert expect(findEdited().props('updatedAt')).toBeTruthy();
expect(findAlert().exists()).toBe(false);
}); });
}); });
...@@ -149,7 +138,7 @@ describe('Issuable output', () => { ...@@ -149,7 +138,7 @@ describe('Issuable output', () => {
wrapper.vm.showForm = true; wrapper.vm.showForm = true;
await nextTick(); await nextTick();
expect(wrapper.find('.markdown-selector').exists()).toBe(true); expect(findForm().exists()).toBe(true);
}); });
it('does not show actions if permissions are incorrect', async () => { it('does not show actions if permissions are incorrect', async () => {
...@@ -157,7 +146,7 @@ describe('Issuable output', () => { ...@@ -157,7 +146,7 @@ describe('Issuable output', () => {
wrapper.setProps({ canUpdate: false }); wrapper.setProps({ canUpdate: false });
await nextTick(); await nextTick();
expect(wrapper.find('.markdown-selector').exists()).toBe(false); expect(findForm().exists()).toBe(false);
}); });
it('does not update formState if form is already open', async () => { it('does not update formState if form is already open', async () => {
...@@ -177,8 +166,7 @@ describe('Issuable output', () => { ...@@ -177,8 +166,7 @@ describe('Issuable output', () => {
${'zoomMeetingUrl'} | ${zoomMeetingUrl} ${'zoomMeetingUrl'} | ${zoomMeetingUrl}
${'publishedIncidentUrl'} | ${publishedIncidentUrl} ${'publishedIncidentUrl'} | ${publishedIncidentUrl}
`('sets the $prop correctly on underlying pinned links', ({ prop, value }) => { `('sets the $prop correctly on underlying pinned links', ({ prop, value }) => {
expect(wrapper.vm[prop]).toBe(value); expect(findPinnedLinks().props(prop)).toBe(value);
expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value);
}); });
}); });
...@@ -327,7 +315,6 @@ describe('Issuable output', () => { ...@@ -327,7 +315,6 @@ describe('Issuable output', () => {
expect(wrapper.vm.formState.lockedWarningVisible).toBe(true); expect(wrapper.vm.formState.lockedWarningVisible).toBe(true);
expect(wrapper.vm.formState.lock_version).toBe(1); expect(wrapper.vm.formState.lock_version).toBe(1);
expect(findAlert().exists()).toBe(true);
}); });
}); });
...@@ -374,15 +361,22 @@ describe('Issuable output', () => { ...@@ -374,15 +361,22 @@ describe('Issuable output', () => {
}); });
describe('show inline edit button', () => { describe('show inline edit button', () => {
it('should not render by default', () => { it('should render by default', () => {
expect(wrapper.find('.btn-edit').exists()).toBe(true); expect(findTitle().props('showInlineEditButton')).toBe(true);
}); });
it('should render if showInlineEditButton', async () => { it('should render if showInlineEditButton', async () => {
wrapper.setProps({ showInlineEditButton: true }); wrapper.setProps({ showInlineEditButton: true });
await nextTick(); await nextTick();
expect(wrapper.find('.btn-edit').exists()).toBe(true); expect(findTitle().props('showInlineEditButton')).toBe(true);
});
it('should not render if showInlineEditButton is false', async () => {
wrapper.setProps({ showInlineEditButton: false });
await nextTick();
expect(findTitle().props('showInlineEditButton')).toBe(false);
}); });
}); });
...@@ -533,13 +527,11 @@ describe('Issuable output', () => { ...@@ -533,13 +527,11 @@ describe('Issuable output', () => {
describe('Composable description component', () => { describe('Composable description component', () => {
const findIncidentTabs = () => wrapper.findComponent(IncidentTabs); const findIncidentTabs = () => wrapper.findComponent(IncidentTabs);
const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent);
const findPinnedLinks = () => wrapper.findComponent(PinnedLinks);
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'; const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
describe('when using description component', () => { describe('when using description component', () => {
it('renders the description component', () => { it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true); expect(findDescription().exists()).toBe(true);
}); });
it('does not render incident tabs', () => { it('does not render incident tabs', () => {
...@@ -572,8 +564,8 @@ describe('Issuable output', () => { ...@@ -572,8 +564,8 @@ describe('Issuable output', () => {
); );
}); });
it('renders the description component', () => { it('does not the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true); expect(findDescription().exists()).toBe(false);
}); });
it('renders incident tabs', () => { it('renders incident tabs', () => {
......
...@@ -14,6 +14,7 @@ import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; ...@@ -14,6 +14,7 @@ import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import { import {
descriptionProps as initialProps, descriptionProps as initialProps,
descriptionHtmlWithCheckboxes, descriptionHtmlWithCheckboxes,
descriptionHtmlWithTask,
} from '../mock_data/mock_data'; } from '../mock_data/mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -29,7 +30,6 @@ describe('Description component', () => { ...@@ -29,7 +30,6 @@ describe('Description component', () => {
const findTextarea = () => wrapper.find('[data-testid="textarea"]'); const findTextarea = () => wrapper.find('[data-testid="textarea"]');
const findTaskActionButtons = () => wrapper.findAll('.js-add-task'); const findTaskActionButtons = () => wrapper.findAll('.js-add-task');
const findConvertToTaskButton = () => wrapper.find('[data-testid="convert-to-task"]'); const findConvertToTaskButton = () => wrapper.find('[data-testid="convert-to-task"]');
const findTaskSvg = () => wrapper.find('[data-testid="issue-open-m-icon"]');
const findPopovers = () => wrapper.findAllComponents(GlPopover); const findPopovers = () => wrapper.findAllComponents(GlPopover);
const findModal = () => wrapper.findComponent(GlModal); const findModal = () => wrapper.findComponent(GlModal);
...@@ -39,6 +39,7 @@ describe('Description component', () => { ...@@ -39,6 +39,7 @@ describe('Description component', () => {
function createComponent({ props = {}, provide = {} } = {}) { function createComponent({ props = {}, provide = {} } = {}) {
wrapper = shallowMountExtended(Description, { wrapper = shallowMountExtended(Description, {
propsData: { propsData: {
issueId: 1,
...initialProps, ...initialProps,
...props, ...props,
}, },
...@@ -277,33 +278,21 @@ describe('Description component', () => { ...@@ -277,33 +278,21 @@ describe('Description component', () => {
expect(hideModal).toHaveBeenCalled(); expect(hideModal).toHaveBeenCalled();
}); });
it('updates description HTML on `onCreate` event', async () => { it('emits `updateDescription` on `onCreate` event', async () => {
const newTitle = 'New title'; const newDescription = `<p>New description</p>`;
findConvertToTaskButton().vm.$emit('click'); findCreateWorkItem().vm.$emit('onCreate', newDescription);
findCreateWorkItem().vm.$emit('onCreate', { title: newTitle });
expect(hideModal).toHaveBeenCalled(); expect(hideModal).toHaveBeenCalled();
await nextTick(); expect(wrapper.emitted('updateDescription')).toEqual([[newDescription]]);
expect(findTaskSvg().exists()).toBe(true);
expect(wrapper.text()).toContain(newTitle);
}); });
}); });
describe('work items detail', () => { describe('work items detail', () => {
const id = '1'; const findTaskLink = () => wrapper.find('a.gfm-issue');
const title = 'my first task';
const type = 'task';
const createThenClickOnTask = () => {
findConvertToTaskButton().vm.$emit('click');
findCreateWorkItem().vm.$emit('onCreate', { id, title, type });
return wrapper.findByRole('button', { name: title }).trigger('click');
};
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
props: { props: {
descriptionHtml: descriptionHtmlWithCheckboxes, descriptionHtml: descriptionHtmlWithTask,
}, },
provide: { provide: {
glFeatures: { workItems: true }, glFeatures: { workItems: true },
...@@ -315,13 +304,13 @@ describe('Description component', () => { ...@@ -315,13 +304,13 @@ describe('Description component', () => {
it('opens when task button is clicked', async () => { it('opens when task button is clicked', async () => {
expect(findWorkItemDetailModal().props('visible')).toBe(false); expect(findWorkItemDetailModal().props('visible')).toBe(false);
await createThenClickOnTask(); await findTaskLink().trigger('click');
expect(findWorkItemDetailModal().props('visible')).toBe(true); expect(findWorkItemDetailModal().props('visible')).toBe(true);
}); });
it('closes from an open state', async () => { it('closes from an open state', async () => {
await createThenClickOnTask(); await findTaskLink().trigger('click');
expect(findWorkItemDetailModal().props('visible')).toBe(true); expect(findWorkItemDetailModal().props('visible')).toBe(true);
...@@ -334,7 +323,7 @@ describe('Description component', () => { ...@@ -334,7 +323,7 @@ describe('Description component', () => {
it('shows error on error', async () => { it('shows error on error', async () => {
const message = 'I am error'; const message = 'I am error';
await createThenClickOnTask(); await findTaskLink().trigger('click');
findWorkItemDetailModal().vm.$emit('error', message); findWorkItemDetailModal().vm.$emit('error', message);
expect(createFlash).toHaveBeenCalledWith({ message }); expect(createFlash).toHaveBeenCalledWith({ message });
...@@ -343,7 +332,7 @@ describe('Description component', () => { ...@@ -343,7 +332,7 @@ describe('Description component', () => {
it('tracks when opened', async () => { it('tracks when opened', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
await createThenClickOnTask(); await findTaskLink().trigger('click');
expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'viewed_work_item_from_modal', { expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'viewed_work_item_from_modal', {
category: 'workItems:show', category: 'workItems:show',
......
...@@ -72,3 +72,18 @@ export const descriptionHtmlWithCheckboxes = ` ...@@ -72,3 +72,18 @@ export const descriptionHtmlWithCheckboxes = `
</li> </li>
</ul> </ul>
`; `;
export const descriptionHtmlWithTask = `
<ul data-sourcepos="1:1-3:7" class="task-list" dir="auto">
<li data-sourcepos="1:1-1:10" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled>
<a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip">1 (#48)</a>
</li>
<li data-sourcepos="2:1-2:7" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled> 2
</li>
<li data-sourcepos="3:1-3:7" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled> 3
</li>
</ul>
`;
import { GlModal } from '@gitlab/ui'; import { GlModal, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemTitle from '~/work_items/components/item_title.vue'; import WorkItemTitle from '~/work_items/components/item_title.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import { resolvers } from '~/work_items/graphql/resolvers'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import { workItemQueryResponse } from '../mock_data';
describe('WorkItemDetailModal component', () => { describe('WorkItemDetailModal component', () => {
let wrapper; let wrapper;
Vue.use(VueApollo); Vue.use(VueApollo);
const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
const findModal = () => wrapper.findComponent(GlModal); const findModal = () => wrapper.findComponent(GlModal);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
const createComponent = () => { const createComponent = ({ workItemId = '1', handler = successHandler } = {}) => {
wrapper = shallowMount(WorkItemDetailModal, { wrapper = shallowMount(WorkItemDetailModal, {
apolloProvider: createMockApollo([], resolvers), apolloProvider: createMockApollo([[workItemQuery, handler]]),
propsData: { visible: true }, propsData: { visible: true, workItemId },
}); });
}; };
...@@ -32,9 +36,57 @@ describe('WorkItemDetailModal component', () => { ...@@ -32,9 +36,57 @@ describe('WorkItemDetailModal component', () => {
expect(findModal().props()).toMatchObject({ visible: true }); expect(findModal().props()).toMatchObject({ visible: true });
}); });
it('renders work item title', () => { describe('when there is no `workItemId` prop', () => {
beforeEach(() => {
createComponent({ workItemId: null });
});
it('renders empty title when there is no `workItemId` prop', () => {
expect(findWorkItemTitle().exists()).toBe(true);
});
it('skips the work item query', () => {
expect(successHandler).not.toHaveBeenCalled();
});
});
describe('when loading', () => {
beforeEach(() => {
createComponent(); createComponent();
});
it('renders loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not render title', () => {
expect(findWorkItemTitle().exists()).toBe(false);
});
});
describe('when loaded', () => {
beforeEach(() => {
createComponent();
return waitForPromises();
});
it('does not render loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('renders title', () => {
expect(findWorkItemTitle().exists()).toBe(true); expect(findWorkItemTitle().exists()).toBe(true);
}); });
});
it('emits an error if query has errored', async () => {
const errorHandler = jest.fn().mockRejectedValue('Oops');
createComponent({ handler: errorHandler });
expect(errorHandler).toHaveBeenCalled();
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([
['Something went wrong when fetching the work item. Please try again.'],
]);
});
}); });
...@@ -78,3 +78,17 @@ export const createWorkItemMutationResponse = { ...@@ -78,3 +78,17 @@ export const createWorkItemMutationResponse = {
}, },
}, },
}; };
export const createWorkItemFromTaskMutationResponse = {
data: {
workItemCreateFromTask: {
__typename: 'WorkItemCreateFromTaskPayload',
errors: [],
workItem: {
descriptionHtml: '<p>New description</p>',
id: 'gid://gitlab/WorkItem/13',
__typename: 'WorkItem',
},
},
},
};
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { GlAlert, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlAlert, GlFormSelect } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
...@@ -9,7 +9,12 @@ import ItemTitle from '~/work_items/components/item_title.vue'; ...@@ -9,7 +9,12 @@ import ItemTitle from '~/work_items/components/item_title.vue';
import { resolvers } from '~/work_items/graphql/resolvers'; import { resolvers } from '~/work_items/graphql/resolvers';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data'; import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
import {
projectWorkItemTypesQueryResponse,
createWorkItemMutationResponse,
createWorkItemFromTaskMutationResponse,
} from '../mock_data';
jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] })); jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] }));
...@@ -20,12 +25,15 @@ describe('Create work item component', () => { ...@@ -20,12 +25,15 @@ describe('Create work item component', () => {
let fakeApollo; let fakeApollo;
const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse); const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse);
const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse); const createWorkItemSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse);
const createWorkItemFromTaskSuccessHandler = jest
.fn()
.mockResolvedValue(createWorkItemFromTaskMutationResponse);
const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
const findAlert = () => wrapper.findComponent(GlAlert); const findAlert = () => wrapper.findComponent(GlAlert);
const findTitleInput = () => wrapper.findComponent(ItemTitle); const findTitleInput = () => wrapper.findComponent(ItemTitle);
const findDropdown = () => wrapper.findComponent(GlDropdown); const findSelect = () => wrapper.findComponent(GlFormSelect);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findCreateButton = () => wrapper.find('[data-testid="create-button"]'); const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
...@@ -36,12 +44,13 @@ describe('Create work item component', () => { ...@@ -36,12 +44,13 @@ describe('Create work item component', () => {
data = {}, data = {},
props = {}, props = {},
queryHandler = querySuccessHandler, queryHandler = querySuccessHandler,
mutationHandler = mutationSuccessHandler, mutationHandler = createWorkItemSuccessHandler,
} = {}) => { } = {}) => {
fakeApollo = createMockApollo( fakeApollo = createMockApollo(
[ [
[projectWorkItemTypesQuery, queryHandler], [projectWorkItemTypesQuery, queryHandler],
[createWorkItemMutation, mutationHandler], [createWorkItemMutation, mutationHandler],
[createWorkItemFromTaskMutation, mutationHandler],
], ],
resolvers, resolvers,
); );
...@@ -123,6 +132,7 @@ describe('Create work item component', () => { ...@@ -123,6 +132,7 @@ describe('Create work item component', () => {
props: { props: {
isModal: true, isModal: true,
}, },
mutationHandler: createWorkItemFromTaskSuccessHandler,
}); });
}); });
...@@ -133,14 +143,12 @@ describe('Create work item component', () => { ...@@ -133,14 +143,12 @@ describe('Create work item component', () => {
}); });
it('emits `onCreate` on successful mutation', async () => { it('emits `onCreate` on successful mutation', async () => {
const mockTitle = 'Test title';
findTitleInput().vm.$emit('title-input', 'Test title'); findTitleInput().vm.$emit('title-input', 'Test title');
wrapper.find('form').trigger('submit'); wrapper.find('form').trigger('submit');
await waitForPromises(); await waitForPromises();
const expected = { id: '1', title: mockTitle }; expect(wrapper.emitted('onCreate')).toEqual([['<p>New description</p>']]);
expect(wrapper.emitted('onCreate')).toEqual([[expected]]);
}); });
it('does not right margin for create button', () => { it('does not right margin for create button', () => {
...@@ -177,16 +185,14 @@ describe('Create work item component', () => { ...@@ -177,16 +185,14 @@ describe('Create work item component', () => {
}); });
it('displays a list of work item types', () => { it('displays a list of work item types', () => {
expect(findDropdownItems()).toHaveLength(2); expect(findSelect().attributes('options').split(',')).toHaveLength(3);
expect(findDropdownItems().at(0).text()).toContain('Issue');
}); });
it('selects a work item type on click', async () => { it('selects a work item type on click', async () => {
expect(findDropdown().props('text')).toBe('Type'); const mockId = 'work-item-1';
findDropdownItems().at(0).vm.$emit('click'); findSelect().vm.$emit('input', mockId);
await nextTick(); await nextTick();
expect(findSelect().attributes('value')).toBe(mockId);
expect(findDropdown().props('text')).toBe('Issue');
}); });
}); });
...@@ -210,17 +216,32 @@ describe('Create work item component', () => { ...@@ -210,17 +216,32 @@ describe('Create work item component', () => {
}); });
describe('when title input field has a text', () => { describe('when title input field has a text', () => {
beforeEach(() => { beforeEach(async () => {
const mockTitle = 'Test title'; const mockTitle = 'Test title';
createComponent(); createComponent();
await waitForPromises();
findTitleInput().vm.$emit('title-input', mockTitle); findTitleInput().vm.$emit('title-input', mockTitle);
}); });
it('renders a non-disabled Create button', () => { it('renders a disabled Create button', () => {
expect(findCreateButton().props('disabled')).toBe(true);
});
it('renders a non-disabled Create button when work item type is selected', async () => {
findSelect().vm.$emit('input', 'work-item-1');
await nextTick();
expect(findCreateButton().props('disabled')).toBe(false); expect(findCreateButton().props('disabled')).toBe(false);
}); });
});
it('shows an alert on mutation error', async () => {
createComponent({ mutationHandler: errorHandler });
await waitForPromises();
findTitleInput().vm.$emit('title-input', 'some title');
findSelect().vm.$emit('input', 'work-item-1');
wrapper.find('form').trigger('submit');
await waitForPromises();
// TODO: write a proper test here when we have a backend implementation expect(findAlert().text()).toBe(CreateWorkItem.createErrorText);
it.todo('shows an alert on mutation error');
}); });
}); });
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