Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-01-21 03:08:59 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-01-21 03:08:59 +0300
commit367847e266036617e540e41b7fd3c7d03033800c (patch)
treea14547ad7556d48dcdeb977021f8cd89305ea026 /app/assets/javascripts/issues
parent5d7e5a8902382caaffa616e1b496b684ba72d148 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/issues')
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue118
-rw-r--r--app/assets/javascripts/issues/show/components/task_list_item_actions.vue40
-rw-r--r--app/assets/javascripts/issues/show/utils.js75
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);
+};