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:
Diffstat (limited to 'app/assets/javascripts/issues/show/components')
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue80
-rw-r--r--app/assets/javascripts/issues/show/components/edit_actions.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql21
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue21
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue73
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue71
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue70
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/utils.js18
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue2
9 files changed, 318 insertions, 40 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