diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-21 03:08:59 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-21 03:08:59 +0300 |
commit | 367847e266036617e540e41b7fd3c7d03033800c (patch) | |
tree | a14547ad7556d48dcdeb977021f8cd89305ea026 /app/assets/javascripts/issues | |
parent | 5d7e5a8902382caaffa616e1b496b684ba72d148 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/issues')
4 files changed, 168 insertions, 69 deletions
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index e5428f87095..45c4e0889c0 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -453,7 +453,7 @@ export default { } }, - handleListItemReorder(description) { + handleSaveDescription(description) { this.updateFormState(); this.setFormState({ description }); this.updateIssuable(); @@ -573,7 +573,7 @@ export default { :update-url="updateEndpoint" :lock-version="state.lock_version" :is-updating="formState.updateLoading" - @listItemReorder="handleListItemReorder" + @saveDescription="handleSaveDescription" @taskListUpdateStarted="taskListUpdateStarted" @taskListUpdateSucceeded="taskListUpdateSucceeded" @taskListUpdateFailed="taskListUpdateFailed" diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 78e729b97da..ba0f28d1c7c 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -1,6 +1,7 @@ <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 SafeHtml from '~/vue_shared/directives/safe_html'; @@ -18,7 +19,6 @@ import Tracking from '~/tracking'; 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 { @@ -29,8 +29,10 @@ import { 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 { convertDescriptionWithDeletedTaskListItem, convertDescriptionWithNewSort } from '../utils'; +import TaskListItemActions from './task_list_item_actions.vue'; Vue.use(GlToast); @@ -44,7 +46,6 @@ export default { GlModal: GlModalDirective, }, components: { - GlTooltip, WorkItemDetailModal, }, mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()], @@ -98,10 +99,10 @@ export default { const workItemId = getParameterByName('work_item_id'); return { + hasTaskListItemActions: false, preAnimation: false, pulseAnimation: false, initialUpdate: true, - taskButtons: [], activeTask: {}, workItemId: isPositiveInteger(workItemId) ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId) @@ -164,6 +165,8 @@ export default { }, }, mounted() { + eventHub.$on('delete-task-list-item', this.deleteTaskListItem); + this.renderGFM(); this.updateTaskStatusText(); @@ -175,6 +178,8 @@ export default { } }, beforeDestroy() { + eventHub.$off('delete-task-list-item', this.deleteTaskListItem); + this.removeAllPointerEventListeners(); }, methods: { @@ -198,7 +203,7 @@ export default { this.renderSortableLists(); if (this.workItemsEnabled) { - this.renderTaskActions(); + this.renderTaskListItemActions(); } } }, @@ -223,7 +228,7 @@ export default { handle: '.drag-icon', onUpdate: (event) => { const description = convertDescriptionWithNewSort(this.descriptionText, event.to); - this.$emit('listItemReorder', description); + this.$emit('saveDescription', description); }, }), ); @@ -232,25 +237,25 @@ 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"> + 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}#drag-vertical"></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 @@ -279,11 +284,9 @@ export default { taskListUpdateStarted() { this.$emit('taskListUpdateStarted'); }, - taskListUpdateSuccess() { this.$emit('taskListUpdateSucceeded'); }, - taskListUpdateError() { createAlert({ message: sprintf( @@ -298,7 +301,6 @@ export default { this.$emit('taskListUpdateFailed'); }, - updateTaskStatusText() { const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/); const $issuableHeader = $('.issuable-meta'); @@ -317,15 +319,28 @@ export default { $tasksShort.text(''); } }, - renderTaskActions() { + createTaskListItemActions(toggleClass) { + const app = new Vue({ + el: document.createElement('div'), + provide: { toggleClass }, + render: (createElement) => createElement(TaskListItemActions), + }); + return app.$el; + }, + deleteTaskListItem(sourcepos) { + this.$emit( + 'saveDescription', + convertDescriptionWithDeletedTaskListItem(this.descriptionText, sourcepos), + ); + }, + 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; @@ -351,31 +366,11 @@ 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-'); + this.addPointerEventListeners(item, `.${toggleClass}`); + this.insertNextToTaskListItemText(this.createTaskListItemActions(toggleClass), item); + this.hasTaskListItemActions = true; }); }, addHoverListeners(taskLink, id) { @@ -391,19 +386,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) { @@ -492,14 +488,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 +496,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 +520,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> diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue new file mode 100644 index 00000000000..084cd6062d5 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue @@ -0,0 +1,40 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + i18n: { + delete: __('Delete'), + taskActions: s__('WorkItem|Task actions'), + }, + components: { + GlDropdown, + GlDropdownItem, + }, + inject: ['toggleClass'], + methods: { + deleteTaskListItem() { + eventHub.$emit('delete-task-list-item', this.$el.closest('li').dataset.sourcepos); + }, + }, +}; +</script> + +<template> + <gl-dropdown + class="task-list-item-actions-wrapper" + category="tertiary" + icon="ellipsis_v" + lazy + no-caret + right + :text="$options.i18n.taskActions" + text-sr-only + :toggle-class="`task-list-item-actions gl-opacity-0 gl-p-2! ${toggleClass}`" + > + <gl-dropdown-item variant="danger" @click="deleteTaskListItem"> + {{ $options.i18n.delete }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/issues/show/utils.js b/app/assets/javascripts/issues/show/utils.js index 05b06586362..26ce5d03c2f 100644 --- a/app/assets/javascripts/issues/show/utils.js +++ b/app/assets/javascripts/issues/show/utils.js @@ -93,3 +93,78 @@ export const convertDescriptionWithNewSort = (description, list) => { return descriptionLines.join(NEWLINE); }; + +const bulletTaskListItemRegex = /^\s*[-*]\s+\[.]/; +const numericalTaskListItemRegex = /^\s*[0-9]\.\s+\[.]/; + +/** + * Checks whether the line of markdown contains a task list item, + * i.e. `- [ ]`, `* [ ]`, or `1. [ ]`. + * + * @param {String} line A line of markdown + * @returns {boolean} `true` if the line contains a task list item, otherwise `false` + */ +const containsTaskListItem = (line) => + bulletTaskListItemRegex.test(line) || numericalTaskListItemRegex.test(line); + +/** + * Deletes a task list item from the description. + * + * Starting from the task list item, it deletes each line until it hits a nested + * task list item and reduces the indentation of each line from this line onwards. + * + * For example, for a given description like: + * + * <pre> + * 1. [ ] item 1 + * + * paragraph text + * + * 1. [ ] item 2 + * + * paragraph text + * + * 1. [ ] item 3 + * </pre> + * + * Then when prompted to delete item 1, this function will return: + * + * <pre> + * 1. [ ] item 2 + * + * paragraph text + * + * 1. [ ] item 3 + * </pre> + * + * @param {String} description Description in markdown format + * @param {String} sourcepos Source position in format `23:3-23:14` + * @returns {String} Markdown with the deleted task list item + */ +export const convertDescriptionWithDeletedTaskListItem = (description, sourcepos) => { + const descriptionLines = description.split(NEWLINE); + const [startIndex, endIndex] = getSourceposRows(sourcepos); + + let indentation = 0; + let linesToDelete = 1; + let reduceIndentation = false; + + for (let i = startIndex + 1; i <= endIndex; i += 1) { + if (reduceIndentation) { + descriptionLines[i] = descriptionLines[i].slice(indentation); + } else if (containsTaskListItem(descriptionLines[i])) { + reduceIndentation = true; + const firstLine = descriptionLines[startIndex]; + const currentLine = descriptionLines[i]; + const firstLineIndentation = firstLine.length - firstLine.trimStart().length; + const currentLineIndentation = currentLine.length - currentLine.trimStart().length; + indentation = currentLineIndentation - firstLineIndentation; + descriptionLines[i] = descriptionLines[i].slice(indentation); + } else { + linesToDelete += 1; + } + } + + descriptionLines.splice(startIndex, linesToDelete); + return descriptionLines.join(NEWLINE); +}; |