Commit 6ea43cb2 authored by Natalia Tepluhina's avatar Natalia Tepluhina Committed by Simon Knox

Unmocking create new task

Removed unused parameter

Unmocking create new task

Removed unused parameter

Added a mutation to convert task

Added mutation with hardcoded data

Added integration for updating and fixed some tests

Passed correct lockVersion

Better to add skip so modal is still rendered

Passed correct line numbers

Replaced dropdown with form-select

Disabled button for missed type

Regenerated translations file

Removed unneeded apollo update

Removed unnecessary helper

Added create WI form component

Revert "Added create WI form component"

This reverts commit dbc7f6794c12861ee75b2af65461c2b6ea5a572a.
Added condition for work items

Fixed loading for mutation

Abstracted error messages

Fixed work item creation

Passed correct issue GID

Added updating an issue description

Removed buttons for existing tasks

Fixed app spec to use shallow mount

Fixed app spec

Fixed create work item spec

Fixed emitting update description event

Fixed work item detail with loading

Fixed description spec

Fixed detail test with loading

Added test for no workItemId prop

Finished workItemDetail spec

Apply 1 suggestion(s) to 1 file(s)
Fixed description to use id

Removed createWorkItemButton method
parent b1ea491d
......@@ -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"
>
......
......@@ -42227,6 +42227,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