Commit 87f8d02d authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '301192-add-tasklist-support-in-test-cases' into 'master'

Add task list support in Issuable Show app

See merge request gitlab-org/gitlab!56196
parents e1cda699 928c49b1
<script>
import { GlLink } from '@gitlab/ui';
import TaskList from '~/task_list';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import IssuableDescription from './issuable_description.vue';
......@@ -40,6 +42,11 @@ export default {
type: Boolean,
required: true,
},
enableTaskList: {
type: Boolean,
required: false,
default: false,
},
editFormVisible: {
type: Boolean,
required: true,
......@@ -56,6 +63,16 @@ export default {
type: String,
required: true,
},
taskListUpdatePath: {
type: String,
required: false,
default: '',
},
taskListLockVersion: {
type: Number,
required: false,
default: 0,
},
},
computed: {
isUpdated() {
......@@ -65,7 +82,50 @@ export default {
return this.issuable.updatedBy;
},
},
watch: {
/**
* When user switches between view and edit modes,
* taskList instance becomes invalid so whenever
* view mode is rendered, we need to re-initialize
* taskList to ensure the behaviour functional.
*/
editFormVisible(value) {
if (!value) {
this.$nextTick(() => {
this.initTaskList();
});
}
},
},
mounted() {
if (this.enableEdit && this.enableTaskList) {
this.initTaskList();
}
},
methods: {
initTaskList() {
this.taskList = new TaskList({
/**
* We have hard-coded dataType to `issue`
* as currently only `issue` types can handle
* task-lists, however, we can still use
* task lists in Issue, Test Cases and Incidents
* as all of those are derived from `issue`.
*/
dataType: 'issue',
fieldName: 'description',
lockVersion: this.taskListLockVersion,
selector: '.js-detail-page-description',
onSuccess: this.handleTaskListUpdateSuccess.bind(this),
onError: this.handleTaskListUpdateFailure.bind(this),
});
},
handleTaskListUpdateSuccess(updatedIssuable) {
this.$emit('task-list-update-success', updatedIssuable);
},
handleTaskListUpdateFailure() {
this.$emit('task-list-update-failure');
},
handleKeydownTitle(e, issuableMeta) {
this.$emit('keydown-title', e, issuableMeta);
},
......@@ -78,7 +138,7 @@ export default {
<template>
<div class="issue-details issuable-details">
<div class="detail-page-description content-block">
<div class="detail-page-description js-detail-page-description content-block">
<issuable-edit-form
v-if="editFormVisible"
:issuable="issuable"
......@@ -106,7 +166,13 @@ export default {
<slot name="status-badge"></slot>
</template>
</issuable-title>
<issuable-description v-if="issuable.descriptionHtml" :issuable="issuable" />
<issuable-description
v-if="issuable.descriptionHtml"
:issuable="issuable"
:enable-task-list="enableTaskList"
:can-edit="enableEdit"
:task-list-update-path="taskListUpdatePath"
/>
<small v-if="isUpdated" class="edited-text gl-font-sm!">
{{ __('Edited') }}
<time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" />
......
......@@ -12,6 +12,18 @@ export default {
type: Object,
required: true,
},
enableTaskList: {
type: Boolean,
required: true,
},
canEdit: {
type: Boolean,
required: true,
},
taskListUpdatePath: {
type: String,
required: true,
},
},
mounted() {
this.renderGFM();
......@@ -25,7 +37,16 @@ export default {
</script>
<template>
<div class="description">
<div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }">
<div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div>
<textarea
v-if="issuable.description && enableTaskList"
ref="textarea"
:value="issuable.description"
:data-update-url="taskListUpdatePath"
class="gl-display-none js-task-list-field"
dir="auto"
>
</textarea>
</div>
</template>
......@@ -3,6 +3,7 @@ import { GlIcon, GlButton, GlTooltipDirective, GlAvatarLink, GlAvatarLabeled } f
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isExternal } from '~/lib/utils/url_utility';
import { n__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
......@@ -45,6 +46,11 @@ export default {
required: false,
default: false,
},
taskCompletionStatus: {
type: Object,
required: false,
default: null,
},
},
computed: {
authorId() {
......@@ -53,6 +59,18 @@ export default {
isAuthorExternal() {
return isExternal(this.author.webUrl);
},
taskStatusString() {
const { count, completedCount } = this.taskCompletionStatus;
return sprintf(
n__(
'%{completedCount} of %{count} task completed',
'%{completedCount} of %{count} tasks completed',
count,
),
{ completedCount, count },
);
},
},
mounted() {
this.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button');
......@@ -74,8 +92,8 @@ export default {
<gl-icon v-if="statusIcon" :name="statusIcon" class="d-block d-sm-none" />
<span class="d-none d-sm-block"><slot name="status-badge"></slot></span>
</div>
<div class="issuable-meta gl-display-flex gl-align-items-center">
<div class="gl-display-inline-block">
<div class="issuable-meta gl-display-flex gl-align-items-center d-md-inline-block">
<div v-if="blocked || confidential" class="gl-display-inline-block">
<div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline">
<gl-icon name="lock" :aria-label="__('Blocked')" />
</div>
......@@ -95,13 +113,13 @@ export default {
:data-name="author.name"
:href="author.webUrl"
target="_blank"
class="js-user-link gl-ml-2"
class="js-user-link gl-vertical-align-middle gl-ml-2"
>
<gl-avatar-labeled
:size="24"
:src="author.avatarUrl"
:label="author.name"
class="d-none d-sm-inline-flex gl-ml-1"
class="d-none d-sm-inline-flex gl-mx-1"
>
<template #meta>
<gl-icon v-if="isAuthorExternal" name="external-link" />
......@@ -109,6 +127,12 @@ export default {
</gl-avatar-labeled>
<strong class="author d-sm-none d-inline">@{{ author.username }}</strong>
</gl-avatar-link>
<span
v-if="taskCompletionStatus"
data-testid="task-status"
class="gl-display-none gl-md-display-block gl-lg-display-inline-block"
>{{ taskStatusString }}</span
>
</div>
<gl-button
data-testid="sidebar-toggle"
......
......@@ -42,6 +42,11 @@ export default {
required: false,
default: true,
},
enableTaskList: {
type: Boolean,
required: false,
default: false,
},
editFormVisible: {
type: Boolean,
required: false,
......@@ -62,6 +67,21 @@ export default {
required: false,
default: '',
},
taskCompletionStatus: {
type: Object,
required: false,
default: null,
},
taskListUpdatePath: {
type: String,
required: false,
default: '',
},
taskListLockVersion: {
type: Number,
required: false,
default: 0,
},
},
methods: {
handleKeydownTitle(e, issuableMeta) {
......@@ -83,6 +103,7 @@ export default {
:confidential="issuable.confidential"
:created-at="issuable.createdAt"
:author="issuable.author"
:task-completion-status="taskCompletionStatus"
>
<template #status-badge>
<slot name="status-badge"></slot>
......@@ -99,11 +120,16 @@ export default {
:enable-edit="enableEdit"
:enable-autocomplete="enableAutocomplete"
:enable-autosave="enableAutosave"
:enable-task-list="enableTaskList"
:edit-form-visible="editFormVisible"
:show-field-title="showFieldTitle"
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
:task-list-update-path="taskListUpdatePath"
:task-list-lock-version="taskListLockVersion"
@edit-issuable="$emit('edit-issuable', $event)"
@task-list-update-success="$emit('task-list-update-success', $event)"
@task-list-update-failure="$emit('task-list-update-failure')"
@keydown-title="handleKeydownTitle"
@keydown-description="handleKeydownDescription"
>
......
......@@ -7,6 +7,7 @@ import {
GlButton,
GlSprintf,
GlLink,
GlAlert,
} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
......@@ -31,6 +32,7 @@ export default {
GlButton,
GlSprintf,
GlLink,
GlAlert,
IssuableShow,
TestCaseSidebar,
},
......@@ -39,6 +41,8 @@ export default {
'projectFullPath',
'testCaseNewPath',
'testCaseId',
'updatePath',
'lockVersion',
'canEditTestCase',
'descriptionPreviewPath',
'descriptionHelpPath',
......@@ -49,6 +53,7 @@ export default {
editTestCaseFormVisible: false,
testCaseSaveInProgress: false,
testCaseStateChangeInProgress: false,
taskListUpdateFailed: false,
};
},
computed: {
......@@ -98,6 +103,9 @@ export default {
this.testCaseStateChangeInProgress = false;
});
},
handleTaskListUpdateFailure() {
this.taskListUpdateFailed = true;
},
handleEditTestCase() {
this.editTestCaseFormVisible = true;
},
......@@ -132,6 +140,13 @@ export default {
<template>
<div class="test-case-container">
<gl-alert v-if="taskListUpdateFailed" variant="danger" @dismiss="taskListUpdateFailed = false">
{{
__(
'Someone edited this test case at the same time you did. The description has been updated and you will need to make your changes again.',
)
}}
</gl-alert>
<gl-loading-icon v-if="testCaseLoading" size="md" class="gl-mt-3" />
<issuable-show
v-if="!testCaseLoading && !testCaseLoadFailed"
......@@ -140,10 +155,15 @@ export default {
:status-icon="statusIcon"
:enable-edit="canEditTestCase"
:enable-autocomplete="true"
:enable-task-list="true"
:edit-form-visible="editTestCaseFormVisible"
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
:task-completion-status="testCase.taskCompletionStatus"
:task-list-update-path="updatePath"
:task-list-lock-version="lockVersion"
@edit-issuable="handleEditTestCase"
@task-list-update-failure="handleTaskListUpdateFailure"
>
<template #status-badge>
<gl-sprintf
......
......@@ -8,6 +8,7 @@ fragment TestCase on Issue {
description
descriptionHtml
state
type
createdAt
updatedAt
updatedBy {
......@@ -34,4 +35,8 @@ fragment TestCase on Issue {
state
}
}
taskCompletionStatus {
count
completedCount
}
}
......@@ -28,6 +28,7 @@ export default function initTestCaseShow({ mountPointSelector }) {
...el.dataset,
projectsFetchPath: sidebarOptions.projectsAutocompleteEndpoint,
canEditTestCase: parseBoolean(el.dataset.canEditTestCase),
lockVersion: parseInt(el.dataset.lockVersion, 10),
},
render: (createElement) => createElement(TestCaseShowApp),
});
......
......@@ -6,6 +6,8 @@
- page_description @test_case.description
#js-issuable-app{ data: { initial: issuable_initial_data(@test_case).to_json,
update_path: issuable_path(@test_case, format: :json),
lock_version: @test_case.lock_version,
can_edit_test_case: can?(current_user, :admin_issue, @project).to_s,
can_move_test_case: @issuable_sidebar.dig(:current_user, :can_move).to_s,
description_preview_path: preview_markdown_path(@project),
......
---
title: Add support for task lists within Test Case description
merge_request: 56196
author:
type: added
import { GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { GlLink, GlLoadingIcon, GlSprintf, GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import TestCaseShowRoot from 'ee/test_case_show/components/test_case_show_root.vue';
......@@ -268,9 +268,26 @@ describe('TestCaseShowRoot', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('renders gl-alert when issuable-show component emits `task-list-update-failure` event', async () => {
await wrapper.find(IssuableShow).vm.$emit('task-list-update-failure');
const alertEl = wrapper.find(GlAlert);
expect(alertEl.exists()).toBe(true);
expect(alertEl.text()).toBe(
'Someone edited this test case at the same time you did. The description has been updated and you will need to make your changes again.',
);
});
it('renders issuable-show when `testCaseLoading` prop is false', () => {
const { statusBadgeClass, statusIcon, editTestCaseFormVisible } = wrapper.vm;
const { canEditTestCase, descriptionPreviewPath, descriptionHelpPath } = mockProvide;
const {
canEditTestCase,
descriptionPreviewPath,
descriptionHelpPath,
updatePath,
lockVersion,
} = mockProvide;
const issuableShowEl = wrapper.find(IssuableShow);
expect(issuableShowEl.exists()).toBe(true);
......@@ -280,9 +297,13 @@ describe('TestCaseShowRoot', () => {
descriptionPreviewPath,
descriptionHelpPath,
enableAutocomplete: true,
enableTaskList: true,
issuable: mockTestCase,
enableEdit: canEditTestCase,
editFormVisible: editTestCaseFormVisible,
taskCompletionStatus: mockTestCase.taskCompletionStatus,
taskListUpdatePath: updatePath,
taskListLockVersion: lockVersion,
});
});
......
......@@ -6,6 +6,10 @@ export const mockTestCase = {
currentUserTodos: {
nodes: [mockCurrentUserTodo],
},
taskCompletionStatus: {
completedCount: 0,
count: 5,
},
};
export const mockProvide = {
......@@ -19,4 +23,6 @@ export const mockProvide = {
labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json',
labelsManagePath: '/gitlab-org/gitlab-shell/-/labels',
projectsFetchPath: '/-/autocomplete/projects?project_id=1',
updatePath: `${mockIssuable.webUrl}.json`,
lockVersion: 1,
};
......@@ -28134,6 +28134,9 @@ msgstr ""
msgid "Someone edited this merge request at the same time you did. Please refresh the page to see changes."
msgstr ""
msgid "Someone edited this test case at the same time you did. The description has been updated and you will need to make your changes again."
msgstr ""
msgid "Something went wrong"
msgstr ""
......
......@@ -6,11 +6,13 @@ import IssuableBody from '~/issuable_show/components/issuable_body.vue';
import IssuableDescription from '~/issuable_show/components/issuable_description.vue';
import IssuableEditForm from '~/issuable_show/components/issuable_edit_form.vue';
import IssuableTitle from '~/issuable_show/components/issuable_title.vue';
import TaskList from '~/task_list';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { mockIssuableShowProps, mockIssuable } from '../mock_data';
jest.mock('~/autosave');
jest.mock('~/flash');
const issuableBodyProps = {
...mockIssuableShowProps,
......@@ -80,6 +82,75 @@ describe('IssuableBody', () => {
});
});
describe('watchers', () => {
describe('editFormVisible', () => {
it('calls initTaskList in nextTick', async () => {
jest.spyOn(wrapper.vm, 'initTaskList');
wrapper.setProps({
editFormVisible: true,
});
await wrapper.vm.$nextTick();
wrapper.setProps({
editFormVisible: false,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.initTaskList).toHaveBeenCalled();
});
});
});
describe('mounted', () => {
it('initializes TaskList instance when enabledEdit and enableTaskList props are true', () => {
expect(wrapper.vm.taskList instanceof TaskList).toBe(true);
expect(wrapper.vm.taskList).toMatchObject({
dataType: 'issue',
fieldName: 'description',
lockVersion: issuableBodyProps.taskListLockVersion,
selector: '.js-detail-page-description',
onSuccess: expect.any(Function),
onError: expect.any(Function),
});
});
it('does not initialize TaskList instance when either enabledEdit or enableTaskList prop is false', () => {
const wrapperNoTaskList = createComponent({
...issuableBodyProps,
enableTaskList: false,
});
expect(wrapperNoTaskList.vm.taskList).not.toBeDefined();
wrapperNoTaskList.destroy();
});
});
describe('methods', () => {
describe('handleTaskListUpdateSuccess', () => {
it('emits `task-list-update-success` event on component', () => {
const updatedIssuable = {
foo: 'bar',
};
wrapper.vm.handleTaskListUpdateSuccess(updatedIssuable);
expect(wrapper.emitted('task-list-update-success')).toBeTruthy();
expect(wrapper.emitted('task-list-update-success')[0]).toEqual([updatedIssuable]);
});
});
describe('handleTaskListUpdateFailure', () => {
it('emits `task-list-update-failure` event on component', () => {
wrapper.vm.handleTaskListUpdateFailure();
expect(wrapper.emitted('task-list-update-failure')).toBeTruthy();
});
});
});
describe('template', () => {
it('renders issuable-title component', () => {
const titleEl = wrapper.find(IssuableTitle);
......
......@@ -5,9 +5,14 @@ import IssuableDescription from '~/issuable_show/components/issuable_description
import { mockIssuable } from '../mock_data';
const createComponent = (issuable = mockIssuable) =>
const createComponent = ({
issuable = mockIssuable,
enableTaskList = true,
canEdit = true,
taskListUpdatePath = `${mockIssuable.webUrl}.json`,
} = {}) =>
shallowMount(IssuableDescription, {
propsData: { issuable },
propsData: { issuable, enableTaskList, canEdit, taskListUpdatePath },
});
describe('IssuableDescription', () => {
......@@ -38,4 +43,27 @@ describe('IssuableDescription', () => {
});
});
});
describe('templates', () => {
it('renders container element with class `js-task-list-container` when canEdit and enableTaskList props are true', () => {
expect(wrapper.classes()).toContain('js-task-list-container');
});
it('renders container element without class `js-task-list-container` when canEdit and enableTaskList props are true', () => {
const wrapperNoTaskList = createComponent({
enableTaskList: false,
});
expect(wrapperNoTaskList.classes()).not.toContain('js-task-list-container');
wrapperNoTaskList.destroy();
});
it('renders hidden textarea element when issuable.description is present and enableTaskList prop is true', () => {
const textareaEl = wrapper.find('textarea.gl-display-none.js-task-list-field');
expect(textareaEl.exists()).toBe(true);
expect(textareaEl.attributes('data-update-url')).toBe(`${mockIssuable.webUrl}.json`);
});
});
});
......@@ -119,6 +119,27 @@ describe('IssuableHeader', () => {
expect(avatarEl.find(GlAvatarLabeled).find(GlIcon).exists()).toBe(false);
});
it('renders tast status text when `taskCompletionStatus` prop is defined', () => {
let taskStatusEl = wrapper.findByTestId('task-status');
expect(taskStatusEl.exists()).toBe(true);
expect(taskStatusEl.text()).toContain('0 of 5 tasks completed');
const wrapperSingleTask = createComponent({
...issuableHeaderProps,
taskCompletionStatus: {
completedCount: 0,
count: 1,
},
});
taskStatusEl = wrapperSingleTask.findByTestId('task-status');
expect(taskStatusEl.text()).toContain('0 of 1 task completed');
wrapperSingleTask.destroy();
});
it('renders sidebar toggle button', () => {
const toggleButtonEl = wrapper.findByTestId('sidebar-toggle');
......
......@@ -54,6 +54,7 @@ describe('IssuableShowRoot', () => {
editFormVisible,
descriptionPreviewPath,
descriptionHelpPath,
taskCompletionStatus,
} = mockIssuableShowProps;
const { blocked, confidential, createdAt, author } = mockIssuable;
......@@ -72,6 +73,7 @@ describe('IssuableShowRoot', () => {
confidential,
createdAt,
author,
taskCompletionStatus,
});
expect(issuableHeader.find('.issuable-status-box').text()).toContain('Open');
expect(issuableHeader.find('.detail-page-header-actions button.js-close').exists()).toBe(
......@@ -111,6 +113,26 @@ describe('IssuableShowRoot', () => {
expect(wrapper.emitted('edit-issuable')).toBeTruthy();
});
it('component emits `task-list-update-success` event bubbled via issuable-body', () => {
const issuableBody = wrapper.find(IssuableBody);
const eventParam = {
foo: 'bar',
};
issuableBody.vm.$emit('task-list-update-success', eventParam);
expect(wrapper.emitted('task-list-update-success')).toBeTruthy();
expect(wrapper.emitted('task-list-update-success')[0]).toEqual([eventParam]);
});
it('component emits `task-list-update-failure` event bubbled via issuable-body', () => {
const issuableBody = wrapper.find(IssuableBody);
issuableBody.vm.$emit('task-list-update-failure');
expect(wrapper.emitted('task-list-update-failure')).toBeTruthy();
});
it('component emits `sidebar-toggle` event bubbled via issuable-sidebar', () => {
const issuableSidebar = wrapper.find(IssuableSidebar);
......
......@@ -12,6 +12,7 @@ export const mockIssuable = {
blocked: false,
confidential: false,
updatedBy: issuable.author,
type: 'ISSUE',
currentUserTodos: {
nodes: [
{
......@@ -26,11 +27,18 @@ export const mockIssuableShowProps = {
issuable: mockIssuable,
descriptionHelpPath: '/help/user/markdown',
descriptionPreviewPath: '/gitlab-org/gitlab-shell/preview_markdown',
taskListUpdatePath: `${mockIssuable.webUrl}.json`,
taskListLockVersion: 1,
editFormVisible: false,
enableAutocomplete: true,
enableAutosave: true,
enableTaskList: true,
enableEdit: true,
showFieldTitle: false,
statusBadgeClass: 'status-box-open',
statusIcon: 'issue-open-m',
taskCompletionStatus: {
completedCount: 0,
count: 5,
},
};
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