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