diff options
Diffstat (limited to 'app/assets/javascripts/issues/show/components')
18 files changed, 421 insertions, 214 deletions
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index e5428f87095..decb559ee81 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -3,10 +3,11 @@ import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gi import Visibility from 'visibilityjs'; import { createAlert } from '~/flash'; import { - IssuableStatus, IssuableStatusText, + STATUS_CLOSED, + TYPE_EPIC, + TYPE_ISSUE, WorkspaceType, - IssuableType, } from '~/issues/constants'; import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -156,7 +157,7 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, }, canAttachFile: { type: Boolean, @@ -190,6 +191,11 @@ export default { required: false, default: null, }, + issueIid: { + type: Number, + required: false, + default: null, + }, }, data() { const store = new Store({ @@ -251,7 +257,7 @@ export default { return sprintf(__('Error updating %{issuableType}'), { issuableType: this.issuableType }); }, isClosed() { - return this.issuableStatus === IssuableStatus.Closed; + return this.issuableStatus === STATUS_CLOSED; }, pinnedLinkClasses() { return this.showTitleBorder @@ -259,7 +265,7 @@ export default { : ''; }, statusIcon() { - if (this.issuableType === IssuableType.Issue) { + if (this.issuableType === TYPE_ISSUE) { return this.isClosed ? 'issue-closed' : 'issues'; } return this.isClosed ? 'epic-closed' : 'epic'; @@ -271,7 +277,7 @@ export default { return IssuableStatusText[this.issuableStatus]; }, shouldShowStickyHeader() { - return [IssuableType.Issue, IssuableType.Epic].includes(this.issuableType); + return [TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType); }, }, created() { @@ -453,7 +459,7 @@ export default { } }, - handleListItemReorder(description) { + handleSaveDescription(description) { this.updateFormState(); this.setFormState({ description }); this.updateIssuable(); @@ -564,6 +570,7 @@ export default { <component :is="descriptionComponent" :issue-id="issueId" + :issue-iid="issueIid" :can-update="canUpdate" :description-html="state.descriptionHtml" :description-text="state.descriptionText" @@ -573,7 +580,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..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> diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 04c5007dbec..3bc24e8ce01 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; @@ -35,6 +36,16 @@ export default { default: true, }, }, + data() { + return { + formFieldProps: { + id: 'issue-description', + name: 'issue-description', + placeholder: __('Write a comment or drag your files here…'), + 'aria-label': __('Description'), + }, + }; + }, computed: { quickActionsDocsPath() { return helpPagePath('user/project/quick_actions'); @@ -60,10 +71,7 @@ export default { :value="value" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :form-field-aria-label="__('Description')" - :form-field-placeholder="__('Write a comment or drag your files here…')" - form-field-id="issue-description" - form-field-name="issue-description" + :form-field-props="formFieldProps" :quick-actions-docs-path="quickActionsDocsPath" :enable-autocomplete="enableAutocomplete" supports-quick-actions @@ -84,15 +92,13 @@ export default { > <template #textarea> <textarea - id="issue-description" + v-bind="formFieldProps" ref="textarea" :value="value" class="note-textarea js-gfm-input js-autosize markdown-area" data-qa-selector="description_field" dir="auto" data-supports-quick-actions="true" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" @input="$emit('input', $event.target.value)" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue index 5695efd7114..5ade1a86d30 100644 --- a/app/assets/javascripts/issues/show/components/fields/type.vue +++ b/app/assets/javascripts/issues/show/components/fields/type.vue @@ -1,6 +1,6 @@ <script> -import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { capitalize } from 'lodash'; +import { GlFormGroup, GlIcon, GlListbox } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; import { issuableTypes, INCIDENT_TYPE } from '../../constants'; import getIssueStateQuery from '../../queries/get_issue_state.query.graphql'; @@ -16,34 +16,35 @@ export default { components: { GlFormGroup, GlIcon, - GlDropdown, - GlDropdownItem, + GlListbox, }, inject: { canCreateIncident: { default: false, }, issueType: { - default: 'issue', + default: TYPE_ISSUE, }, }, data() { return { issueState: {}, + selectedIssueType: '', }; }, apollo: { issueState: { query: getIssueStateQuery, + result({ + data: { + issueState: { issueType }, + }, + }) { + this.selectedIssueType = issueType; + }, }, }, computed: { - dropdownText() { - const { - issueState: { issueType }, - } = this; - return issuableTypes.find((type) => type.value === issueType)?.text || capitalize(issueType); - }, shouldShowIncident() { return this.issueType === INCIDENT_TYPE || this.canCreateIncident; }, @@ -72,25 +73,21 @@ export default { label-for="issuable-type" class="mb-2 mb-md-0" > - <gl-dropdown - id="issuable-type" - :aria-labelledby="$options.i18n.label" - :text="dropdownText" + <gl-listbox + v-model="selectedIssueType" + toggle-class="gl-mb-0" + :items="$options.issuableTypes" :header-text="$options.i18n.label" - class="gl-w-full" - toggle-class="dropdown-menu-toggle" + :list-aria-labelled-by="$options.i18n.label" + block + @select="updateIssueType" > - <gl-dropdown-item - v-for="type in $options.issuableTypes" - v-show="isShown(type)" - :key="type.value" - :is-checked="issueState.issueType === type.value" - is-check-item - @click="updateIssueType(type.value)" - > - <gl-icon :name="type.icon" /> - {{ type.text }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item="{ item }"> + <span v-show="isShown(item)" data-testid="issue-type-list-item"> + <gl-icon :name="item.icon" /> + {{ item.text }} + </span> + </template> + </gl-listbox> </gl-form-group> </template> diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index b56c91d7983..bcea9cf57a7 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -1,7 +1,7 @@ <script> import { GlAlert } from '@gitlab/ui'; import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave'; -import { IssuableType } from '~/issues/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import eventHub from '../event_hub'; import EditActions from './edit_actions.vue'; import DescriptionField from './fields/description.vue'; @@ -98,7 +98,7 @@ export default { return this.formState.lockedWarningVisible && !this.formState.updateLoading; }, isIssueType() { - return this.issuableType === IssuableType.Issue; + return this.issuableType === TYPE_ISSUE; }, }, watch: { diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 56e360c75e3..9d92b5cf954 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -12,7 +12,7 @@ import { import { mapActions, mapGetters, mapState } from 'vuex'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; -import { IssuableStatus, IssueType } from '~/issues/constants'; +import { IssueType, STATUS_CLOSED } from '~/issues/constants'; import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -98,6 +98,12 @@ export default { submitAsSpamPath: { default: '', }, + reportedUserId: { + default: '', + }, + reportedFromUrl: { + default: '', + }, }, data() { return { @@ -108,7 +114,7 @@ export default { ...mapState(['isToggleStateButtonLoading']), ...mapGetters(['openState', 'getBlockedByIssues']), isClosed() { - return this.openState === IssuableStatus.Closed; + return this.openState === STATUS_CLOSED; }, issueTypeText() { const issueTypeTexts = { @@ -368,7 +374,12 @@ export default { :title="deleteButtonText" /> + <!-- IMPORTANT: show this component lazily because it causes layout thrashing --> + <!-- https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 --> <abuse-category-selector + v-if="isReportAbuseDrawerOpen" + :reported-user-id="reportedUserId" + :reported-from-url="reportedFromUrl" :show-drawer="isReportAbuseDrawerOpen" @close-drawer="toggleReportAbuseDrawer(false)" /> diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index 2fdae538902..c0aadf9c14e 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -47,9 +47,21 @@ export const timelineItemI18n = Object.freeze({ export const timelineEventTagsI18n = Object.freeze({ startTime: __('Start time'), + impactDetected: __('Impact detected'), + responseInitiated: __('Response initiated'), + impactMitigated: __('Impact mitigated'), + causeIdentified: __('Cause identified'), endTime: __('End time'), }); +export const timelineEventTagsPopover = Object.freeze({ + title: s__('Incident|Event tag'), + message: s__( + 'Incident|Adding an event tag associates the timeline comment with specific incident metrics.', + ), + link: __('Learn more'), +}); + export const MAX_TEXT_LENGTH = 280; export const TIMELINE_EVENT_TAGS = Object.values(timelineEventTagsI18n).map((item) => ({ diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue index 81111d42b39..40cb7fbb0ff 100644 --- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue @@ -5,7 +5,7 @@ import { GlIcon } from '@gitlab/ui'; import { sprintf } from '~/locale'; import { createAlert } from '~/flash'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE } from '~/graphql_shared/constants'; import { timelineFormI18n } from './constants'; import TimelineEventsForm from './timeline_events_form.vue'; @@ -41,7 +41,7 @@ export default { } const variables = { - incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId), fullPath: this.fullPath, }; @@ -71,7 +71,7 @@ export default { mutation: CreateTimelineEvent, variables: { input: { - incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId), note: eventDetails.note, occurredAt: eventDetails.occurredAt, timelineEventTagNames: eventDetails.timelineEventTags, @@ -113,13 +113,13 @@ export default { > <div v-if="hasTimelineEvents" - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-z-index-1" > <gl-icon name="comment" class="note-icon" /> </div> <timeline-events-form ref="eventForm" - :class="{ 'gl-border-gray-50 gl-border-t': hasTimelineEvents }" + :class="{ 'gl-border-gray-50 gl-border-t gl-pt-3': hasTimelineEvents }" :is-event-processed="createTimelineEventActive" show-save-and-add @save-event="createIncidentTimelineEvent" diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue index 4ef9b9c5a99..c2fb8b6f683 100644 --- a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue @@ -28,9 +28,9 @@ export default { </script> <template> - <div class="gl-relative gl-display-flex gl-align-items-center"> + <div class="edit-timeline-event gl-relative gl-display-flex gl-align-items-center"> <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-z-index-1" > <gl-icon name="comment" class="note-icon" /> </div> @@ -40,6 +40,7 @@ export default { :is-event-processed="editTimelineEventActive" :previous-occurred-at="event.occurredAt" :previous-note="event.note" + :previous-tags="event.timelineEventTags.nodes" is-editing @save-event="saveEvent" @cancel="$emit('hide-edit')" diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql index 54f036268cc..77f955c08dc 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql @@ -7,6 +7,12 @@ mutation UpdateTimelineEvent($input: TimelineEventUpdateInput!) { action occurredAt createdAt + timelineEventTags { + nodes { + id + name + } + } } errors } diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 53956fcb4b2..997fadec602 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -125,8 +125,8 @@ export default { item.classList.toggle('gl-display-none', !isSummaryTab); }); - editButton.classList.toggle('gl-display-none', !isSummaryTab); - editButton.classList.toggle('gl-sm-display-inline-flex!', isSummaryTab); + editButton?.classList.toggle('gl-display-none', !isSummaryTab); + editButton?.classList.toggle('gl-sm-display-inline-flex!', isSummaryTab); } }, }, diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue index 6648e20865d..7944362a40f 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -1,10 +1,11 @@ <script> -import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlListbox } from '@gitlab/ui'; +import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlCollapsibleListbox } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __, sprintf } from '~/locale'; +import TimelineEventsTagsPopover from './timeline_events_tags_popover.vue'; import { MAX_TEXT_LENGTH, TIMELINE_EVENT_TAGS, timelineFormI18n } from './constants'; -import { getUtcShiftedDate } from './utils'; +import { getUtcShiftedDate, getPreviousEventTags } from './utils'; export default { name: 'TimelineEventsForm', @@ -21,11 +22,12 @@ export default { ], components: { MarkdownField, + TimelineEventsTagsPopover, GlDatepicker, GlFormInput, GlFormGroup, GlButton, - GlListbox, + GlCollapsibleListbox, }, mixins: [glFeatureFlagsMixin()], i18n: timelineFormI18n, @@ -77,7 +79,7 @@ export default { hourPickerInput: placeholderDate.getHours(), minutePickerInput: placeholderDate.getMinutes(), datePickerInput: placeholderDate, - selectedTags: [...this.previousTags], + selectedTags: getPreviousEventTags(this.previousTags), }; }, computed: { @@ -101,19 +103,19 @@ export default { timelineTextCount() { return this.timelineText.length; }, - dropdownText() { + listboxText() { if (!this.selectedTags.length) { return timelineFormI18n.selectTags; } - const dropdownText = + const listboxText = this.selectedTags.length === 1 ? this.selectedTags[0] : sprintf(__('%{numberOfSelectedTags} tags'), { numberOfSelectedTags: this.selectedTags.length, }); - return dropdownText; + return listboxText; }, }, mounted() { @@ -164,11 +166,11 @@ export default { <template> <form class="gl-flex-grow-1 gl-border-gray-50"> - <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row"> - <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5"> + <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-mt-3"> + <gl-form-group :label="__('Date')" class="gl-mr-5"> <gl-datepicker id="incident-date" ref="datepicker" v-model="datePickerInput" /> </gl-form-group> - <div class="gl-display-flex gl-mt-5"> + <div class="gl-display-flex"> <gl-form-group :label="__('Time')"> <div class="gl-display-flex"> <label label-for="timeline-input-hours" class="sr-only"></label> @@ -197,10 +199,15 @@ export default { <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> </div> </div> - <gl-form-group v-if="glFeatures.incidentEventTags" :label="$options.i18n.tagsLabel"> - <gl-listbox + <gl-form-group v-if="glFeatures.incidentEventTags"> + <label class="gl-display-flex gl-align-items-center gl-gap-3" for="timeline-input-tags"> + {{ $options.i18n.tagsLabel }} + <timeline-events-tags-popover /> + </label> + <gl-collapsible-listbox + id="timeline-input-tags" :selected="selectedTags" - :toggle-text="dropdownText" + :toggle-text="listboxText" :items="tags" :is-check-centered="true" :multiple="true" diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue index 90ee4351e39..d33f3146d64 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -32,16 +32,19 @@ export default { type: String, required: true, }, - eventTag: { - type: String, + eventTags: { + type: Array, required: false, - default: null, + default: () => [], }, }, computed: { time() { return formatDate(this.occurredAt, 'HH:MM', true); }, + canEditEvent() { + return this.action === 'comment'; + }, }, methods: { getEventIcon, @@ -51,19 +54,24 @@ export default { <template> <div class="timeline-event gl-display-grid"> <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" > <gl-icon :name="getEventIcon(action)" class="note-icon" /> </div> <div class="timeline-event-note timeline-event-border" data-testid="event-text-container"> - <div class="gl-display-flex gl-align-items-center gl-mb-3"> - <strong class="gl-font-lg" data-testid="event-time"> + <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-gap-3 gl-mb-2"> + <h3 + class="timeline-event-note-date gl-font-weight-bold gl-font-sm gl-my-0" + data-testid="event-time" + > <gl-sprintf :message="$options.i18n.timeUTC"> - <template #time>{{ time }}</template> + <template #time> + <span class="gl-font-lg">{{ time }}</span> + </template> </gl-sprintf> - </strong> - <gl-badge v-if="eventTag" variant="muted" icon="tag" class="gl-ml-3"> - {{ eventTag }} + </h3> + <gl-badge v-for="tag in eventTags" :key="tag.key" variant="muted" icon="tag"> + {{ tag.name }} </gl-badge> </div> <div v-safe-html="noteHtml" class="md"></div> @@ -78,7 +86,7 @@ export default { category="tertiary" no-caret > - <gl-dropdown-item @click="$emit('edit')"> + <gl-dropdown-item v-if="canEditEvent" @click="$emit('edit')"> {{ $options.i18n.edit }} </gl-dropdown-item> <gl-dropdown-item @click="$emit('delete')"> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue index c6b93201c97..10b80529a66 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue @@ -50,9 +50,6 @@ export default { }, }, methods: { - getFirstTag(eventTag) { - return eventTag.nodes?.[0]?.name; - }, handleEditSelection(event) { this.eventToEdit = event.id; this.$emit('hide-new-incident-timeline-event-form'); @@ -105,6 +102,7 @@ export default { id: eventDetails.id, note: eventDetails.note, occurredAt: eventDetails.occurredAt, + timelineEventTagNames: eventDetails.timelineEventTags, }, }, }) @@ -132,21 +130,25 @@ export default { </script> <template> - <div class="issuable-discussion incident-timeline-events"> + <div class="issuable-discussion incident-timeline-events gl-mt-n3"> <div v-for="[eventDate, events] in dateGroupedEvents" :key="eventDate" data-testid="timeline-group" class="timeline-group" > - <div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid"> - <strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong> - </div> + <h2 + class="gl-font-size-h2 gl-my-0 gl-py-5 gl-border-gray-50 gl-border-1 gl-border-b-solid" + data-testid="event-date" + > + {{ eventDate }} + </h2> + <ul class="notes main-notes-list"> <li v-for="(event, eventIndex) in events" :key="eventIndex" - class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-2! gl-pr-0!" + class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-0! gl-pr-0!" > <edit-timeline-event v-if="eventToEdit === event.id" @@ -164,7 +166,7 @@ export default { :action="event.action" :occurred-at="event.occurredAt" :note-html="event.noteHtml" - :event-tag="getFirstTag(event.timelineEventTags)" + :event-tags="event.timelineEventTags.nodes" @delete="handleDelete(event)" @edit="handleEditSelection(event)" /> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue index c8237766505..cb18d34b70b 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE } from '~/graphql_shared/constants'; import { fetchPolicies } from '~/lib/graphql'; import notesEventHub from '~/notes/event_hub'; import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql'; @@ -33,7 +33,7 @@ export default { variables() { return { fullPath: this.fullPath, - incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId), }; }, update(data) { diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue new file mode 100644 index 00000000000..772a16e9ba2 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue @@ -0,0 +1,42 @@ +<script> +import { GlIcon, GlPopover, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { timelineEventTagsPopover } from './constants'; + +export default { + name: 'TimelineEventsTagsPopover', + components: { + GlIcon, + GlPopover, + GlLink, + }, + i18n: timelineEventTagsPopover, + learnMoreLink: helpPagePath('ee/operations/incident_management/incident_timeline_events', { + anchor: 'incident-tags', + }), +}; +</script> + +<template> + <span> + <gl-icon id="timeline-events-tag-question" name="question-o" class="gl-text-blue-600" /> + + <gl-popover + target="timeline-events-tag-question" + triggers="hover focus" + placement="top" + container="viewport" + :title="$options.i18n.title" + > + <div> + <p class="gl-mb-0"> + {{ $options.i18n.message }} + </p> + <gl-link target="_blank" class="gl-font-sm" :href="$options.learnMoreLink">{{ + $options.i18n.link + }}</gl-link + >. + </div> + </gl-popover> + </span> +</template> diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js index 5a009debd75..ce33e91c3b8 100644 --- a/app/assets/javascripts/issues/show/components/incidents/utils.js +++ b/app/assets/javascripts/issues/show/components/incidents/utils.js @@ -32,3 +32,11 @@ export const getUtcShiftedDate = (ISOString = null) => { return date; }; + +/** + * Returns an array of previously set event tags + * @param {array} timelineEventTagsNodes + * @returns {array} + */ +export const getPreviousEventTags = (timelineEventTagsNodes = []) => + timelineEventTagsNodes.map(({ name }) => name); 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..d0beb0f39b3 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue @@ -0,0 +1,47 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + i18n: { + convertToTask: s__('WorkItem|Convert to task'), + delete: __('Delete'), + taskActions: s__('WorkItem|Task actions'), + }, + components: { + GlDropdown, + GlDropdownItem, + }, + inject: ['canUpdate', 'toggleClass'], + methods: { + convertToTask() { + eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos); + }, + 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 v-if="canUpdate" @click="convertToTask"> + {{ $options.i18n.convertToTask }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canUpdate" variant="danger" @click="deleteTaskListItem"> + {{ $options.i18n.delete }} + </gl-dropdown-item> + </gl-dropdown> +</template> |