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>2021-12-20 16:37:47 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-12-20 16:37:47 +0300
commitaee0a117a889461ce8ced6fcf73207fe017f1d99 (patch)
tree891d9ef189227a8445d83f35c1b0fc99573f4380 /app/assets/javascripts/issues/show/components
parent8d46af3258650d305f53b819eabf7ab18d22f59e (diff)
Add latest changes from gitlab-org/gitlab@14-6-stable-eev14.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/issues/show/components')
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue558
-rw-r--r--app/assets/javascripts/issues/show/components/delete_issue_modal.vue71
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue169
-rw-r--r--app/assets/javascripts/issues/show/components/edit_actions.vue141
-rw-r--r--app/assets/javascripts/issues/show/components/edited.vue45
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue70
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description_template.vue111
-rw-r--r--app/assets/javascripts/issues/show/components/fields/title.vue33
-rw-r--r--app/assets/javascripts/issues/show/components/fields/type.vue96
-rw-r--r--app/assets/javascripts/issues/show/components/form.vue227
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue345
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql23
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue63
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue81
-rw-r--r--app/assets/javascripts/issues/show/components/locked_warning.vue33
-rw-r--r--app/assets/javascripts/issues/show/components/pinned_links.vue68
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue90
17 files changed, 2224 insertions, 0 deletions
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
new file mode 100644
index 00000000000..eeaf865a35f
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -0,0 +1,558 @@
+<script>
+import { GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
+import Visibility from 'visibilityjs';
+import createFlash from '~/flash';
+import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/constants';
+import Poll from '~/lib/utils/poll';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { __, sprintf } from '~/locale';
+import { IssueTypePath, IncidentTypePath, IncidentType, POLLING_DELAY } from '../constants';
+import eventHub from '../event_hub';
+import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
+import Service from '../services/index';
+import Store from '../stores';
+import descriptionComponent from './description.vue';
+import editedComponent from './edited.vue';
+import formComponent from './form.vue';
+import PinnedLinks from './pinned_links.vue';
+import titleComponent from './title.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlIntersectionObserver,
+ titleComponent,
+ editedComponent,
+ formComponent,
+ PinnedLinks,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ endpoint: {
+ required: true,
+ type: String,
+ },
+ updateEndpoint: {
+ required: true,
+ type: String,
+ },
+ canUpdate: {
+ required: true,
+ type: Boolean,
+ },
+ canDestroy: {
+ required: true,
+ type: Boolean,
+ },
+ showInlineEditButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showDeleteButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ zoomMeetingUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ publishedIncidentUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issuableRef: {
+ type: String,
+ required: true,
+ },
+ issuableStatus: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialTitleHtml: {
+ type: String,
+ required: true,
+ },
+ initialTitleText: {
+ type: String,
+ required: true,
+ },
+ initialDescriptionHtml: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialDescriptionText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialTaskStatus: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issuableTemplateNamesPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ isConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isLocked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ lockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ descriptionComponent: {
+ type: Object,
+ required: false,
+ default: () => {
+ return descriptionComponent;
+ },
+ },
+ showTitleBorder: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ isHidden: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ const store = new Store({
+ titleHtml: this.initialTitleHtml,
+ titleText: this.initialTitleText,
+ descriptionHtml: this.initialDescriptionHtml,
+ descriptionText: this.initialDescriptionText,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
+ taskStatus: this.initialTaskStatus,
+ lock_version: this.lockVersion,
+ });
+
+ return {
+ store,
+ state: store.state,
+ showForm: false,
+ templatesRequested: false,
+ isStickyHeaderShowing: false,
+ issueState: {},
+ };
+ },
+ apollo: {
+ issueState: {
+ query: getIssueStateQuery,
+ },
+ },
+ computed: {
+ issuableTemplates() {
+ return this.store.formState.issuableTemplates;
+ },
+ formState() {
+ return this.store.formState;
+ },
+ hasUpdated() {
+ return Boolean(this.state.updatedAt);
+ },
+ issueChanged() {
+ const {
+ store: {
+ formState: { description, title },
+ },
+ initialDescriptionText,
+ initialTitleText,
+ } = this;
+
+ if (initialDescriptionText || description) {
+ return initialDescriptionText !== description;
+ }
+
+ if (initialTitleText || title) {
+ return initialTitleText !== title;
+ }
+
+ return false;
+ },
+ defaultErrorMessage() {
+ return sprintf(__('Error updating %{issuableType}'), { issuableType: this.issuableType });
+ },
+ isClosed() {
+ return this.issuableStatus === IssuableStatus.Closed;
+ },
+ pinnedLinkClasses() {
+ return this.showTitleBorder
+ ? 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'
+ : '';
+ },
+ statusIcon() {
+ return this.isClosed ? 'issue-close' : 'issue-open-m';
+ },
+ statusText() {
+ return IssuableStatusText[this.issuableStatus];
+ },
+ shouldShowStickyHeader() {
+ return this.issuableType === IssuableType.Issue;
+ },
+ },
+ created() {
+ this.flashContainer = null;
+ this.service = new Service(this.endpoint);
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getData',
+ successCallback: (res) => this.store.updateState(res.data),
+ errorCallback(err) {
+ throw new Error(err);
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.poll.makeDelayedRequest(POLLING_DELAY);
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+
+ window.addEventListener('beforeunload', this.handleBeforeUnloadEvent);
+
+ eventHub.$on('update.issuable', this.updateIssuable);
+ eventHub.$on('close.form', this.closeForm);
+ eventHub.$on('open.form', this.openForm);
+ },
+ beforeDestroy() {
+ eventHub.$off('update.issuable', this.updateIssuable);
+ eventHub.$off('close.form', this.closeForm);
+ eventHub.$off('open.form', this.openForm);
+ window.removeEventListener('beforeunload', this.handleBeforeUnloadEvent);
+ },
+ methods: {
+ handleBeforeUnloadEvent(e) {
+ const event = e;
+ if (this.showForm && this.issueChanged && !this.issueState.isDirty) {
+ event.returnValue = __('Are you sure you want to lose your issue information?');
+ }
+ return undefined;
+ },
+
+ updateStoreState() {
+ return this.service
+ .getData()
+ .then((res) => res.data)
+ .then((data) => {
+ this.store.updateState(data);
+ })
+ .catch(() => {
+ createFlash({
+ message: this.defaultErrorMessage,
+ });
+ });
+ },
+
+ updateAndShowForm(templates = {}) {
+ if (!this.showForm) {
+ this.showForm = true;
+ this.store.setFormState({
+ title: this.state.titleText,
+ description: this.state.descriptionText,
+ lock_version: this.state.lock_version,
+ lockedWarningVisible: false,
+ updateLoading: false,
+ issuableTemplates: templates,
+ });
+ }
+ },
+
+ requestTemplatesAndShowForm() {
+ return this.service
+ .loadTemplates(this.issuableTemplateNamesPath)
+ .then((res) => {
+ this.updateAndShowForm(res.data);
+ })
+ .catch(() => {
+ createFlash({
+ message: this.defaultErrorMessage,
+ });
+ this.updateAndShowForm();
+ });
+ },
+
+ openForm() {
+ if (!this.templatesRequested) {
+ this.templatesRequested = true;
+ this.requestTemplatesAndShowForm();
+ } else {
+ this.updateAndShowForm(this.issuableTemplates);
+ }
+ },
+
+ closeForm() {
+ this.showForm = false;
+ },
+
+ updateIssuable() {
+ const {
+ store: { formState },
+ issueState,
+ } = this;
+ const issuablePayload = issueState.isDirty
+ ? { ...formState, issue_type: issueState.issueType }
+ : formState;
+ this.clearFlash();
+ return this.service
+ .updateIssuable(issuablePayload)
+ .then((res) => res.data)
+ .then((data) => {
+ if (
+ !window.location.pathname.includes(data.web_url) &&
+ issueState.issueType !== IncidentType
+ ) {
+ visitUrl(data.web_url);
+ }
+
+ if (issueState.isDirty) {
+ const URI =
+ issueState.issueType === IncidentType
+ ? data.web_url.replace(IssueTypePath, IncidentTypePath)
+ : data.web_url;
+ visitUrl(URI);
+ }
+ })
+ .then(this.updateStoreState)
+ .then(() => {
+ eventHub.$emit('close.form');
+ })
+ .catch((error = {}) => {
+ const { message, response = {} } = error;
+
+ this.store.setFormState({
+ updateLoading: false,
+ });
+
+ let errMsg = this.defaultErrorMessage;
+
+ if (response.data && response.data.errors) {
+ errMsg += `. ${response.data.errors.join(' ')}`;
+ } else if (message) {
+ errMsg += `. ${message}`;
+ }
+
+ this.flashContainer = createFlash({
+ message: errMsg,
+ });
+ });
+ },
+
+ hideStickyHeader() {
+ this.isStickyHeaderShowing = false;
+ },
+
+ showStickyHeader() {
+ this.isStickyHeaderShowing = true;
+ },
+
+ clearFlash() {
+ if (this.flashContainer) {
+ this.flashContainer.style.display = 'none';
+ this.flashContainer = null;
+ }
+ },
+
+ taskListUpdateStarted() {
+ this.poll.stop();
+ },
+
+ taskListUpdateSucceeded() {
+ this.poll.enable();
+ this.poll.makeDelayedRequest(POLLING_DELAY);
+ },
+
+ taskListUpdateFailed() {
+ this.poll.enable();
+ this.poll.makeDelayedRequest(POLLING_DELAY);
+
+ this.updateStoreState();
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div v-if="canUpdate && showForm">
+ <form-component
+ :endpoint="endpoint"
+ :form-state="formState"
+ :initial-description-text="initialDescriptionText"
+ :can-destroy="canDestroy"
+ :issuable-templates="issuableTemplates"
+ :markdown-docs-path="markdownDocsPath"
+ :markdown-preview-path="markdownPreviewPath"
+ :project-path="projectPath"
+ :project-id="projectId"
+ :project-namespace="projectNamespace"
+ :show-delete-button="showDeleteButton"
+ :can-attach-file="canAttachFile"
+ :enable-autocomplete="enableAutocomplete"
+ :issuable-type="issuableType"
+ />
+ </div>
+ <div v-else>
+ <title-component
+ :issuable-ref="issuableRef"
+ :can-update="canUpdate"
+ :title-html="state.titleHtml"
+ :title-text="state.titleText"
+ :show-inline-edit-button="showInlineEditButton"
+ />
+
+ <gl-intersection-observer
+ v-if="shouldShowStickyHeader"
+ @appear="hideStickyHeader"
+ @disappear="showStickyHeader"
+ >
+ <transition name="issuable-header-slide">
+ <div
+ v-if="isStickyHeaderShowing"
+ class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3"
+ data-testid="issue-sticky-header"
+ >
+ <div
+ class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5"
+ >
+ <p
+ class="issuable-status-box status-box gl-my-0"
+ :class="[isClosed ? 'status-box-issue-closed' : 'status-box-open']"
+ >
+ <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" />
+ <span class="gl-display-none d-sm-block">{{ statusText }}</span>
+ </p>
+ <span v-if="isLocked" data-testid="locked" class="issuable-warning-icon">
+ <gl-icon name="lock" :aria-label="__('Locked')" />
+ </span>
+ <span v-if="isConfidential" data-testid="confidential" class="issuable-warning-icon">
+ <gl-icon name="eye-slash" :aria-label="__('Confidential')" />
+ </span>
+ <span
+ v-if="isHidden"
+ v-gl-tooltip
+ :title="__('This issue is hidden because its author has been banned')"
+ data-testid="hidden"
+ class="issuable-warning-icon"
+ >
+ <gl-icon name="spam" />
+ </span>
+ <p
+ class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
+ :title="state.titleText"
+ >
+ {{ state.titleText }}
+ </p>
+ </div>
+ </div>
+ </transition>
+ </gl-intersection-observer>
+
+ <pinned-links
+ :zoom-meeting-url="zoomMeetingUrl"
+ :published-incident-url="publishedIncidentUrl"
+ :class="pinnedLinkClasses"
+ />
+
+ <component
+ :is="descriptionComponent"
+ :can-update="canUpdate"
+ :description-html="state.descriptionHtml"
+ :description-text="state.descriptionText"
+ :updated-at="state.updatedAt"
+ :task-status="state.taskStatus"
+ :issuable-type="issuableType"
+ :update-url="updateEndpoint"
+ :lock-version="state.lock_version"
+ @taskListUpdateStarted="taskListUpdateStarted"
+ @taskListUpdateSucceeded="taskListUpdateSucceeded"
+ @taskListUpdateFailed="taskListUpdateFailed"
+ />
+
+ <edited-component
+ v-if="hasUpdated"
+ :updated-at="state.updatedAt"
+ :updated-by-name="state.updatedByName"
+ :updated-by-path="state.updatedByPath"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
new file mode 100644
index 00000000000..26862346b86
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { __, sprintf } from '~/locale';
+
+export default {
+ actionCancel: { text: __('Cancel') },
+ csrf,
+ components: {
+ GlModal,
+ },
+ props: {
+ issuePath: {
+ type: String,
+ required: true,
+ },
+ issueType: {
+ type: String,
+ required: true,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ actionPrimary() {
+ return {
+ attributes: { variant: 'danger' },
+ text: this.title,
+ };
+ },
+ bodyText() {
+ return this.issueType.toLowerCase() === 'epic'
+ ? __('Delete this epic and all descendants?')
+ : sprintf(__('%{issuableType} will be removed! Are you sure?'), {
+ issuableType: capitalizeFirstCharacter(this.issueType),
+ });
+ },
+ },
+ methods: {
+ submitForm() {
+ this.$emit('delete');
+ this.$refs.form.submit();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ :action-cancel="$options.actionCancel"
+ :action-primary="actionPrimary"
+ :modal-id="modalId"
+ size="sm"
+ :title="title"
+ @primary="submitForm"
+ >
+ <form ref="form" :action="issuePath" method="post">
+ <input type="hidden" name="_method" value="delete" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ <input type="hidden" name="destroy_confirm" value="true" />
+ {{ bodyText }}
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
new file mode 100644
index 00000000000..7be4c13f544
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -0,0 +1,169 @@
+<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import $ from 'jquery';
+import createFlash from '~/flash';
+import { __, sprintf } from '~/locale';
+import TaskList from '~/task_list';
+import animateMixin from '../mixins/animate';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+
+ mixins: [animateMixin],
+
+ props: {
+ canUpdate: {
+ type: Boolean,
+ required: true,
+ },
+ descriptionHtml: {
+ type: String,
+ required: true,
+ },
+ descriptionText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskStatus: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ updateUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ lockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ data() {
+ return {
+ preAnimation: false,
+ pulseAnimation: false,
+ initialUpdate: true,
+ };
+ },
+ watch: {
+ descriptionHtml(newDescription, oldDescription) {
+ if (!this.initialUpdate && newDescription !== oldDescription) {
+ this.animateChange();
+ } else {
+ this.initialUpdate = false;
+ }
+
+ this.$nextTick(() => {
+ this.renderGFM();
+ });
+ },
+ taskStatus() {
+ this.updateTaskStatusText();
+ },
+ },
+ mounted() {
+ this.renderGFM();
+ this.updateTaskStatusText();
+ },
+ methods: {
+ renderGFM() {
+ $(this.$refs['gfm-content']).renderGFM();
+
+ if (this.canUpdate) {
+ // eslint-disable-next-line no-new
+ new TaskList({
+ dataType: this.issuableType,
+ fieldName: 'description',
+ lockVersion: this.lockVersion,
+ selector: '.detail-page-description',
+ onUpdate: this.taskListUpdateStarted.bind(this),
+ onSuccess: this.taskListUpdateSuccess.bind(this),
+ onError: this.taskListUpdateError.bind(this),
+ });
+ }
+ },
+
+ taskListUpdateStarted() {
+ this.$emit('taskListUpdateStarted');
+ },
+
+ taskListUpdateSuccess() {
+ this.$emit('taskListUpdateSucceeded');
+ },
+
+ taskListUpdateError() {
+ createFlash({
+ message: sprintf(
+ __(
+ 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.',
+ ),
+ {
+ issueType: this.issuableType,
+ },
+ ),
+ });
+
+ this.$emit('taskListUpdateFailed');
+ },
+
+ updateTaskStatusText() {
+ const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
+ const $issuableHeader = $('.issuable-meta');
+ const $tasks = $('#task_status', $issuableHeader);
+ const $tasksShort = $('#task_status_short', $issuableHeader);
+
+ if (taskRegexMatches) {
+ $tasks.text(this.taskStatus);
+ $tasksShort.text(
+ `${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`,
+ );
+ } else {
+ $tasks.text('');
+ $tasksShort.text('');
+ }
+ },
+ },
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] },
+};
+</script>
+
+<template>
+ <div
+ v-if="descriptionHtml"
+ :class="{
+ 'js-task-list-container': canUpdate,
+ }"
+ class="description"
+ >
+ <div
+ ref="gfm-content"
+ v-safe-html:[$options.safeHtmlConfig]="descriptionHtml"
+ :class="{
+ 'issue-realtime-pre-pulse': preAnimation,
+ 'issue-realtime-trigger-pulse': pulseAnimation,
+ }"
+ class="md"
+ ></div>
+ <!-- eslint-disable vue/no-mutating-props -->
+ <textarea
+ v-if="descriptionText"
+ ref="textarea"
+ v-model="descriptionText"
+ :data-update-url="updateUrl"
+ class="hidden js-task-list-field"
+ dir="auto"
+ >
+ </textarea>
+ <!-- eslint-enable vue/no-mutating-props -->
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue
new file mode 100644
index 00000000000..4daf6f2b61b
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/edit_actions.vue
@@ -0,0 +1,141 @@
+<script>
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { __, sprintf } from '~/locale';
+import Tracking from '~/tracking';
+import eventHub from '../event_hub';
+import updateMixin from '../mixins/update';
+import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
+import DeleteIssueModal from './delete_issue_modal.vue';
+
+const issuableTypes = {
+ issue: __('Issue'),
+ epic: __('Epic'),
+ incident: __('Incident'),
+};
+
+const trackingMixin = Tracking.mixin({ label: 'delete_issue' });
+
+export default {
+ components: {
+ DeleteIssueModal,
+ GlButton,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ mixins: [trackingMixin, updateMixin],
+ props: {
+ canDestroy: {
+ type: Boolean,
+ required: true,
+ },
+ endpoint: {
+ required: true,
+ type: String,
+ },
+ formState: {
+ type: Object,
+ required: true,
+ },
+ showDeleteButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ deleteLoading: false,
+ skipApollo: false,
+ issueState: {},
+ modalId: uniqueId('delete-issuable-modal-'),
+ };
+ },
+ apollo: {
+ issueState: {
+ query: getIssueStateQuery,
+ skip() {
+ return this.skipApollo;
+ },
+ result() {
+ this.skipApollo = true;
+ },
+ },
+ },
+ computed: {
+ deleteIssuableButtonText() {
+ return sprintf(__('Delete %{issuableType}'), {
+ issuableType: this.typeToShow.toLowerCase(),
+ });
+ },
+ isSubmitEnabled() {
+ return this.formState.title.trim() !== '';
+ },
+ shouldShowDeleteButton() {
+ return this.canDestroy && this.showDeleteButton;
+ },
+ typeToShow() {
+ const { issueState, issuableType } = this;
+ const type = issueState.issueType ?? issuableType;
+ return issuableTypes[type];
+ },
+ },
+ methods: {
+ closeForm() {
+ eventHub.$emit('close.form');
+ },
+ deleteIssuable() {
+ this.deleteLoading = true;
+ eventHub.$emit('delete.issuable');
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between">
+ <div>
+ <gl-button
+ :loading="formState.updateLoading"
+ :disabled="formState.updateLoading || !isSubmitEnabled"
+ category="primary"
+ variant="confirm"
+ class="qa-save-button gl-mr-3"
+ data-testid="issuable-save-button"
+ type="submit"
+ @click.prevent="updateIssuable"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ <gl-button data-testid="issuable-cancel-button" @click="closeForm">
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ <div v-if="shouldShowDeleteButton">
+ <gl-button
+ v-gl-modal="modalId"
+ :loading="deleteLoading"
+ :disabled="deleteLoading"
+ category="secondary"
+ variant="danger"
+ class="qa-delete-button"
+ data-testid="issuable-delete-button"
+ @click="track('click_button')"
+ >
+ {{ deleteIssuableButtonText }}
+ </gl-button>
+ <delete-issue-modal
+ :issue-path="endpoint"
+ :issue-type="typeToShow"
+ :modal-id="modalId"
+ :title="deleteIssuableButtonText"
+ @delete="deleteIssuable"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue
new file mode 100644
index 00000000000..0da1900a6d0
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/edited.vue
@@ -0,0 +1,45 @@
+<script>
+/* eslint-disable @gitlab/vue-require-i18n-strings */
+import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ timeAgoTooltip,
+ },
+ props: {
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ hasUpdatedBy() {
+ return this.updatedByName && this.updatedByPath;
+ },
+ },
+};
+</script>
+
+<template>
+ <small class="edited-text">
+ Edited
+ <time-ago-tooltip v-if="updatedAt" :time="updatedAt" tooltip-placement="bottom" />
+ <span v-if="hasUpdatedBy">
+ by
+ <a :href="updatedByPath" class="author-link">
+ <span>{{ updatedByName }}</span>
+ </a>
+ </span>
+ </small>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
new file mode 100644
index 00000000000..5476a1ef897
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -0,0 +1,70 @@
+<script>
+import markdownField from '~/vue_shared/components/markdown/field.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import updateMixin from '../../mixins/update';
+
+export default {
+ components: {
+ markdownField,
+ },
+ mixins: [glFeatureFlagsMixin(), updateMixin],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ mounted() {
+ this.$refs.textarea.focus();
+ },
+};
+</script>
+
+<template>
+ <div class="common-note-form">
+ <label class="sr-only" for="issue-description">{{ __('Description') }}</label>
+ <markdown-field
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ :can-attach-file="canAttachFile"
+ :enable-autocomplete="enableAutocomplete"
+ :textarea-value="formState.description"
+ >
+ <template #textarea>
+ <!-- eslint-disable vue/no-mutating-props -->
+ <textarea
+ id="issue-description"
+ ref="textarea"
+ v-model="formState.description"
+ class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
+ dir="auto"
+ :data-supports-quick-actions="!glFeatures.tributeAutocomplete"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
+ @keydown.meta.enter="updateIssuable"
+ @keydown.ctrl.enter="updateIssuable"
+ >
+ </textarea>
+ <!-- eslint-enable vue/no-mutating-props -->
+ </template>
+ </markdown-field>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue
new file mode 100644
index 00000000000..9ce49b65a1a
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import $ from 'jquery';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ issuableTemplates: {
+ type: [Object, Array],
+ required: false,
+ default: () => ({}),
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ issuableTemplatesJson() {
+ return JSON.stringify(this.issuableTemplates);
+ },
+ },
+ mounted() {
+ // Create the editor for the template
+ const editor = document.querySelector('.detail-page-description .note-textarea') || {};
+ editor.setValue = (val) => {
+ // eslint-disable-next-line vue/no-mutating-props
+ this.formState.description = val;
+ };
+ editor.getValue = () => this.formState.description;
+
+ this.issuableTemplate = new IssuableTemplateSelectors({
+ $dropdowns: $(this.$refs.toggle),
+ editor,
+ });
+ },
+};
+</script>
+
+<template>
+ <!-- eslint-disable @gitlab/vue-no-data-toggle -->
+ <div class="dropdown js-issuable-selector-wrap gl-mb-0" data-issuable-type="issues">
+ <button
+ ref="toggle"
+ :data-namespace-path="projectNamespace"
+ :data-project-path="projectPath"
+ :data-project-id="projectId"
+ :data-data="issuableTemplatesJson"
+ class="dropdown-menu-toggle js-issuable-selector gl-button"
+ type="button"
+ data-field-name="issuable_template"
+ data-selected="null"
+ data-toggle="dropdown"
+ >
+ <span class="dropdown-toggle-text">{{ __('Choose a template') }}</span>
+ <gl-icon name="chevron-down" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" />
+ </button>
+ <div class="dropdown-menu dropdown-select">
+ <div class="dropdown-title gl-display-flex gl-justify-content-center">
+ <span class="gl-ml-auto">{{ __('Choose a template') }}</span>
+ <button
+ class="dropdown-title-button dropdown-menu-close gl-ml-auto"
+ :aria-label="__('Close')"
+ type="button"
+ >
+ <gl-icon name="close" class="dropdown-menu-close-icon" />
+ </button>
+ </div>
+ <div class="dropdown-input">
+ <input
+ type="search"
+ class="dropdown-input-field"
+ :placeholder="__('Filter')"
+ autocomplete="off"
+ />
+ <gl-icon name="search" class="dropdown-input-search" />
+ <gl-icon
+ name="close"
+ class="dropdown-input-clear js-dropdown-input-clear"
+ :aria-label="__('Clear templates search input')"
+ />
+ </div>
+ <div class="dropdown-content"></div>
+ <div class="dropdown-footer">
+ <ul class="dropdown-footer-list">
+ <li>
+ <a class="no-template">{{ __('No template') }}</a>
+ </li>
+ <li>
+ <a class="reset-template">{{ __('Reset template') }}</a>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue
new file mode 100644
index 00000000000..a73926575d0
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/fields/title.vue
@@ -0,0 +1,33 @@
+<script>
+import updateMixin from '../../mixins/update';
+
+export default {
+ mixins: [updateMixin],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <fieldset>
+ <label class="sr-only" for="issuable-title">{{ __('Title') }}</label>
+ <!-- eslint-disable vue/no-mutating-props -->
+ <input
+ id="issuable-title"
+ ref="input"
+ v-model="formState.title"
+ class="form-control qa-title-input gl-border-gray-200"
+ dir="auto"
+ type="text"
+ :placeholder="__('Title')"
+ :aria-label="__('Title')"
+ @keydown.meta.enter="updateIssuable"
+ @keydown.ctrl.enter="updateIssuable"
+ />
+ <!-- eslint-enable vue/no-mutating-props -->
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue
new file mode 100644
index 00000000000..9110a6924b4
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/fields/type.vue
@@ -0,0 +1,96 @@
+<script>
+import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
+import { capitalize } from 'lodash';
+import { __ } from '~/locale';
+import { IssuableTypes, IncidentType } from '../../constants';
+import getIssueStateQuery from '../../queries/get_issue_state.query.graphql';
+import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql';
+
+export const i18n = {
+ label: __('Issue Type'),
+};
+
+export default {
+ i18n,
+ IssuableTypes,
+ components: {
+ GlFormGroup,
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ inject: {
+ canCreateIncident: {
+ default: false,
+ },
+ issueType: {
+ default: 'issue',
+ },
+ },
+ data() {
+ return {
+ issueState: {},
+ };
+ },
+ apollo: {
+ issueState: {
+ query: getIssueStateQuery,
+ },
+ },
+ computed: {
+ dropdownText() {
+ const {
+ issueState: { issueType },
+ } = this;
+ return capitalize(issueType);
+ },
+ shouldShowIncident() {
+ return this.issueType === IncidentType || this.canCreateIncident;
+ },
+ },
+ methods: {
+ updateIssueType(issueType) {
+ this.$apollo.mutate({
+ mutation: updateIssueStateMutation,
+ variables: {
+ issueType,
+ isDirty: true,
+ },
+ });
+ },
+ isShown(type) {
+ return type.value !== IncidentType || this.shouldShowIncident;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ :label="$options.i18n.label"
+ label-class="sr-only"
+ label-for="issuable-type"
+ class="mb-2 mb-md-0"
+ >
+ <gl-dropdown
+ id="issuable-type"
+ :aria-labelledby="$options.i18n.label"
+ :text="dropdownText"
+ :header-text="$options.i18n.label"
+ class="gl-w-full"
+ toggle-class="dropdown-menu-toggle"
+ >
+ <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>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue
new file mode 100644
index 00000000000..6447ec85b4e
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -0,0 +1,227 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import $ from 'jquery';
+import Autosave from '~/autosave';
+import { IssuableType } from '~/issues/constants';
+import eventHub from '../event_hub';
+import EditActions from './edit_actions.vue';
+import DescriptionField from './fields/description.vue';
+import DescriptionTemplateField from './fields/description_template.vue';
+import IssuableTitleField from './fields/title.vue';
+import IssuableTypeField from './fields/type.vue';
+import LockedWarning from './locked_warning.vue';
+
+export default {
+ components: {
+ DescriptionField,
+ DescriptionTemplateField,
+ EditActions,
+ GlAlert,
+ IssuableTitleField,
+ IssuableTypeField,
+ LockedWarning,
+ },
+ props: {
+ canDestroy: {
+ type: Boolean,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ formState: {
+ type: Object,
+ required: true,
+ },
+ issuableTemplates: {
+ type: [Object, Array],
+ required: false,
+ default: () => [],
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ showDeleteButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ initialDescriptionText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ showOutdatedDescriptionWarning: false,
+ };
+ },
+ computed: {
+ hasIssuableTemplates() {
+ return Object.values(Object(this.issuableTemplates)).length;
+ },
+ showLockedWarning() {
+ return this.formState.lockedWarningVisible && !this.formState.updateLoading;
+ },
+ isIssueType() {
+ return this.issuableType === IssuableType.Issue;
+ },
+ },
+ created() {
+ eventHub.$on('delete.issuable', this.resetAutosave);
+ eventHub.$on('update.issuable', this.resetAutosave);
+ eventHub.$on('close.form', this.resetAutosave);
+ },
+ mounted() {
+ this.initAutosave();
+ },
+ beforeDestroy() {
+ eventHub.$off('delete.issuable', this.resetAutosave);
+ eventHub.$off('update.issuable', this.resetAutosave);
+ eventHub.$off('close.form', this.resetAutosave);
+ },
+ methods: {
+ initAutosave() {
+ const {
+ description: {
+ $refs: { textarea },
+ },
+ title: {
+ $refs: { input },
+ },
+ } = this.$refs;
+
+ this.autosaveDescription = new Autosave(
+ $(textarea),
+ [document.location.pathname, document.location.search, 'description'],
+ null,
+ this.formState.lock_version,
+ );
+
+ const savedLockVersion = this.autosaveDescription.getSavedLockVersion();
+
+ this.showOutdatedDescriptionWarning =
+ savedLockVersion && String(this.formState.lock_version) !== savedLockVersion;
+
+ this.autosaveTitle = new Autosave($(input), [
+ document.location.pathname,
+ document.location.search,
+ 'title',
+ ]);
+ },
+ resetAutosave() {
+ this.autosaveDescription.reset();
+ this.autosaveTitle.reset();
+ },
+ keepAutosave() {
+ const {
+ description: {
+ $refs: { textarea },
+ },
+ } = this.$refs;
+
+ textarea.focus();
+ this.showOutdatedDescriptionWarning = false;
+ },
+ discardAutosave() {
+ const {
+ description: {
+ $refs: { textarea },
+ },
+ } = this.$refs;
+
+ textarea.value = this.initialDescriptionText;
+ textarea.focus();
+ this.showOutdatedDescriptionWarning = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <form data-testid="issuable-form">
+ <locked-warning v-if="showLockedWarning" />
+ <gl-alert
+ v-if="showOutdatedDescriptionWarning"
+ class="gl-mb-5"
+ variant="warning"
+ :primary-button-text="__('Keep')"
+ :secondary-button-text="__('Discard')"
+ :dismissible="false"
+ @primaryAction="keepAutosave"
+ @secondaryAction="discardAutosave"
+ >{{
+ __(
+ 'The comment you are editing has been changed by another user. Would you like to keep your changes and overwrite the new description or discard your changes?',
+ )
+ }}</gl-alert
+ >
+ <div class="row gl-mb-3">
+ <div class="col-12">
+ <issuable-title-field ref="title" :form-state="formState" />
+ </div>
+ </div>
+ <div class="row">
+ <div v-if="isIssueType" class="col-12 col-md-4 pr-md-0">
+ <issuable-type-field ref="issue-type" />
+ </div>
+ <div v-if="hasIssuableTemplates" class="col-12 col-md-4 pl-md-2">
+ <description-template-field
+ :form-state="formState"
+ :issuable-templates="issuableTemplates"
+ :project-path="projectPath"
+ :project-id="projectId"
+ :project-namespace="projectNamespace"
+ />
+ </div>
+ </div>
+ <description-field
+ ref="description"
+ :form-state="formState"
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ :can-attach-file="canAttachFile"
+ :enable-autocomplete="enableAutocomplete"
+ />
+ <edit-actions
+ :endpoint="endpoint"
+ :form-state="formState"
+ :can-destroy="canDestroy"
+ :show-delete-button="showDeleteButton"
+ :issuable-type="issuableType"
+ />
+ </form>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
new file mode 100644
index 00000000000..700ef92a0f3
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -0,0 +1,345 @@
+<script>
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlLink,
+ GlModal,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import createFlash, { FLASH_TYPES } from '~/flash';
+import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
+import { IssuableType } from '~/vue_shared/issuable/show/constants';
+import { IssuableStatus } from '~/issues/constants';
+import { IssueStateEvent } from '~/issues/show/constants';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { s__, __, sprintf } from '~/locale';
+import eventHub from '~/notes/event_hub';
+import Tracking from '~/tracking';
+import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql';
+import updateIssueMutation from '../queries/update_issue.mutation.graphql';
+import DeleteIssueModal from './delete_issue_modal.vue';
+
+const trackingMixin = Tracking.mixin({ label: 'delete_issue' });
+
+export default {
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: __('Yes, close issue'),
+ },
+ deleteModalId: 'delete-modal-id',
+ i18n: {
+ promoteErrorMessage: __(
+ 'Something went wrong while promoting the issue to an epic. Please try again.',
+ ),
+ promoteSuccessMessage: __(
+ 'The issue was successfully promoted to an epic. Redirecting to epic...',
+ ),
+ },
+ components: {
+ DeleteIssueModal,
+ GlButton,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlLink,
+ GlModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ mixins: [trackingMixin],
+ inject: {
+ canCreateIssue: {
+ default: false,
+ },
+ canDestroyIssue: {
+ default: false,
+ },
+ canPromoteToEpic: {
+ default: false,
+ },
+ canReopenIssue: {
+ default: false,
+ },
+ canReportSpam: {
+ default: false,
+ },
+ canUpdateIssue: {
+ default: false,
+ },
+ iid: {
+ default: '',
+ },
+ isIssueAuthor: {
+ default: false,
+ },
+ issuePath: {
+ default: '',
+ },
+ issueType: {
+ default: IssuableType.Issue,
+ },
+ newIssuePath: {
+ default: '',
+ },
+ projectPath: {
+ default: '',
+ },
+ reportAbusePath: {
+ default: '',
+ },
+ submitAsSpamPath: {
+ default: '',
+ },
+ },
+ computed: {
+ ...mapState(['isToggleStateButtonLoading']),
+ ...mapGetters(['openState', 'getBlockedByIssues']),
+ isClosed() {
+ return this.openState === IssuableStatus.Closed;
+ },
+ issueTypeText() {
+ const issueTypeTexts = {
+ [IssuableType.Issue]: s__('HeaderAction|issue'),
+ [IssuableType.Incident]: s__('HeaderAction|incident'),
+ };
+
+ return issueTypeTexts[this.issueType] ?? this.issueType;
+ },
+ buttonText() {
+ return this.isClosed
+ ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueTypeText })
+ : sprintf(__('Close %{issueType}'), { issueType: this.issueTypeText });
+ },
+ deleteButtonText() {
+ return sprintf(__('Delete %{issuableType}'), { issuableType: this.issueTypeText });
+ },
+ qaSelector() {
+ return this.isClosed ? 'reopen_issue_button' : 'close_issue_button';
+ },
+ dropdownText() {
+ return sprintf(__('%{issueType} actions'), {
+ issueType: capitalizeFirstCharacter(this.issueType),
+ });
+ },
+ newIssueTypeText() {
+ return sprintf(__('New %{issueType}'), { issueType: this.issueType });
+ },
+ showToggleIssueStateButton() {
+ const canClose = !this.isClosed && this.canUpdateIssue;
+ const canReopen = this.isClosed && this.canReopenIssue;
+ return canClose || canReopen;
+ },
+ },
+ created() {
+ eventHub.$on('toggle.issuable.state', this.toggleIssueState);
+ },
+ beforeDestroy() {
+ eventHub.$off('toggle.issuable.state', this.toggleIssueState);
+ },
+ methods: {
+ ...mapActions(['toggleStateButtonLoading']),
+ toggleIssueState() {
+ if (!this.isClosed && this.getBlockedByIssues?.length) {
+ this.$refs.blockedByIssuesModal.show();
+ return;
+ }
+
+ this.invokeUpdateIssueMutation();
+ },
+ invokeUpdateIssueMutation() {
+ this.toggleStateButtonLoading(true);
+
+ this.$apollo
+ .mutate({
+ mutation: updateIssueMutation,
+ variables: {
+ input: {
+ iid: this.iid.toString(),
+ projectPath: this.projectPath,
+ stateEvent: this.isClosed ? IssueStateEvent.Reopen : IssueStateEvent.Close,
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.updateIssue.errors.length) {
+ throw new Error();
+ }
+
+ const payload = {
+ detail: {
+ data: { id: this.iid },
+ isClosed: !this.isClosed,
+ },
+ };
+
+ // Dispatch event which updates open/close state, shared among the issue show page
+ document.dispatchEvent(new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, payload));
+ })
+ .catch(() => createFlash({ message: __('Error occurred while updating the issue status') }))
+ .finally(() => {
+ this.toggleStateButtonLoading(false);
+ });
+ },
+ promoteToEpic() {
+ this.toggleStateButtonLoading(true);
+
+ this.$apollo
+ .mutate({
+ mutation: promoteToEpicMutation,
+ variables: {
+ input: {
+ iid: this.iid,
+ projectPath: this.projectPath,
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.promoteToEpic.errors.length) {
+ throw new Error();
+ }
+
+ createFlash({
+ message: this.$options.i18n.promoteSuccessMessage,
+ type: FLASH_TYPES.SUCCESS,
+ });
+
+ visitUrl(data.promoteToEpic.epic.webPath);
+ })
+ .catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage }))
+ .finally(() => {
+ this.toggleStateButtonLoading(false);
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="detail-page-header-actions gl-display-flex">
+ <gl-dropdown
+ class="gl-sm-display-none! w-100"
+ block
+ :text="dropdownText"
+ data-qa-selector="issue_actions_dropdown"
+ :loading="isToggleStateButtonLoading"
+ >
+ <gl-dropdown-item
+ v-if="showToggleIssueStateButton"
+ :data-qa-selector="`mobile_${qaSelector}`"
+ @click="toggleIssueState"
+ >
+ {{ buttonText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
+ {{ newIssueTypeText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="canPromoteToEpic" @click="promoteToEpic">
+ {{ __('Promote to epic') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
+ {{ __('Report abuse') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="canReportSpam"
+ :href="submitAsSpamPath"
+ data-method="post"
+ rel="nofollow"
+ >
+ {{ __('Submit as spam') }}
+ </gl-dropdown-item>
+ <template v-if="canDestroyIssue">
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ v-gl-modal="$options.deleteModalId"
+ variant="danger"
+ @click="track('click_dropdown')"
+ >
+ {{ deleteButtonText }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+
+ <gl-button
+ v-if="showToggleIssueStateButton"
+ class="gl-display-none gl-sm-display-inline-flex!"
+ :data-qa-selector="qaSelector"
+ :loading="isToggleStateButtonLoading"
+ @click="toggleIssueState"
+ >
+ {{ buttonText }}
+ </gl-button>
+
+ <gl-dropdown
+ class="gl-display-none gl-sm-display-inline-flex! gl-ml-3"
+ icon="ellipsis_v"
+ category="tertiary"
+ :text="dropdownText"
+ :text-sr-only="true"
+ no-caret
+ right
+ >
+ <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
+ {{ newIssueTypeText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="canPromoteToEpic"
+ :disabled="isToggleStateButtonLoading"
+ data-testid="promote-button"
+ @click="promoteToEpic"
+ >
+ {{ __('Promote to epic') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
+ {{ __('Report abuse') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="canReportSpam"
+ :href="submitAsSpamPath"
+ data-method="post"
+ rel="nofollow"
+ >
+ {{ __('Submit as spam') }}
+ </gl-dropdown-item>
+ <template v-if="canDestroyIssue">
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ v-gl-modal="$options.deleteModalId"
+ variant="danger"
+ @click="track('click_dropdown')"
+ >
+ {{ deleteButtonText }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+
+ <gl-modal
+ ref="blockedByIssuesModal"
+ modal-id="blocked-by-issues-modal"
+ :action-cancel="$options.actionCancel"
+ :action-primary="$options.actionPrimary"
+ :title="__('Are you sure you want to close this blocked issue?')"
+ @primary="invokeUpdateIssueMutation"
+ >
+ <p>{{ __('This issue is currently blocked by the following issues:') }}</p>
+ <ul>
+ <li v-for="issue in getBlockedByIssues" :key="issue.iid">
+ <gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link>
+ </li>
+ </ul>
+ </gl-modal>
+
+ <delete-issue-modal
+ :issue-path="issuePath"
+ :issue-type="issueType"
+ :modal-id="$options.deleteModalId"
+ :title="deleteButtonText"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql
new file mode 100644
index 00000000000..d88633f2ae9
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql
@@ -0,0 +1,23 @@
+query getAlert($iid: String!, $fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ issue(iid: $iid) {
+ id
+ alertManagementAlert {
+ iid
+ title
+ detailsUrl
+ severity
+ status
+ startedAt
+ eventCount
+ monitoringTool
+ service
+ description
+ endedAt
+ hosts
+ details
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue
new file mode 100644
index 00000000000..d509f0dbc09
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { formatDate } from '~/lib/utils/datetime_utility';
+
+export default {
+ components: {
+ GlLink,
+ IncidentSla: () => import('ee_component/issues/show/components/incidents/incident_sla.vue'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ alert: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return { childHasData: false };
+ },
+ computed: {
+ startTime() {
+ return formatDate(this.alert.startedAt, 'yyyy-mm-dd Z');
+ },
+ showHighlightBar() {
+ return this.alert || this.childHasData;
+ },
+ },
+ methods: {
+ update(hasData) {
+ this.childHasData = hasData;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-show="showHighlightBar"
+ class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column"
+ >
+ <div v-if="alert" class="gl-mr-3">
+ <span class="gl-font-weight-bold">{{ s__('HighlightBar|Original alert:') }}</span>
+ <gl-link v-gl-tooltip :title="alert.title" :href="alert.detailsUrl">
+ #{{ alert.iid }}
+ </gl-link>
+ </div>
+
+ <div v-if="alert" class="gl-mr-3">
+ <span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert start time:') }}</span>
+ {{ startTime }}
+ </div>
+
+ <div v-if="alert" class="gl-mr-3">
+ <span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert events:') }}</span>
+ <span>{{ alert.eventCount }}</span>
+ </div>
+
+ <incident-sla @update="update" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
new file mode 100644
index 00000000000..4790062ab7d
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlTab, GlTabs } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+import DescriptionComponent from '../description.vue';
+import getAlert from './graphql/queries/get_alert.graphql';
+import HighlightBar from './highlight_bar.vue';
+
+export default {
+ components: {
+ AlertDetailsTable,
+ DescriptionComponent,
+ GlTab,
+ GlTabs,
+ HighlightBar,
+ MetricsTab: () => import('ee_component/issues/show/components/incidents/metrics_tab.vue'),
+ },
+ inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'],
+ apollo: {
+ alert: {
+ query: getAlert,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update(data) {
+ return data?.project?.issue?.alertManagementAlert;
+ },
+ error() {
+ createFlash({
+ message: s__('Incident|There was an issue loading alert data. Please try again.'),
+ });
+ },
+ },
+ },
+ data() {
+ return {
+ alert: null,
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.alert.loading;
+ },
+ },
+ mounted() {
+ this.trackPageViews();
+ },
+ methods: {
+ trackPageViews() {
+ const { category, action } = trackIncidentDetailsViewsOptions;
+ Tracking.event(category, action);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-tabs content-class="gl-reset-line-height" class="gl-mt-n3" data-testid="incident-tabs">
+ <gl-tab :title="s__('Incident|Summary')">
+ <highlight-bar :alert="alert" />
+ <description-component v-bind="$attrs" />
+ </gl-tab>
+ <metrics-tab v-if="uploadMetricsFeatureAvailable" data-testid="metrics-tab" />
+ <gl-tab
+ v-if="alert"
+ class="alert-management-details"
+ :title="s__('Incident|Alert details')"
+ data-testid="alert-details-tab"
+ >
+ <alert-details-table :alert="alert" :loading="loading" />
+ </gl-tab>
+ </gl-tabs>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue
new file mode 100644
index 00000000000..4b99888ae73
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/locked_warning.vue
@@ -0,0 +1,33 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const alertMessage = __(
+ 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.',
+);
+
+export default {
+ alertMessage,
+ components: {
+ GlSprintf,
+ GlLink,
+ },
+ computed: {
+ currentPath() {
+ return window.location.pathname;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="alert alert-danger">
+ <gl-sprintf :message="$options.alertMessage">
+ <template #link="{ content }">
+ <gl-link :href="currentPath" target="_blank" rel="nofollow">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/pinned_links.vue b/app/assets/javascripts/issues/show/components/pinned_links.vue
new file mode 100644
index 00000000000..d38189307bd
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/pinned_links.vue
@@ -0,0 +1,68 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { STATUS_PAGE_PUBLISHED, JOIN_ZOOM_MEETING } from '../constants';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ zoomMeetingUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ publishedIncidentUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ pinnedLinks() {
+ const links = [];
+ if (this.publishedIncidentUrl) {
+ links.push({
+ id: 'publishedIncidentUrl',
+ url: this.publishedIncidentUrl,
+ text: STATUS_PAGE_PUBLISHED,
+ icon: 'tanuki',
+ });
+ }
+ if (this.zoomMeetingUrl) {
+ links.push({
+ id: 'zoomMeetingUrl',
+ url: this.zoomMeetingUrl,
+ text: JOIN_ZOOM_MEETING,
+ icon: 'brand-zoom',
+ });
+ }
+
+ return links;
+ },
+ },
+ methods: {
+ needsPaddingClass(i) {
+ return i < this.pinnedLinks.length - 1;
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="pinnedLinks && pinnedLinks.length" class="gl-display-flex gl-justify-content-start">
+ <template v-for="(link, i) in pinnedLinks">
+ <div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }">
+ <gl-button
+ :href="link.url"
+ target="_blank"
+ :icon="link.icon"
+ size="small"
+ class="gl-font-weight-bold gl-mb-5"
+ :data-testid="link.id"
+ >{{ link.text }}</gl-button
+ >
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue
new file mode 100644
index 00000000000..5e92211685a
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/title.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlButton, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { __ } from '~/locale';
+import eventHub from '../event_hub';
+import animateMixin from '../mixins/animate';
+
+export default {
+ i18n: {
+ editTitleAndDescription: __('Edit title and description'),
+ },
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml,
+ },
+ mixins: [animateMixin],
+ props: {
+ issuableRef: {
+ type: [String, Number],
+ required: true,
+ },
+ canUpdate: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
+ titleHtml: {
+ type: String,
+ required: true,
+ },
+ titleText: {
+ type: String,
+ required: true,
+ },
+ showInlineEditButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ preAnimation: false,
+ pulseAnimation: false,
+ titleEl: document.querySelector('title'),
+ };
+ },
+ watch: {
+ titleHtml() {
+ this.setPageTitle();
+ this.animateChange();
+ },
+ },
+ methods: {
+ setPageTitle() {
+ const currentPageTitleScope = this.titleEl.innerText.split('·');
+ currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
+ this.titleEl.textContent = currentPageTitleScope.join('·');
+ },
+ edit() {
+ eventHub.$emit('open.form');
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="title-container">
+ <h2
+ v-safe-html="titleHtml"
+ :class="{
+ 'issue-realtime-pre-pulse': preAnimation,
+ 'issue-realtime-trigger-pulse': pulseAnimation,
+ }"
+ class="title qa-title"
+ dir="auto"
+ ></h2>
+ <gl-button
+ v-if="showInlineEditButton && canUpdate"
+ v-gl-tooltip.bottom
+ icon="pencil"
+ class="btn-edit js-issuable-edit qa-edit-button"
+ :title="$options.i18n.editTitleAndDescription"
+ :aria-label="$options.i18n.editTitleAndDescription"
+ @click="edit"
+ />
+ </div>
+</template>