debian-mirror-gitlab/app/assets/javascripts/issues/show/components/description.vue

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

465 lines
14 KiB
Vue
Raw Normal View History

2017-09-10 17:25:29 +05:30
<script>
2023-05-27 22:25:52 +05:30
import { GlToast } from '@gitlab/ui';
2021-03-11 19:13:27 +05:30
import $ from 'jquery';
2022-07-16 23:28:13 +05:30
import Sortable from 'sortablejs';
2022-06-21 17:19:12 +05:30
import Vue from 'vue';
2023-04-23 21:23:45 +05:30
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
2023-03-04 22:38:38 +05:30
import SafeHtml from '~/vue_shared/directives/safe_html';
2023-05-27 22:25:52 +05:30
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_ISSUE, TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import { createAlert } from '~/alert';
2023-04-23 21:23:45 +05:30
import { TYPE_ISSUE } from '~/issues/constants';
2022-06-21 17:19:12 +05:30
import { __, s__, sprintf } from '~/locale';
2022-07-16 23:28:13 +05:30
import { getSortableDefaultOptions, isDragging } from '~/sortable/utils';
2022-01-26 12:08:38 +05:30
import TaskList from '~/task_list';
2023-04-23 21:23:45 +05:30
import addHierarchyChildMutation from '~/work_items/graphql/add_hierarchy_child.mutation.graphql';
import removeHierarchyChildMutation from '~/work_items/graphql/remove_hierarchy_child.mutation.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
2022-08-13 15:12:31 +05:30
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import {
2022-10-11 01:57:18 +05:30
sprintfWorkItem,
I18N_WORK_ITEM_ERROR_CREATING,
2023-04-23 21:23:45 +05:30
I18N_WORK_ITEM_ERROR_DELETING,
2022-08-13 15:12:31 +05:30
TASK_TYPE_NAME,
} from '~/work_items/constants';
2023-03-04 22:38:38 +05:30
import { renderGFM } from '~/behaviors/markdown/render_gfm';
2023-04-23 21:23:45 +05:30
import eventHub from '../event_hub';
2021-03-11 19:13:27 +05:30
import animateMixin from '../mixins/animate';
2023-04-23 21:23:45 +05:30
import {
deleteTaskListItem,
convertDescriptionWithNewSort,
extractTaskTitleAndDescription,
} from '../utils';
import TaskListItemActions from './task_list_item_actions.vue';
2017-09-10 17:25:29 +05:30
2022-06-21 17:19:12 +05:30
Vue.use(GlToast);
2022-07-16 23:28:13 +05:30
const workItemTypes = {
TASK: 'task',
};
2018-12-13 13:39:08 +05:30
export default {
2020-11-24 15:15:51 +05:30
directives: {
SafeHtml,
},
2023-05-27 22:25:52 +05:30
mixins: [animateMixin],
2023-04-23 21:23:45 +05:30
inject: ['fullPath', 'hasIterationsFeature'],
2018-12-13 13:39:08 +05:30
props: {
canUpdate: {
type: Boolean,
required: true,
2017-09-10 17:25:29 +05:30
},
2018-12-13 13:39:08 +05:30
descriptionHtml: {
type: String,
required: true,
2017-09-10 17:25:29 +05:30
},
2018-12-13 13:39:08 +05:30
descriptionText: {
type: String,
2020-11-24 15:15:51 +05:30
required: false,
default: '',
2018-12-13 13:39:08 +05:30
},
taskStatus: {
type: String,
required: false,
default: '',
},
issuableType: {
type: String,
required: false,
2023-04-23 21:23:45 +05:30
default: TYPE_ISSUE,
2018-12-13 13:39:08 +05:30
},
updateUrl: {
type: String,
required: false,
default: null,
},
2019-03-02 22:35:43 +05:30
lockVersion: {
type: Number,
required: false,
default: 0,
},
2022-06-21 17:19:12 +05:30
issueId: {
type: Number,
required: false,
default: null,
},
2022-07-16 23:28:13 +05:30
isUpdating: {
type: Boolean,
required: false,
default: false,
},
2018-12-13 13:39:08 +05:30
},
data() {
return {
2023-04-23 21:23:45 +05:30
hasTaskListItemActions: false,
2018-12-13 13:39:08 +05:30
preAnimation: false,
pulseAnimation: false,
2020-11-24 15:15:51 +05:30
initialUpdate: true,
2023-04-23 21:23:45 +05:30
issueDetails: {},
2022-08-13 15:12:31 +05:30
workItemTypes: [],
2018-12-13 13:39:08 +05:30
};
},
2022-07-16 23:28:13 +05:30
apollo: {
2023-04-23 21:23:45 +05:30
issueDetails: {
query: getIssueDetailsQuery,
variables() {
return {
2023-05-27 22:25:52 +05:30
id: convertToGraphQLId(TYPENAME_ISSUE, this.issueId),
2022-07-16 23:28:13 +05:30
};
},
2023-05-27 22:25:52 +05:30
update: (data) => data.issue,
2022-07-16 23:28:13 +05:30
skip() {
2023-05-27 22:25:52 +05:30
return !this.canUpdate || !this.issueId;
2022-07-16 23:28:13 +05:30
},
2022-05-07 20:08:51 +05:30
},
2022-08-13 15:12:31 +05:30
workItemTypes: {
query: projectWorkItemTypesQuery,
variables() {
return {
fullPath: this.fullPath,
};
},
update(data) {
return data.workspace?.workItemTypes?.nodes;
},
2023-05-27 22:25:52 +05:30
skip() {
return !this.canUpdate;
},
2022-08-13 15:12:31 +05:30
},
2022-07-16 23:28:13 +05:30
},
computed: {
2023-05-27 22:25:52 +05:30
taskWorkItemTypeId() {
2022-08-13 15:12:31 +05:30
return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
},
2022-06-21 17:19:12 +05:30
issueGid() {
2023-04-23 21:23:45 +05:30
return this.issueId ? convertToGraphQLId(TYPENAME_WORK_ITEM, this.issueId) : null;
2022-06-21 17:19:12 +05:30
},
2022-04-04 11:22:00 +05:30
},
2018-12-13 13:39:08 +05:30
watch: {
2020-11-24 15:15:51 +05:30
descriptionHtml(newDescription, oldDescription) {
if (!this.initialUpdate && newDescription !== oldDescription) {
this.animateChange();
} else {
this.initialUpdate = false;
}
2017-09-10 17:25:29 +05:30
2018-12-13 13:39:08 +05:30
this.$nextTick(() => {
this.renderGFM();
});
2017-09-10 17:25:29 +05:30
},
2018-12-13 13:39:08 +05:30
taskStatus() {
2018-03-17 18:26:18 +05:30
this.updateTaskStatusText();
},
2018-12-13 13:39:08 +05:30
},
mounted() {
2023-04-23 21:23:45 +05:30
eventHub.$on('convert-task-list-item', this.convertTaskListItem);
eventHub.$on('delete-task-list-item', this.deleteTaskListItem);
2018-12-13 13:39:08 +05:30
this.renderGFM();
this.updateTaskStatusText();
2022-07-16 23:28:13 +05:30
},
beforeDestroy() {
2023-04-23 21:23:45 +05:30
eventHub.$off('convert-task-list-item', this.convertTaskListItem);
eventHub.$off('delete-task-list-item', this.deleteTaskListItem);
2022-07-16 23:28:13 +05:30
this.removeAllPointerEventListeners();
2018-12-13 13:39:08 +05:30
},
methods: {
renderGFM() {
2023-03-04 22:38:38 +05:30
renderGFM(this.$refs['gfm-content']);
2017-09-10 17:25:29 +05:30
2018-12-13 13:39:08 +05:30
if (this.canUpdate) {
// eslint-disable-next-line no-new
new TaskList({
dataType: this.issuableType,
fieldName: 'description',
2019-03-02 22:35:43 +05:30
lockVersion: this.lockVersion,
2018-12-13 13:39:08 +05:30
selector: '.detail-page-description',
2021-12-11 22:18:48 +05:30
onUpdate: this.taskListUpdateStarted.bind(this),
onSuccess: this.taskListUpdateSuccess.bind(this),
2019-03-02 22:35:43 +05:30
onError: this.taskListUpdateError.bind(this),
2018-12-13 13:39:08 +05:30
});
2022-07-16 23:28:13 +05:30
2022-07-23 23:45:48 +05:30
this.removeAllPointerEventListeners();
this.renderSortableLists();
2023-05-08 21:46:49 +05:30
this.renderTaskListItemActions();
2018-12-13 13:39:08 +05:30
}
},
2022-07-16 23:28:13 +05:30
renderSortableLists() {
2022-07-23 23:45:48 +05:30
// We exclude GLFM table of contents which have a `section-nav` class on the root `ul`.
2023-05-27 22:25:52 +05:30
const lists = this.$el.querySelectorAll?.(
2022-07-23 23:45:48 +05:30
'.description .md > ul:not(.section-nav), .description .md > ul:not(.section-nav) ul, .description ol',
);
2023-05-27 22:25:52 +05:30
lists?.forEach((list) => {
2022-07-16 23:28:13 +05:30
if (list.children.length <= 1) {
return;
}
2018-03-17 18:26:18 +05:30
2022-07-16 23:28:13 +05:30
Array.from(list.children).forEach((listItem) => {
listItem.prepend(this.createDragIconElement());
2022-07-23 23:45:48 +05:30
this.addPointerEventListeners(listItem, '.drag-icon');
2022-07-16 23:28:13 +05:30
});
Sortable.create(
list,
getSortableDefaultOptions({
handle: '.drag-icon',
onUpdate: (event) => {
const description = convertDescriptionWithNewSort(this.descriptionText, event.to);
2023-04-23 21:23:45 +05:30
this.$emit('saveDescription', description);
2022-07-16 23:28:13 +05:30
},
}),
);
});
},
createDragIconElement() {
const container = document.createElement('div');
2022-10-11 01:57:18 +05:30
// eslint-disable-next-line no-unsanitized/property
2023-04-23 21:23:45 +05:30
container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-opacity-0" role="img" aria-hidden="true">
<use href="${gon.sprite_icons}#grip"></use>
2022-07-16 23:28:13 +05:30
</svg>`;
return container.firstChild;
},
2023-04-23 21:23:45 +05:30
addPointerEventListeners(listItem, elementSelector) {
2022-07-16 23:28:13 +05:30
const pointeroverListener = (event) => {
2023-04-23 21:23:45 +05:30
const element = event.target.closest('li').querySelector(elementSelector);
if (!element || isDragging() || this.isUpdating) {
2022-07-16 23:28:13 +05:30
return;
}
2023-04-23 21:23:45 +05:30
element.classList.add('gl-opacity-10');
2022-07-16 23:28:13 +05:30
};
const pointeroutListener = (event) => {
2023-04-23 21:23:45 +05:30
const element = event.target.closest('li').querySelector(elementSelector);
if (!element) {
2022-07-16 23:28:13 +05:30
return;
}
2023-04-23 21:23:45 +05:30
element.classList.remove('gl-opacity-10');
2022-07-16 23:28:13 +05:30
};
// We use pointerover/pointerout instead of CSS so that when we hover over a
2023-04-23 21:23:45 +05:30
// list item with children, the grip icons of its children do not become visible.
2022-07-16 23:28:13 +05:30
listItem.addEventListener('pointerover', pointeroverListener);
listItem.addEventListener('pointerout', pointeroutListener);
this.pointerEventListeners = this.pointerEventListeners || new Map();
2022-07-23 23:45:48 +05:30
const events = [
2022-07-16 23:28:13 +05:30
{ type: 'pointerover', listener: pointeroverListener },
{ type: 'pointerout', listener: pointeroutListener },
2022-07-23 23:45:48 +05:30
];
if (this.pointerEventListeners.has(listItem)) {
const concatenatedEvents = this.pointerEventListeners.get(listItem).concat(events);
this.pointerEventListeners.set(listItem, concatenatedEvents);
} else {
this.pointerEventListeners.set(listItem, events);
}
2022-07-16 23:28:13 +05:30
},
removeAllPointerEventListeners() {
this.pointerEventListeners?.forEach((events, listItem) => {
events.forEach((event) => listItem.removeEventListener(event.type, event.listener));
this.pointerEventListeners.delete(listItem);
});
},
2021-12-11 22:18:48 +05:30
taskListUpdateStarted() {
this.$emit('taskListUpdateStarted');
},
taskListUpdateSuccess() {
this.$emit('taskListUpdateSucceeded');
},
2019-03-02 22:35:43 +05:30
taskListUpdateError() {
2023-03-04 22:38:38 +05:30
createAlert({
2021-09-04 01:27:46 +05:30
message: sprintf(
2021-12-11 22:18:48 +05:30
__(
2019-03-02 22:35:43 +05:30
'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.',
),
{
issueType: this.issuableType,
},
),
2021-09-04 01:27:46 +05:30
});
2019-03-02 22:35:43 +05:30
this.$emit('taskListUpdateFailed');
},
2018-12-13 13:39:08 +05:30
updateTaskStatusText() {
const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
const $issuableHeader = $('.issuable-meta');
const $tasks = $('#task_status', $issuableHeader);
const $tasksShort = $('#task_status_short', $issuableHeader);
2017-09-10 17:25:29 +05:30
2018-12-13 13:39:08 +05:30
if (taskRegexMatches) {
$tasks.text(this.taskStatus);
$tasksShort.text(
2022-08-27 11:52:29 +05:30
`${taskRegexMatches[1]}/${taskRegexMatches[2]} checklist item${
taskRegexMatches[2] > 1 ? 's' : ''
}`,
2018-12-13 13:39:08 +05:30
);
} else {
$tasks.text('');
$tasksShort.text('');
}
2017-09-10 17:25:29 +05:30
},
2023-04-23 21:23:45 +05:30
createTaskListItemActions(provide) {
const app = new Vue({
el: document.createElement('div'),
provide,
render: (createElement) => createElement(TaskListItemActions),
});
return app.$el;
},
convertTaskListItem(sourcepos) {
const oldDescription = this.descriptionText;
const { newDescription, taskDescription, taskTitle } = deleteTaskListItem(
oldDescription,
sourcepos,
);
this.$emit('saveDescription', newDescription);
this.createTask({ taskTitle, taskDescription, oldDescription });
},
deleteTaskListItem(sourcepos) {
const { newDescription } = deleteTaskListItem(this.descriptionText, sourcepos);
this.$emit('saveDescription', newDescription);
},
renderTaskListItemActions() {
2023-05-27 22:25:52 +05:30
const taskListItems = this.$el.querySelectorAll?.(
'.task-list-item:not(.inapplicable, table .task-list-item)',
);
2023-04-23 21:23:45 +05:30
2023-05-27 22:25:52 +05:30
taskListItems?.forEach((item) => {
const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate });
2023-04-23 21:23:45 +05:30
this.insertNextToTaskListItemText(dropdown, item);
2023-05-27 22:25:52 +05:30
this.addPointerEventListeners(item, '.task-list-item-actions');
2023-04-23 21:23:45 +05:30
this.hasTaskListItemActions = true;
2022-07-16 23:28:13 +05:30
});
},
2023-04-23 21:23:45 +05:30
insertNextToTaskListItemText(element, listItem) {
const children = Array.from(listItem.children);
const paragraph = children.find((el) => el.tagName === 'P');
const list = children.find((el) => el.classList.contains('task-list'));
2022-07-23 23:45:48 +05:30
if (paragraph) {
// If there's a `p` element, then it's a multi-paragraph task item
// and the task text exists within the `p` element as the last child
2023-04-23 21:23:45 +05:30
paragraph.append(element);
} else if (list) {
2022-07-23 23:45:48 +05:30
// Otherwise, the task item can have a child list which exists directly after the task text
2023-04-23 21:23:45 +05:30
list.insertAdjacentElement('beforebegin', element);
2022-07-23 23:45:48 +05:30
} else {
// Otherwise, the task item is a simple one where the task text exists as the last child
2023-04-23 21:23:45 +05:30
listItem.append(element);
2022-07-23 23:45:48 +05:30
}
},
2023-04-23 21:23:45 +05:30
async createTask({ taskTitle, taskDescription, oldDescription }) {
2022-08-13 15:12:31 +05:30
try {
2023-04-23 21:23:45 +05:30
const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription);
const iterationInput = {
iterationWidget: {
iterationId: this.issueDetails.iteration?.id ?? null,
2022-08-13 15:12:31 +05:30
},
2023-04-23 21:23:45 +05:30
};
const input = {
confidential: this.issueDetails.confidential,
description,
hierarchyWidget: {
parentId: this.issueGid,
},
...(this.hasIterationsFeature && iterationInput),
milestoneWidget: {
milestoneId: this.issueDetails.milestone?.id ?? null,
2022-08-13 15:12:31 +05:30
},
2023-04-23 21:23:45 +05:30
projectPath: this.fullPath,
title,
2023-05-27 22:25:52 +05:30
workItemTypeId: this.taskWorkItemTypeId,
2023-04-23 21:23:45 +05:30
};
const { data } = await this.$apollo.mutate({
mutation: createWorkItemMutation,
variables: { input },
2022-08-13 15:12:31 +05:30
});
2023-04-23 21:23:45 +05:30
const { workItem, errors } = data.workItemCreate;
if (errors?.length) {
throw new Error(errors);
}
2022-08-13 15:12:31 +05:30
2023-04-23 21:23:45 +05:30
await this.$apollo.mutate({
mutation: addHierarchyChildMutation,
variables: { id: this.issueGid, workItem },
});
2022-08-13 15:12:31 +05:30
2023-04-23 21:23:45 +05:30
this.$toast.show(s__('WorkItem|Converted to task'), {
action: {
text: s__('WorkItem|Undo'),
onClick: (_, toast) => {
this.undoCreateTask(oldDescription, workItem.id);
toast.hide();
},
},
});
2022-08-13 15:12:31 +05:30
} catch (error) {
2023-04-23 21:23:45 +05:30
this.showAlert(I18N_WORK_ITEM_ERROR_CREATING, error);
}
},
async undoCreateTask(oldDescription, id) {
this.$emit('saveDescription', oldDescription);
try {
const { data } = await this.$apollo.mutate({
mutation: deleteWorkItemMutation,
variables: { input: { id } },
});
const { errors } = data.workItemDelete;
if (errors?.length) {
throw new Error(errors);
}
await this.$apollo.mutate({
mutation: removeHierarchyChildMutation,
variables: { id: this.issueGid, workItem: { id } },
2022-08-13 15:12:31 +05:30
});
2023-04-23 21:23:45 +05:30
this.$toast.show(s__('WorkItem|Task reverted'));
} catch (error) {
this.showAlert(I18N_WORK_ITEM_ERROR_DELETING, error);
2022-08-13 15:12:31 +05:30
}
2022-04-04 11:22:00 +05:30
},
2023-04-23 21:23:45 +05:30
showAlert(message, error) {
createAlert({
message: sprintfWorkItem(message, workItemTypes.TASK),
error,
captureError: true,
});
},
2018-12-13 13:39:08 +05:30
},
2022-01-26 12:08:38 +05:30
safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] },
2018-12-13 13:39:08 +05:30
};
2017-09-10 17:25:29 +05:30
</script>
<template>
2023-04-23 21:23:45 +05:30
<div v-if="descriptionHtml" :class="{ 'js-task-list-container': canUpdate }" class="description">
2017-09-10 17:25:29 +05:30
<div
2018-11-08 19:23:39 +05:30
ref="gfm-content"
2021-11-11 11:23:49 +05:30
v-safe-html:[$options.safeHtmlConfig]="descriptionHtml"
2022-04-04 11:22:00 +05:30
data-testid="gfm-content"
2017-09-10 17:25:29 +05:30
:class="{
'issue-realtime-pre-pulse': preAnimation,
2019-02-15 15:39:39 +05:30
'issue-realtime-trigger-pulse': pulseAnimation,
2023-04-23 21:23:45 +05:30
'has-task-list-item-actions': hasTaskListItemActions,
2017-09-10 17:25:29 +05:30
}"
2019-07-07 11:18:12 +05:30
class="md"
2019-02-15 15:39:39 +05:30
></div>
2017-09-10 17:25:29 +05:30
<textarea
v-if="descriptionText"
2022-06-21 17:19:12 +05:30
:value="descriptionText"
2018-03-17 18:26:18 +05:30
:data-update-url="updateUrl"
2018-11-08 19:23:39 +05:30
class="hidden js-task-list-field"
2019-07-31 22:56:46 +05:30
dir="auto"
2022-04-04 11:22:00 +05:30
data-testid="textarea"
2018-03-17 18:26:18 +05:30
>
2017-09-10 17:25:29 +05:30
</textarea>
</div>
</template>