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'; ...@@ -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)}` } }); } catch {
} else { this.error = this.$options.createErrorText;
this.$emit('onCreate', { id, title: this.title, type }); }
} },
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" />
/> <gl-form-select
<template v-else> v-else
<gl-dropdown-item v-model="selectedWorkItemType"
v-for="type in workItemTypes" :options="formOptions"
:key="type.id" class="gl-max-w-26"
@click="selectWorkItemType(type)" />
>
{{ 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"
> >
......
...@@ -42227,6 +42227,9 @@ msgstr "" ...@@ -42227,6 +42227,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', () => {
createComponent(); 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 = { ...@@ -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