summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/issues/show/components/description.vue
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/issues/show/components/description.vue')
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue297
1 files changed, 175 insertions, 122 deletions
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 78e729b97da..188a6f6b15e 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,13 +1,15 @@
<script>
-import { GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui';
+import { GlModalDirective, GlToast } from '@gitlab/ui';
import $ from 'jquery';
+import { uniqueId } from 'lodash';
import Sortable from 'sortablejs';
import Vue from 'vue';
+import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import { createAlert } from '~/flash';
-import { IssuableType } from '~/issues/constants';
+import { TYPE_ISSUE } from '~/issues/constants';
import { isMetaKey } from '~/lib/utils/common_utils';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
@@ -15,22 +17,30 @@ import { __, s__, sprintf } from '~/locale';
import { getSortableDefaultOptions, isDragging } from '~/sortable/utils';
import TaskList from '~/task_list';
import Tracking from '~/tracking';
+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';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
-import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
-
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import {
sprintfWorkItem,
I18N_WORK_ITEM_ERROR_CREATING,
+ I18N_WORK_ITEM_ERROR_DELETING,
TRACKING_CATEGORY_SHOW,
TASK_TYPE_NAME,
- WIDGET_TYPE_DESCRIPTION,
} from '~/work_items/constants';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import eventHub from '../event_hub';
import animateMixin from '../mixins/animate';
-import { convertDescriptionWithNewSort } from '../utils';
+import {
+ deleteTaskListItem,
+ convertDescriptionWithNewSort,
+ extractTaskTitleAndDescription,
+} from '../utils';
+import TaskListItemActions from './task_list_item_actions.vue';
Vue.use(GlToast);
@@ -44,11 +54,10 @@ export default {
GlModal: GlModalDirective,
},
components: {
- GlTooltip,
WorkItemDetailModal,
},
mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()],
- inject: ['fullPath'],
+ inject: ['fullPath', 'hasIterationsFeature'],
props: {
canUpdate: {
type: Boolean,
@@ -71,7 +80,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: IssuableType.Issue,
+ default: TYPE_ISSUE,
},
updateUrl: {
type: String,
@@ -88,6 +97,11 @@ export default {
required: false,
default: null,
},
+ issueIid: {
+ type: Number,
+ required: false,
+ default: null,
+ },
isUpdating: {
type: Boolean,
required: false,
@@ -98,18 +112,29 @@ export default {
const workItemId = getParameterByName('work_item_id');
return {
+ hasTaskListItemActions: false,
preAnimation: false,
pulseAnimation: false,
initialUpdate: true,
- taskButtons: [],
+ issueDetails: {},
activeTask: {},
workItemId: isPositiveInteger(workItemId)
- ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId)
+ ? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId)
: undefined,
workItemTypes: [],
};
},
apollo: {
+ issueDetails: {
+ query: getIssueDetailsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: String(this.issueIid),
+ };
+ },
+ update: (data) => data.workspace?.issuable,
+ },
workItem: {
query: workItemQuery,
variables() {
@@ -118,7 +143,7 @@ export default {
};
},
skip() {
- return !this.workItemId || !this.workItemsEnabled;
+ return !this.workItemId || !this.workItemsMvcEnabled;
},
},
workItemTypes: {
@@ -132,19 +157,19 @@ export default {
return data.workspace?.workItemTypes?.nodes;
},
skip() {
- return !this.workItemsEnabled;
+ return !this.workItemsMvcEnabled;
},
},
},
computed: {
- workItemsEnabled() {
- return this.glFeatures.workItemsCreateFromMarkdown;
+ workItemsMvcEnabled() {
+ return this.glFeatures.workItemsMvc;
},
taskWorkItemType() {
return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
},
issueGid() {
- return this.issueId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issueId) : null;
+ return this.issueId ? convertToGraphQLId(TYPENAME_WORK_ITEM, this.issueId) : null;
},
},
watch: {
@@ -164,10 +189,13 @@ export default {
},
},
mounted() {
+ eventHub.$on('convert-task-list-item', this.convertTaskListItem);
+ eventHub.$on('delete-task-list-item', this.deleteTaskListItem);
+
this.renderGFM();
this.updateTaskStatusText();
- if (this.workItemId && this.workItemsEnabled) {
+ if (this.workItemId && this.workItemsMvcEnabled) {
const taskLink = this.$el.querySelector(
`.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`,
);
@@ -175,6 +203,9 @@ export default {
}
},
beforeDestroy() {
+ eventHub.$off('convert-task-list-item', this.convertTaskListItem);
+ eventHub.$off('delete-task-list-item', this.deleteTaskListItem);
+
this.removeAllPointerEventListeners();
},
methods: {
@@ -197,8 +228,8 @@ export default {
this.renderSortableLists();
- if (this.workItemsEnabled) {
- this.renderTaskActions();
+ if (this.workItemsMvcEnabled) {
+ this.renderTaskListItemActions();
}
}
},
@@ -223,7 +254,7 @@ export default {
handle: '.drag-icon',
onUpdate: (event) => {
const description = convertDescriptionWithNewSort(this.descriptionText, event.to);
- this.$emit('listItemReorder', description);
+ this.$emit('saveDescription', description);
},
}),
);
@@ -232,29 +263,29 @@ export default {
createDragIconElement() {
const container = document.createElement('div');
// eslint-disable-next-line no-unsanitized/property
- container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-visibility-hidden" role="img" aria-hidden="true">
- <use href="${gon.sprite_icons}#drag-vertical"></use>
+ 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>
</svg>`;
return container.firstChild;
},
- addPointerEventListeners(listItem, iconSelector) {
+ addPointerEventListeners(listItem, elementSelector) {
const pointeroverListener = (event) => {
- const icon = event.target.closest('li').querySelector(iconSelector);
- if (!icon || isDragging() || this.isUpdating) {
+ const element = event.target.closest('li').querySelector(elementSelector);
+ if (!element || isDragging() || this.isUpdating) {
return;
}
- icon.style.visibility = 'visible';
+ element.classList.add('gl-opacity-10');
};
const pointeroutListener = (event) => {
- const icon = event.target.closest('li').querySelector(iconSelector);
- if (!icon) {
+ const element = event.target.closest('li').querySelector(elementSelector);
+ if (!element) {
return;
}
- icon.style.visibility = 'hidden';
+ element.classList.remove('gl-opacity-10');
};
// We use pointerover/pointerout instead of CSS so that when we hover over a
- // list item with children, the drag icons of its children do not become visible.
+ // list item with children, the grip icons of its children do not become visible.
listItem.addEventListener('pointerover', pointeroverListener);
listItem.addEventListener('pointerout', pointeroutListener);
@@ -279,11 +310,9 @@ export default {
taskListUpdateStarted() {
this.$emit('taskListUpdateStarted');
},
-
taskListUpdateSuccess() {
this.$emit('taskListUpdateSucceeded');
},
-
taskListUpdateError() {
createAlert({
message: sprintf(
@@ -298,7 +327,6 @@ export default {
this.$emit('taskListUpdateFailed');
},
-
updateTaskStatusText() {
const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
const $issuableHeader = $('.issuable-meta');
@@ -317,22 +345,42 @@ export default {
$tasksShort.text('');
}
},
- renderTaskActions() {
+ 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() {
if (!this.$el?.querySelectorAll) {
return;
}
- this.taskButtons = [];
const taskListFields = this.$el.querySelectorAll('.task-list-item:not(.inapplicable)');
- taskListFields.forEach((item, index) => {
+ taskListFields.forEach((item) => {
const taskLink = item.querySelector('.gfm-issue');
if (taskLink) {
const { issue, referenceType, issueType } = taskLink.dataset;
if (issueType !== workItemTypes.TASK) {
return;
}
- const workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue);
+ const workItemId = convertToGraphQLId(TYPENAME_WORK_ITEM, issue);
this.addHoverListeners(taskLink, workItemId);
taskLink.classList.add('gl-link');
taskLink.addEventListener('click', (e) => {
@@ -351,31 +399,12 @@ export default {
});
return;
}
- this.addPointerEventListeners(item, '.js-add-task');
- const button = document.createElement('button');
- button.classList.add(
- 'btn',
- 'btn-default',
- 'btn-md',
- 'gl-button',
- 'btn-default-tertiary',
- 'gl-visibility-hidden',
- 'gl-p-0!',
- 'gl-mt-n1',
- 'gl-ml-3',
- 'js-add-task',
- );
- button.id = `js-task-button-${index}`;
- this.taskButtons.push(button.id);
- // eslint-disable-next-line no-unsanitized/property
- button.innerHTML = `
- <svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14">
- <use href="${gon.sprite_icons}#doc-new"></use>
- </svg>
- `;
- button.setAttribute('aria-label', s__('WorkItem|Create task'));
- button.addEventListener('click', () => this.handleCreateTask(button));
- this.insertButtonNextToTaskText(item, button);
+
+ const toggleClass = uniqueId('task-list-item-actions-');
+ const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate, toggleClass });
+ this.addPointerEventListeners(item, `.${toggleClass}`);
+ this.insertNextToTaskListItemText(dropdown, item);
+ this.hasTaskListItemActions = true;
});
},
addHoverListeners(taskLink, id) {
@@ -391,19 +420,20 @@ export default {
}
});
},
- insertButtonNextToTaskText(listItem, button) {
- const paragraph = Array.from(listItem.children).find((element) => element.tagName === 'P');
- const lastChild = listItem.lastElementChild;
+ 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'));
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
- paragraph.append(button);
- } else if (lastChild.tagName === 'OL' || lastChild.tagName === 'UL') {
+ paragraph.append(element);
+ } else if (list) {
// Otherwise, the task item can have a child list which exists directly after the task text
- lastChild.insertAdjacentElement('beforebegin', button);
+ list.insertAdjacentElement('beforebegin', element);
} else {
// Otherwise, the task item is a simple one where the task text exists as the last child
- listItem.append(button);
+ listItem.append(element);
}
},
setActiveTask(el) {
@@ -427,55 +457,90 @@ export default {
this.workItemId = undefined;
this.updateWorkItemIdUrlQuery(undefined);
},
- async handleCreateTask(el) {
- this.setActiveTask(el);
+ async createTask({ taskTitle, taskDescription, oldDescription }) {
try {
- const { data } = await this.$apollo.mutate({
- mutation: createWorkItemFromTaskMutation,
- variables: {
- input: {
- id: this.issueGid,
- workItemData: {
- lockVersion: this.lockVersion,
- title: this.activeTask.title,
- lineNumberStart: Number(this.activeTask.lineNumberStart),
- lineNumberEnd: Number(this.activeTask.lineNumberEnd),
- workItemTypeId: this.taskWorkItemType,
- },
- },
+ const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription);
+ const iterationInput = {
+ iterationWidget: {
+ iterationId: this.issueDetails.iteration?.id ?? null,
},
- update(store, { data: { workItemCreateFromTask } }) {
- const { newWorkItem } = workItemCreateFromTask;
-
- store.writeQuery({
- query: workItemQuery,
- variables: {
- id: newWorkItem.id,
- },
- data: {
- workItem: newWorkItem,
- },
- });
+ };
+ const input = {
+ confidential: this.issueDetails.confidential,
+ description,
+ hierarchyWidget: {
+ parentId: this.issueGid,
+ },
+ ...(this.hasIterationsFeature && iterationInput),
+ milestoneWidget: {
+ milestoneId: this.issueDetails.milestone?.id ?? null,
},
+ projectPath: this.fullPath,
+ title,
+ workItemTypeId: this.taskWorkItemType,
+ };
+
+ const { data } = await this.$apollo.mutate({
+ mutation: createWorkItemMutation,
+ variables: { input },
});
- const { workItem, newWorkItem } = data.workItemCreateFromTask;
+ const { workItem, errors } = data.workItemCreate;
+
+ if (errors?.length) {
+ throw new Error(errors);
+ }
- const updatedDescription = workItem?.widgets?.find(
- (widget) => widget.type === WIDGET_TYPE_DESCRIPTION,
- )?.descriptionHtml;
+ await this.$apollo.mutate({
+ mutation: addHierarchyChildMutation,
+ variables: { id: this.issueGid, workItem },
+ });
- this.$emit('updateDescription', updatedDescription);
- this.workItemId = newWorkItem.id;
- this.openWorkItemDetailModal(el);
+ this.$toast.show(s__('WorkItem|Converted to task'), {
+ action: {
+ text: s__('WorkItem|Undo'),
+ onClick: (_, toast) => {
+ this.undoCreateTask(oldDescription, workItem.id);
+ toast.hide();
+ },
+ },
+ });
} catch (error) {
- createAlert({
- message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK),
- error,
- captureError: true,
+ 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 } },
});
+
+ this.$toast.show(s__('WorkItem|Task reverted'));
+ } catch (error) {
+ this.showAlert(I18N_WORK_ITEM_ERROR_DELETING, error);
}
},
+ showAlert(message, error) {
+ createAlert({
+ message: sprintfWorkItem(message, workItemTypes.TASK),
+ error,
+ captureError: true,
+ });
+ },
handleDeleteTask(description) {
this.$emit('updateDescription', description);
this.$toast.show(s__('WorkItem|Task deleted'));
@@ -492,14 +557,7 @@ export default {
</script>
<template>
- <div
- v-if="descriptionHtml"
- :class="{
- 'js-task-list-container': canUpdate,
- 'work-items-enabled': workItemsEnabled,
- }"
- class="description"
- >
+ <div v-if="descriptionHtml" :class="{ 'js-task-list-container': canUpdate }" class="description">
<div
ref="gfm-content"
v-safe-html:[$options.safeHtmlConfig]="descriptionHtml"
@@ -507,10 +565,10 @@ export default {
:class="{
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
+ 'has-task-list-item-actions': hasTaskListItemActions,
}"
class="md"
></div>
-
<textarea
v-if="descriptionText"
:value="descriptionText"
@@ -531,10 +589,5 @@ export default {
@workItemDeleted="handleDeleteTask"
@close="closeWorkItemDetailModal"
/>
- <template v-if="workItemsEnabled">
- <gl-tooltip v-for="item in taskButtons" :key="item" :target="item">
- {{ s__('WorkItem|Create task') }}
- </gl-tooltip>
- </template>
</div>
</template>