Commit c651140a authored by Natalia Tepluhina's avatar Natalia Tepluhina

Added Create Task functionality

CreateWorkItem component is modified to work with both standalone route and a modal
parent d627ce1d
<script> <script>
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import {
GlSafeHtmlDirective as SafeHtml,
GlModal,
GlModalDirective,
GlPopover,
GlButton,
} from '@gitlab/ui';
import $ from 'jquery'; import $ from 'jquery';
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';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import animateMixin from '../mixins/animate'; import animateMixin from '../mixins/animate';
export default { export default {
directives: { directives: {
SafeHtml, SafeHtml,
GlModal: GlModalDirective,
}, },
components: {
mixins: [animateMixin], GlModal,
GlPopover,
CreateWorkItem,
GlButton,
},
mixins: [animateMixin, glFeatureFlagMixin()],
props: { props: {
canUpdate: { canUpdate: {
type: Boolean, type: Boolean,
...@@ -53,8 +66,15 @@ export default { ...@@ -53,8 +66,15 @@ export default {
preAnimation: false, preAnimation: false,
pulseAnimation: false, pulseAnimation: false,
initialUpdate: true, initialUpdate: true,
taskButtons: [],
activeTask: {},
}; };
}, },
computed: {
workItemsEnabled() {
return this.glFeatures.workItems;
},
},
watch: { watch: {
descriptionHtml(newDescription, oldDescription) { descriptionHtml(newDescription, oldDescription) {
if (!this.initialUpdate && newDescription !== oldDescription) { if (!this.initialUpdate && newDescription !== oldDescription) {
...@@ -74,6 +94,10 @@ export default { ...@@ -74,6 +94,10 @@ export default {
mounted() { mounted() {
this.renderGFM(); this.renderGFM();
this.updateTaskStatusText(); this.updateTaskStatusText();
if (this.workItemsEnabled) {
this.renderTaskActions();
}
}, },
methods: { methods: {
renderGFM() { renderGFM() {
...@@ -132,6 +156,55 @@ export default { ...@@ -132,6 +156,55 @@ export default {
$tasksShort.text(''); $tasksShort.text('');
} }
}, },
renderTaskActions() {
const taskListFields = this.$el.querySelectorAll('.task-list-item');
taskListFields.forEach((item, index) => {
const button = document.createElement('button');
button.classList.add(
'btn',
'btn-default',
'btn-md',
'gl-button',
'btn-default-tertiary',
'gl-left-0',
'gl-p-0!',
'gl-top-2',
'gl-absolute',
'js-add-task',
);
button.id = `js-task-button-${index}`;
this.taskButtons.push(button.id);
button.innerHTML =
'<svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14"><use href="/assets/icons-7f1680a3670112fe4c8ef57b9dfb93f0f61b43a2a479d7abd6c83bcb724b9201.svg#ellipsis_v"></use></svg>';
item.prepend(button);
});
},
openCreateTaskModal(id) {
this.activeTask = { id, title: this.$el.querySelector(`#${id}`).parentElement.innerText };
this.$refs.modal.show();
},
closeCreateTaskModal() {
this.$refs.modal.hide();
},
handleCreateTask(title) {
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="/assets/icons-7f1680a3670112fe4c8ef57b9dfb93f0f61b43a2a479d7abd6c83bcb724b9201.svg#issue-open-m"></use>
</svg>
<span class="badge badge-info badge-pill gl-badge sm gl-mr-1">
${__('Task')}
</span>
<a href="#">${title}</a>
`;
listItem.insertBefore(taskBadge, listItem.lastChild);
listItem.removeChild(listItem.lastChild);
this.closeCreateTaskModal();
},
focusButton() {
this.$refs.convertButton[0].$el.focus();
},
}, },
safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] },
}; };
...@@ -142,12 +215,14 @@ export default { ...@@ -142,12 +215,14 @@ export default {
v-if="descriptionHtml" v-if="descriptionHtml"
:class="{ :class="{
'js-task-list-container': canUpdate, 'js-task-list-container': canUpdate,
'work-items-enabled': workItemsEnabled,
}" }"
class="description" class="description"
> >
<div <div
ref="gfm-content" ref="gfm-content"
v-safe-html:[$options.safeHtmlConfig]="descriptionHtml" v-safe-html:[$options.safeHtmlConfig]="descriptionHtml"
data-testid="gfm-content"
:class="{ :class="{
'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation, 'issue-realtime-trigger-pulse': pulseAnimation,
...@@ -157,13 +232,46 @@ export default { ...@@ -157,13 +232,46 @@ export default {
<!-- eslint-disable vue/no-mutating-props --> <!-- eslint-disable vue/no-mutating-props -->
<textarea <textarea
v-if="descriptionText" v-if="descriptionText"
ref="textarea"
v-model="descriptionText" v-model="descriptionText"
:data-update-url="updateUrl" :data-update-url="updateUrl"
class="hidden js-task-list-field" class="hidden js-task-list-field"
dir="auto" dir="auto"
data-testid="textarea"
> >
</textarea> </textarea>
<!-- eslint-enable vue/no-mutating-props --> <!-- eslint-enable vue/no-mutating-props -->
<gl-modal
ref="modal"
modal-id="create-task-modal"
:title="s__('WorkItem|New Task')"
hide-footer
body-class="gl-py-0!"
>
<create-work-item
:is-modal="true"
:initial-title="activeTask.title"
@closeModal="closeCreateTaskModal"
@onCreate="handleCreateTask"
/>
</gl-modal>
<template v-if="workItemsEnabled">
<gl-popover
v-for="item in taskButtons"
:key="item"
:target="item"
placement="top"
triggers="focus"
@shown="focusButton"
>
<gl-button
ref="convertButton"
variant="link"
data-testid="convert-to-task"
class="gl-text-gray-900! gl-text-decoration-none! gl-outline-0!"
@click="openCreateTaskModal(item)"
>{{ s__('WorkItem|Convert to work item') }}</gl-button
>
</gl-popover>
</template>
</div> </div>
</template> </template>
...@@ -2,6 +2,7 @@ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; ...@@ -2,6 +2,7 @@ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import produce from 'immer'; import produce from 'immer';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
import { resolvers as workItemResolvers } from '~/work_items/graphql/resolvers';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import introspectionQueryResultData from './fragmentTypes.json'; import introspectionQueryResultData from './fragmentTypes.json';
...@@ -10,6 +11,7 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({ ...@@ -10,6 +11,7 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({
}); });
const resolvers = { const resolvers = {
...workItemResolvers,
Mutation: { Mutation: {
updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => { updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
const sourceData = cache.readQuery({ query: getIssueStateQuery }); const sourceData = cache.readQuery({ query: getIssueStateQuery });
...@@ -18,6 +20,7 @@ const resolvers = { ...@@ -18,6 +20,7 @@ const resolvers = {
}); });
cache.writeQuery({ query: getIssueStateQuery, data }); cache.writeQuery({ query: getIssueStateQuery, data });
}, },
...workItemResolvers.Mutation,
}, },
}; };
......
...@@ -10,9 +10,21 @@ export default { ...@@ -10,9 +10,21 @@ export default {
GlAlert, GlAlert,
ItemTitle, ItemTitle,
}, },
props: {
isModal: {
type: Boolean,
required: false,
default: false,
},
initialTitle: {
type: String,
required: false,
default: '',
},
},
data() { data() {
return { return {
title: '', title: this.initialTitle,
error: false, error: false,
}; };
}, },
...@@ -35,7 +47,11 @@ export default { ...@@ -35,7 +47,11 @@ export default {
}, },
}, },
} = response; } = response;
this.$router.push({ name: 'workItem', params: { id } }); if (!this.isModal) {
this.$router.push({ name: 'workItem', params: { id } });
} else {
this.$emit('onCreate', this.title);
}
} catch { } catch {
this.error = true; this.error = true;
} }
...@@ -43,6 +59,13 @@ export default { ...@@ -43,6 +59,13 @@ export default {
handleTitleInput(title) { handleTitleInput(title) {
this.title = title; this.title = title;
}, },
handleCancelClick() {
if (!this.isModal) {
this.$router.go(-1);
return;
}
this.$emit('closeModal');
},
}, },
}; };
</script> </script>
...@@ -52,18 +75,27 @@ export default { ...@@ -52,18 +75,27 @@ export default {
<gl-alert v-if="error" variant="danger" @dismiss="error = false">{{ <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
__('Something went wrong when creating a work item. Please try again') __('Something went wrong when creating a work item. Please try again')
}}</gl-alert> }}</gl-alert>
<item-title data-testid="title-input" @title-input="handleTitleInput" /> <item-title :initial-title="title" data-testid="title-input" @title-input="handleTitleInput" />
<div class="gl-bg-gray-10 gl-py-5 gl-px-6"> <div
class="gl-bg-gray-10 gl-py-5 gl-px-6"
:class="{ 'gl-display-flex gl-justify-content-end': isModal }"
>
<gl-button <gl-button
variant="confirm" variant="confirm"
:disabled="title.length === 0" :disabled="title.length === 0"
class="gl-mr-3" :class="{ 'gl-mr-3': !isModal }"
data-testid="create-button" data-testid="create-button"
type="submit" type="submit"
> >
{{ __('Create') }} {{ s__('WorkItem|Create work item') }}
</gl-button> </gl-button>
<gl-button type="button" data-testid="cancel-button" @click="$router.go(-1)"> <gl-button
type="button"
data-testid="cancel-button"
class="gl-order-n1"
:class="{ 'gl-mr-3': isModal }"
@click="handleCancelClick"
>
{{ __('Cancel') }} {{ __('Cancel') }}
</gl-button> </gl-button>
</div> </div>
......
...@@ -305,3 +305,32 @@ ul.related-merge-requests > li gl-emoji { ...@@ -305,3 +305,32 @@ ul.related-merge-requests > li gl-emoji {
.issuable-header-slide-leave-to { .issuable-header-slide-leave-to {
transform: translateY(-100%); transform: translateY(-100%);
} }
.description.work-items-enabled {
ul.task-list {
> li.task-list-item {
padding-inline-start: 2.25rem;
.js-add-task {
svg {
visibility: hidden;
}
&:focus svg {
visibility: visible;
}
}
> input.task-list-item-checkbox {
left: 0.875rem;
}
&:hover,
&:focus-within {
.js-add-task svg {
visibility: visible;
}
}
}
}
}
...@@ -54,6 +54,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -54,6 +54,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml) push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_issue_discussions, @project, default_enabled: :yaml) push_frontend_feature_flag(:paginated_issue_discussions, @project, default_enabled: :yaml)
push_frontend_feature_flag(:fix_comment_scroll, @project, default_enabled: :yaml) push_frontend_feature_flag(:fix_comment_scroll, @project, default_enabled: :yaml)
push_frontend_feature_flag(:work_items, project, default_enabled: :yaml)
end end
around_action :allow_gitaly_ref_name_caching, only: [:discussions] around_action :allow_gitaly_ref_name_caching, only: [:discussions]
......
...@@ -35044,6 +35044,9 @@ msgstr "" ...@@ -35044,6 +35044,9 @@ msgstr ""
msgid "Target-Branch" msgid "Target-Branch"
msgstr "" msgstr ""
msgid "Task"
msgstr ""
msgid "Task ID: %{elastic_task}" msgid "Task ID: %{elastic_task}"
msgstr "" msgstr ""
...@@ -40600,6 +40603,15 @@ msgstr "" ...@@ -40600,6 +40603,15 @@ msgstr ""
msgid "Work in progress Limit" msgid "Work in progress Limit"
msgstr "" msgstr ""
msgid "WorkItem|Convert to work item"
msgstr ""
msgid "WorkItem|Create work item"
msgstr ""
msgid "WorkItem|New Task"
msgstr ""
msgid "WorkItem|Work Items" msgid "WorkItem|Work Items"
msgstr "" msgstr ""
......
import $ from 'jquery'; import $ from 'jquery';
import Vue, { nextTick } from 'vue'; import { nextTick } from 'vue';
import '~/behaviors/markdown/render_gfm'; import '~/behaviors/markdown/render_gfm';
import { GlPopover, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import mountComponent from 'helpers/vue_mount_component_helper';
import Description from '~/issues/show/components/description.vue'; import Description from '~/issues/show/components/description.vue';
import TaskList from '~/task_list'; import TaskList from '~/task_list';
import { descriptionProps as props } from '../mock_data/mock_data'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import {
descriptionProps as initialProps,
descriptionHtmlWithCheckboxes,
} from '../mock_data/mock_data';
jest.mock('~/task_list'); jest.mock('~/task_list');
const showModal = jest.fn();
const hideModal = jest.fn();
describe('Description component', () => { describe('Description component', () => {
let vm; let wrapper;
let DescriptionComponent;
const findGfmContent = () => wrapper.find('[data-testid="gfm-content"]');
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);
const findCreateWorkItem = () => wrapper.findComponent(CreateWorkItem);
function createComponent({ props = {}, provide = {} } = {}) {
wrapper = shallowMount(Description, {
propsData: {
...initialProps,
...props,
},
provide,
stubs: {
GlModal: stubComponent(GlModal, {
methods: {
show: showModal,
hide: hideModal,
},
}),
GlPopover,
},
});
}
beforeEach(() => { beforeEach(() => {
DescriptionComponent = Vue.extend(Description);
if (!document.querySelector('.issuable-meta')) { if (!document.querySelector('.issuable-meta')) {
const metaData = document.createElement('div'); const metaData = document.createElement('div');
metaData.classList.add('issuable-meta'); metaData.classList.add('issuable-meta');
...@@ -24,12 +59,10 @@ describe('Description component', () => { ...@@ -24,12 +59,10 @@ describe('Description component', () => {
document.body.appendChild(metaData); document.body.appendChild(metaData);
} }
vm = mountComponent(DescriptionComponent, props);
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
}); });
afterAll(() => { afterAll(() => {
...@@ -37,64 +70,91 @@ describe('Description component', () => { ...@@ -37,64 +70,91 @@ describe('Description component', () => {
}); });
it('doesnt animate first description changes', async () => { it('doesnt animate first description changes', async () => {
vm.descriptionHtml = 'changed'; createComponent();
await wrapper.setProps({
descriptionHtml: 'changed',
});
await nextTick(); expect(findGfmContent().classes()).not.toContain('issue-realtime-pre-pulse');
expect(vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse')).toBeFalsy();
jest.runAllTimers();
}); });
it('animates description changes on live update', async () => { it('animates description changes on live update', async () => {
vm.descriptionHtml = 'changed'; createComponent();
await nextTick(); await wrapper.setProps({
vm.descriptionHtml = 'changed second time'; descriptionHtml: 'changed',
await nextTick(); });
expect(vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse')).toBeTruthy();
jest.runAllTimers(); expect(findGfmContent().classes()).not.toContain('issue-realtime-pre-pulse');
await nextTick();
expect( await wrapper.setProps({
vm.$el.querySelector('.md').classList.contains('issue-realtime-trigger-pulse'), descriptionHtml: 'changed second time',
).toBeTruthy(); });
expect(findGfmContent().classes()).toContain('issue-realtime-pre-pulse');
await jest.runOnlyPendingTimers();
expect(findGfmContent().classes()).toContain('issue-realtime-trigger-pulse');
}); });
it('applies syntax highlighting and math when description changed', async () => { it('applies syntax highlighting and math when description changed', async () => {
const vmSpy = jest.spyOn(vm, 'renderGFM');
const prototypeSpy = jest.spyOn($.prototype, 'renderGFM'); const prototypeSpy = jest.spyOn($.prototype, 'renderGFM');
vm.descriptionHtml = 'changed'; createComponent();
await nextTick(); await wrapper.setProps({
expect(vm.$refs['gfm-content']).toBeDefined(); descriptionHtml: 'changed',
expect(vmSpy).toHaveBeenCalled(); });
expect(findGfmContent().exists()).toBe(true);
expect(prototypeSpy).toHaveBeenCalled(); expect(prototypeSpy).toHaveBeenCalled();
expect($.prototype.renderGFM).toHaveBeenCalled();
}); });
it('sets data-update-url', () => { it('sets data-update-url', () => {
expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(TEST_HOST); createComponent();
expect(findTextarea().attributes('data-update-url')).toBe(TEST_HOST);
}); });
describe('TaskList', () => { describe('TaskList', () => {
beforeEach(() => { beforeEach(() => {
vm.$destroy();
TaskList.mockClear(); TaskList.mockClear();
vm = mountComponent(DescriptionComponent, { ...props, issuableType: 'issuableType' });
}); });
it('re-inits the TaskList when description changed', () => { it('re-inits the TaskList when description changed', () => {
vm.descriptionHtml = 'changed'; createComponent({
props: {
issuableType: 'issuableType',
},
});
wrapper.setProps({
descriptionHtml: 'changed',
});
expect(TaskList).toHaveBeenCalled(); expect(TaskList).toHaveBeenCalled();
}); });
it('does not re-init the TaskList when canUpdate is false', () => { it('does not re-init the TaskList when canUpdate is false', async () => {
vm.canUpdate = false; createComponent({
vm.descriptionHtml = 'changed'; props: {
issuableType: 'issuableType',
canUpdate: false,
},
});
wrapper.setProps({
descriptionHtml: 'changed',
});
expect(TaskList).toHaveBeenCalledTimes(1); expect(TaskList).not.toHaveBeenCalled();
}); });
it('calls with issuableType dataType', () => { it('calls with issuableType dataType', () => {
vm.descriptionHtml = 'changed'; createComponent({
props: {
issuableType: 'issuableType',
},
});
wrapper.setProps({
descriptionHtml: 'changed',
});
expect(TaskList).toHaveBeenCalledWith({ expect(TaskList).toHaveBeenCalledWith({
dataType: 'issuableType', dataType: 'issuableType',
...@@ -110,61 +170,96 @@ describe('Description component', () => { ...@@ -110,61 +170,96 @@ describe('Description component', () => {
describe('taskStatus', () => { describe('taskStatus', () => {
it('adds full taskStatus', async () => { it('adds full taskStatus', async () => {
vm.taskStatus = '1 of 1'; createComponent({
props: {
taskStatus: '1 of 1',
},
});
await nextTick(); await nextTick();
expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe( expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe(
'1 of 1', '1 of 1',
); );
}); });
it('adds short taskStatus', async () => { it('adds short taskStatus', async () => {
vm.taskStatus = '1 of 1'; createComponent({
props: {
taskStatus: '1 of 1',
},
});
await nextTick(); await nextTick();
expect(document.querySelector('.issuable-meta #task_status_short').textContent.trim()).toBe( expect(document.querySelector('.issuable-meta #task_status_short').textContent.trim()).toBe(
'1/1 task', '1/1 task',
); );
}); });
it('clears task status text when no tasks are present', async () => { it('clears task status text when no tasks are present', async () => {
vm.taskStatus = '0 of 0'; createComponent({
props: {
taskStatus: '0 of 0',
},
});
await nextTick(); await nextTick();
expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe(''); expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe('');
}); });
}); });
describe('taskListUpdateStarted', () => { describe('with work items feature flag is enabled', () => {
it('emits event to parent', () => { beforeEach(async () => {
const spy = jest.spyOn(vm, '$emit'); createComponent({
props: {
vm.taskListUpdateStarted(); descriptionHtml: descriptionHtmlWithCheckboxes,
},
provide: {
glFeatures: {
workItems: true,
},
},
});
await nextTick();
});
expect(spy).toHaveBeenCalledWith('taskListUpdateStarted'); it('renders a list of hidden buttons corresponding to checkboxes in description HTML', () => {
expect(findTaskActionButtons()).toHaveLength(3);
}); });
});
describe('taskListUpdateSuccess', () => { it('renders a list of popovers corresponding to checkboxes in description HTML', () => {
it('emits event to parent', () => { expect(findPopovers()).toHaveLength(3);
const spy = jest.spyOn(vm, '$emit'); expect(findPopovers().at(0).props('target')).toBe(
findTaskActionButtons().at(0).attributes('id'),
);
});
vm.taskListUpdateSuccess(); it('does not show a modal by default', () => {
expect(findModal().props('visible')).toBe(false);
});
expect(spy).toHaveBeenCalledWith('taskListUpdateSucceeded'); it('opens a modal when a button on popover is clicked and displays correct title', async () => {
findConvertToTaskButton().vm.$emit('click');
expect(showModal).toHaveBeenCalled();
await nextTick();
expect(findCreateWorkItem().props('initialTitle').trim()).toBe('todo 1');
}); });
});
describe('taskListUpdateError', () => { it('closes the modal on `closeCreateTaskModal` event', () => {
it('should create flash notification and emit an event to parent', () => { findConvertToTaskButton().vm.$emit('click');
const msg = findCreateWorkItem().vm.$emit('closeModal');
'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.'; expect(hideModal).toHaveBeenCalled();
const spy = jest.spyOn(vm, '$emit'); });
vm.taskListUpdateError(); it('updates description HTML on `onCreate` event', async () => {
const newTitle = 'New title';
findConvertToTaskButton().vm.$emit('click');
findCreateWorkItem().vm.$emit('onCreate', newTitle);
expect(hideModal).toHaveBeenCalled();
await nextTick();
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg); expect(findTaskSvg().exists()).toBe(true);
expect(spy).toHaveBeenCalledWith('taskListUpdateFailed'); expect(wrapper.text()).toContain(newTitle);
}); });
}); });
}); });
...@@ -58,3 +58,17 @@ export const appProps = { ...@@ -58,3 +58,17 @@ export const appProps = {
zoomMeetingUrl, zoomMeetingUrl,
publishedIncidentUrl, publishedIncidentUrl,
}; };
export const descriptionHtmlWithCheckboxes = `
<ul dir="auto" class="task-list" data-sourcepos"3:1-5:12">
<li class="task-list-item" data-sourcepos="3:1-3:11">
<input class="task-list-item-checkbox" type="checkbox"> todo 1
</li>
<li class="task-list-item" data-sourcepos="4:1-4:12">
<input class="task-list-item-checkbox" type="checkbox"> todo 2
</li>
<li class="task-list-item" data-sourcepos="5:1-5:12">
<input class="task-list-item-checkbox" type="checkbox"> todo 3
</li>
</ul>
`;
...@@ -19,7 +19,7 @@ describe('Create work item component', () => { ...@@ -19,7 +19,7 @@ describe('Create work item component', () => {
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"]');
const createComponent = ({ data = {} } = {}) => { const createComponent = ({ data = {}, props = {} } = {}) => {
fakeApollo = createMockApollo([], resolvers); fakeApollo = createMockApollo([], resolvers);
wrapper = shallowMount(CreateWorkItem, { wrapper = shallowMount(CreateWorkItem, {
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
...@@ -28,6 +28,9 @@ describe('Create work item component', () => { ...@@ -28,6 +28,9 @@ describe('Create work item component', () => {
...data, ...data,
}; };
}, },
propsData: {
...props,
},
mocks: { mocks: {
$router: { $router: {
go: jest.fn(), go: jest.fn(),
...@@ -54,40 +57,99 @@ describe('Create work item component', () => { ...@@ -54,40 +57,99 @@ describe('Create work item component', () => {
expect(findCreateButton().props('disabled')).toBe(true); expect(findCreateButton().props('disabled')).toBe(true);
}); });
it('redirects to the previous page on Cancel button click', () => { describe('when displayed on a separate route', () => {
createComponent(); beforeEach(() => {
findCancelButton().vm.$emit('click'); createComponent();
});
it('redirects to the previous page on Cancel button click', () => {
findCancelButton().vm.$emit('click');
expect(wrapper.vm.$router.go).toHaveBeenCalledWith(-1);
});
it('redirects to the work item page on successful mutation', async () => {
findTitleInput().vm.$emit('title-input', 'Test title');
wrapper.find('form').trigger('submit');
await waitForPromises();
expect(wrapper.vm.$router.push).toHaveBeenCalled();
});
it('adds right margin for create button', () => {
expect(findCreateButton().classes()).toContain('gl-mr-3');
});
it('does not add right margin for cancel button', () => {
expect(findCancelButton().classes()).not.toContain('gl-mr-3');
});
});
describe('when displayed in a modal', () => {
beforeEach(() => {
createComponent({
props: {
isModal: true,
},
});
});
it('emits `closeModal` event on Cancel button click', () => {
findCancelButton().vm.$emit('click');
expect(wrapper.vm.$router.go).toHaveBeenCalledWith(-1); expect(wrapper.emitted('closeModal')).toEqual([[]]);
});
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();
expect(wrapper.emitted('onCreate')).toEqual([[mockTitle]]);
});
it('does not right margin for create button', () => {
expect(findCreateButton().classes()).not.toContain('gl-mr-3');
});
it('adds right margin for cancel button', () => {
expect(findCancelButton().classes()).toContain('gl-mr-3');
});
}); });
it('hides the alert on dismissing the error', async () => { it('hides the alert on dismissing the error', async () => {
createComponent({ data: { error: true } }); createComponent({ data: { error: true } });
expect(findAlert().exists()).toBe(true); expect(findAlert().exists()).toBe(true);
findAlert().vm.$emit('dismiss'); findAlert().vm.$emit('dismiss');
await nextTick(); await nextTick();
expect(findAlert().exists()).toBe(false); expect(findAlert().exists()).toBe(false);
}); });
it('displays an initial title if passed', () => {
const initialTitle = 'Initial Title';
createComponent({
props: { initialTitle },
});
expect(findTitleInput().props('initialTitle')).toBe(initialTitle);
});
describe('when title input field has a text', () => { describe('when title input field has a text', () => {
beforeEach(async () => { beforeEach(() => {
const mockTitle = 'Test title'; const mockTitle = 'Test title';
createComponent(); createComponent();
await findTitleInput().vm.$emit('title-input', mockTitle); findTitleInput().vm.$emit('title-input', mockTitle);
}); });
it('renders a non-disabled Create button', () => { it('renders a non-disabled Create button', () => {
expect(findCreateButton().props('disabled')).toBe(false); expect(findCreateButton().props('disabled')).toBe(false);
}); });
it('redirects to the work item page on successful mutation', async () => {
wrapper.find('form').trigger('submit');
await waitForPromises();
expect(wrapper.vm.$router.push).toHaveBeenCalled();
});
// TODO: write a proper test here when we have a backend implementation // TODO: write a proper test here when we have a backend implementation
it.todo('shows an alert on mutation error'); 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