diff options
Diffstat (limited to 'app/assets/javascripts/issues/show/components/description.vue')
-rw-r--r-- | app/assets/javascripts/issues/show/components/description.vue | 297 |
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> |