diff options
Diffstat (limited to 'app/assets/javascripts/issues/show')
10 files changed, 322 insertions, 41 deletions
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index daa1632c4aa..892c631f8ea 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -23,6 +23,7 @@ import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import animateMixin from '../mixins/animate'; import { convertDescriptionWithNewSort } from '../utils'; @@ -135,9 +136,6 @@ export default { this.$nextTick(() => { this.renderGFM(); - if (this.workItemsEnabled) { - this.renderTaskActions(); - } }); }, taskStatus() { @@ -148,10 +146,6 @@ export default { this.renderGFM(); this.updateTaskStatusText(); - if (this.workItemsEnabled) { - this.renderTaskActions(); - } - if (this.workItemId) { const taskLink = this.$el.querySelector( `.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`, @@ -178,15 +172,20 @@ export default { onError: this.taskListUpdateError.bind(this), }); - if (this.issuableType === IssuableType.Issue) { - this.renderSortableLists(); + this.removeAllPointerEventListeners(); + + this.renderSortableLists(); + + if (this.workItemsEnabled) { + this.renderTaskActions(); } } }, renderSortableLists() { - this.removeAllPointerEventListeners(); - - const lists = document.querySelectorAll('.description ul, .description ol'); + // We exclude GLFM table of contents which have a `section-nav` class on the root `ul`. + const lists = document.querySelectorAll( + '.description .md > ul:not(.section-nav), .description .md > ul:not(.section-nav) ul, .description ol', + ); lists.forEach((list) => { if (list.children.length <= 1) { return; @@ -194,7 +193,7 @@ export default { Array.from(list.children).forEach((listItem) => { listItem.prepend(this.createDragIconElement()); - this.addPointerEventListeners(listItem); + this.addPointerEventListeners(listItem, '.drag-icon'); }); Sortable.create( @@ -216,20 +215,20 @@ export default { </svg>`; return container.firstChild; }, - addPointerEventListeners(listItem) { + addPointerEventListeners(listItem, iconSelector) { const pointeroverListener = (event) => { - const dragIcon = event.target.closest('li').querySelector('.drag-icon'); - if (!dragIcon || isDragging() || this.isUpdating) { + const icon = event.target.closest('li').querySelector(iconSelector); + if (!icon || isDragging() || this.isUpdating) { return; } - dragIcon.style.visibility = 'visible'; + icon.style.visibility = 'visible'; }; const pointeroutListener = (event) => { - const dragIcon = event.target.closest('li').querySelector('.drag-icon'); - if (!dragIcon) { + const icon = event.target.closest('li').querySelector(iconSelector); + if (!icon) { return; } - dragIcon.style.visibility = 'hidden'; + icon.style.visibility = 'hidden'; }; // We use pointerover/pointerout instead of CSS so that when we hover over a @@ -238,10 +237,16 @@ export default { listItem.addEventListener('pointerout', pointeroutListener); this.pointerEventListeners = this.pointerEventListeners || new Map(); - this.pointerEventListeners.set(listItem, [ + const events = [ { type: 'pointerover', listener: pointeroverListener }, { type: 'pointerout', listener: pointeroutListener }, - ]); + ]; + if (this.pointerEventListeners.has(listItem)) { + const concatenatedEvents = this.pointerEventListeners.get(listItem).concat(events); + this.pointerEventListeners.set(listItem, concatenatedEvents); + } else { + this.pointerEventListeners.set(listItem, events); + } }, removeAllPointerEventListeners() { this.pointerEventListeners?.forEach((events, listItem) => { @@ -311,13 +316,14 @@ export default { this.workItemId = workItemId; this.updateWorkItemIdUrlQuery(issue); this.track('viewed_work_item_from_modal', { - category: 'workItems:show', + category: TRACKING_CATEGORY_SHOW, label: 'work_item_view', property: `type_${referenceType}`, }); }); return; } + this.addPointerEventListeners(item, '.js-add-task'); const button = document.createElement('button'); button.classList.add( 'btn', @@ -325,6 +331,7 @@ export default { 'btn-md', 'gl-button', 'btn-default-tertiary', + 'gl-visibility-hidden', 'gl-p-0!', 'gl-mt-n1', 'gl-ml-3', @@ -339,7 +346,7 @@ export default { `; button.setAttribute('aria-label', s__('WorkItem|Convert to work item')); button.addEventListener('click', () => this.openCreateTaskModal(button)); - item.append(button); + this.insertButtonNextToTaskText(item, button); }); }, addHoverListeners(taskLink, id) { @@ -355,9 +362,24 @@ export default { } }); }, + insertButtonNextToTaskText(listItem, button) { + const paragraph = Array.from(listItem.children).find((element) => element.tagName === 'P'); + const lastChild = listItem.lastElementChild; + 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') { + // Otherwise, the task item can have a child list which exists directly after the task text + lastChild.insertAdjacentElement('beforebegin', button); + } else { + // Otherwise, the task item is a simple one where the task text exists as the last child + listItem.append(button); + } + }, setActiveTask(el) { const { parentElement } = el; - const lineNumbers = parentElement.getAttribute('data-sourcepos').match(/\b\d+(?=:)/g); + const lineNumbers = parentElement.dataset.sourcepos.match(/\b\d+(?=:)/g); this.activeTask = { title: parentElement.innerText, lineNumberStart: lineNumbers[0], @@ -431,13 +453,7 @@ export default { > </textarea> - <gl-modal - ref="modal" - modal-id="create-task-modal" - :title="s__('WorkItem|New Task')" - hide-footer - body-class="gl-p-0!" - > + <gl-modal ref="modal" size="lg" modal-id="create-task-modal" hide-footer body-class="gl-p-0!"> <create-work-item is-modal :initial-title="activeTask.title" diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue index 4daf6f2b61b..9b31014c1ba 100644 --- a/app/assets/javascripts/issues/show/components/edit_actions.vue +++ b/app/assets/javascripts/issues/show/components/edit_actions.vue @@ -77,7 +77,7 @@ export default { return this.formState.title.trim() !== ''; }, shouldShowDeleteButton() { - return this.canDestroy && this.showDeleteButton; + return this.canDestroy && this.showDeleteButton && this.typeToShow; }, typeToShow() { const { issueState, issuableType } = this; diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql new file mode 100644 index 00000000000..7e049d98c1a --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql @@ -0,0 +1,21 @@ +query GetTimelineEvents($fullPath: ID!, $incidentId: IssueID!) { + project(fullPath: $fullPath) { + id + incidentManagementTimelineEvents(incidentId: $incidentId) { + nodes { + id + author { + id + name + username + } + note + noteHtml + action + occurredAt + createdAt + updatedAt + } + } + } +} 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 ea0e15adfed..6fdce6045f2 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -9,6 +9,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DescriptionComponent from '../description.vue'; import getAlert from './graphql/queries/get_alert.graphql'; import HighlightBar from './highlight_bar.vue'; +import TimelineTab from './timeline_events_tab.vue'; export default { components: { @@ -17,8 +18,7 @@ export default { GlTab, GlTabs, HighlightBar, - TimelineTab: () => - import('ee_component/issues/show/components/incidents/timeline_events_tab.vue'), + TimelineTab, IncidentMetricTab: () => import('ee_component/issues/show/components/incidents/incident_metric_tab.vue'), }, @@ -53,7 +53,7 @@ export default { return this.$apollo.queries.alert.loading; }, incidentTabEnabled() { - return this.glFeatures.incidentTimelineEvents && this.glFeatures.incidentTimeline; + return this.glFeatures.incidentTimeline; }, }, mounted() { @@ -65,17 +65,26 @@ export default { Tracking.event(category, action); }, handleTabChange(tabIndex) { + /** + * TODO: Implement a solution that does not violate Vue principles in using + * DOM manipulation directly (#361618) + */ const parent = document.querySelector('.js-issue-details'); if (parent !== null) { const itemsToHide = parent.querySelectorAll('.js-issue-widgets'); const lineSeparator = parent.querySelector('.js-detail-page-description'); + const editButton = document.querySelector('.js-issuable-edit'); + const isSummaryTab = tabIndex === 0; - lineSeparator.classList.toggle('gl-border-b-0', tabIndex > 0); + lineSeparator.classList.toggle('gl-border-b-0', !isSummaryTab); itemsToHide.forEach(function hide(item) { - item.classList.toggle('gl-display-none', tabIndex > 0); + item.classList.toggle('gl-display-none', !isSummaryTab); }); + + editButton.classList.toggle('gl-display-none', !isSummaryTab); + editButton.classList.toggle('gl-sm-display-inline-flex!', isSummaryTab); } }, }, @@ -103,7 +112,7 @@ export default { > <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> - <timeline-tab v-if="incidentTabEnabled" data-testid="timeline-events-tab" /> + <timeline-tab v-if="incidentTabEnabled" /> </gl-tabs> </div> </template> 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 new file mode 100644 index 00000000000..a6e58ee0bdc --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue @@ -0,0 +1,73 @@ +<script> +import { formatDate } from '~/lib/utils/datetime_utility'; +import IncidentTimelineEventListItem from './timeline_events_list_item.vue'; + +export default { + name: 'IncidentTimelineEventList', + components: { + IncidentTimelineEventListItem, + }, + props: { + timelineEventLoading: { + type: Boolean, + required: false, + default: true, + }, + timelineEvents: { + type: Array, + required: true, + default: () => [], + }, + }, + computed: { + dateGroupedEvents() { + const groupedEvents = new Map(); + + this.timelineEvents.forEach((event) => { + const date = formatDate(event.occurredAt, 'isoDate', true); + + if (groupedEvents.has(date)) { + groupedEvents.get(date).push(event); + } else { + groupedEvents.set(date, [event]); + } + }); + + return groupedEvents; + }, + }, + methods: { + isLastItem(groups, groupIndex, events, eventIndex) { + if (groupIndex < groups.size - 1) { + return false; + } + return eventIndex === events.length - 1; + }, + }, +}; +</script> + +<template> + <div class="issuable-discussion incident-timeline-events"> + <div + v-for="([eventDate, events], groupIndex) in dateGroupedEvents" + :key="eventDate" + data-testid="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> + <ul class="notes main-notes-list gl-pl-n3"> + <incident-timeline-event-list-item + v-for="(event, eventIndex) in events" + :key="event.id" + :action="event.action" + :occurred-at="event.occurredAt" + :note-html="event.noteHtml" + :is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)" + data-testid="timeline-event" + /> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue new file mode 100644 index 00000000000..fef9bf713b7 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue @@ -0,0 +1,71 @@ +<script> +import { GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; +import { getEventIcon } from './utils'; + +export default { + name: 'IncidentTimelineEventListItem', + i18n: { + timeUTC: __('%{time} UTC'), + }, + components: { + GlIcon, + GlSprintf, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + isLastItem: { + type: Boolean, + required: true, + }, + occurredAt: { + type: String, + required: true, + }, + action: { + type: String, + required: true, + }, + noteHtml: { + type: String, + required: true, + }, + }, + computed: { + time() { + return formatDate(this.occurredAt, 'HH:MM', true); + }, + }, + methods: { + getEventIcon, + }, +}; +</script> +<template> + <li + class="timeline-entry timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!" + > + <div class="gl-display-flex gl-align-items-center"> + <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-n2 gl-mr-3 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 gl-w-full" + :class="{ 'gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid': !isLastItem }" + data-testid="event-text-container" + > + <strong class="gl-font-lg" data-testid="event-time"> + <gl-sprintf :message="$options.i18n.timeUTC"> + <template #time>{{ time }}</template> + </gl-sprintf> + </strong> + <div v-safe-html="noteHtml"></div> + </div> + </div> + </li> +</template> 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 new file mode 100644 index 00000000000..400e1f0b725 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -0,0 +1,70 @@ +<script> +import { GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import { fetchPolicies } from '~/lib/graphql'; +import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql'; +import { displayAndLogError } from './utils'; + +import IncidentTimelineEventsList from './timeline_events_list.vue'; + +export default { + components: { + GlEmptyState, + GlLoadingIcon, + GlTab, + IncidentTimelineEventsList, + }, + inject: ['fullPath', 'issuableId'], + data() { + return { + timelineEvents: [], + }; + }, + apollo: { + timelineEvents: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query: getTimelineEvents, + variables() { + return { + fullPath: this.fullPath, + incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + }; + }, + update(data) { + return data.project.incidentManagementTimelineEvents.nodes; + }, + error(error) { + displayAndLogError(error); + }, + }, + }, + computed: { + timelineEventLoading() { + return this.$apollo.queries.timelineEvents.loading; + }, + hasTimelineEvents() { + return Boolean(this.timelineEvents.length); + }, + showEmptyState() { + return !this.timelineEventLoading && !this.hasTimelineEvents; + }, + }, +}; +</script> + +<template> + <gl-tab :title="s__('Incident|Timeline')"> + <gl-loading-icon v-if="timelineEventLoading" size="lg" color="dark" class="gl-mt-5" /> + <gl-empty-state + v-else-if="showEmptyState" + :compact="true" + :description="s__('Incident|No timeline items have been added yet.')" + /> + <incident-timeline-events-list + v-if="hasTimelineEvents" + :timeline-event-loading="timelineEventLoading" + :timeline-events="timelineEvents" + /> + </gl-tab> +</template> diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js new file mode 100644 index 00000000000..8b5a2ec4031 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/utils.js @@ -0,0 +1,18 @@ +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; + +export const displayAndLogError = (error) => + createAlert({ + message: s__('Incident|Something went wrong while fetching incident timeline events.'), + captureError: true, + error, + }); + +const EVENT_ICONS = { + comment: 'comment', + default: 'comment', +}; + +export const getEventIcon = (actionName) => { + return EVENT_ICONS[actionName] ?? EVENT_ICONS.default; +}; diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue index 1982147e454..7f67b31b122 100644 --- a/app/assets/javascripts/issues/show/components/title.vue +++ b/app/assets/javascripts/issues/show/components/title.vue @@ -74,7 +74,7 @@ export default { 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation, }" - class="title qa-title" + class="title qa-title gl-font-size-h-display" dir="auto" ></h1> <gl-button diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index 6b0b26ef2e3..5bdad010af7 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -33,6 +33,7 @@ export function initIncidentApp(issueData = {}) { canCreateIncident, canUpdate, iid, + issuableId, projectNamespace, projectPath, projectId, @@ -53,6 +54,7 @@ export function initIncidentApp(issueData = {}) { canUpdate, fullPath, iid, + issuableId, projectId, slaFeatureAvailable: parseBoolean(slaFeatureAvailable), uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable), @@ -83,7 +85,7 @@ export function initIssueApp(issueData, store) { bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); - const { canCreateIncident, ...issueProps } = issueData; + const { canCreateIncident, hasIssueWeightsFeature, ...issueProps } = issueData; return new Vue({ el, @@ -93,6 +95,7 @@ export function initIssueApp(issueData, store) { provide: { canCreateIncident, fullPath, + hasIssueWeightsFeature, }, computed: { ...mapGetters(['getNoteableData']), |