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')
-rw-r--r--app/assets/javascripts/issues/constants.js25
-rw-r--r--app/assets/javascripts/issues/filtered_search_service_desk.js31
-rw-r--r--app/assets/javascripts/issues/form.js24
-rw-r--r--app/assets/javascripts/issues/init_filtered_search_service_desk.js11
-rw-r--r--app/assets/javascripts/issues/issue.js113
-rw-r--r--app/assets/javascripts/issues/manual_ordering.js61
-rw-r--r--app/assets/javascripts/issues/new/components/title_suggestions.vue94
-rw-r--r--app/assets/javascripts/issues/new/components/title_suggestions_item.vue132
-rw-r--r--app/assets/javascripts/issues/new/components/type_popover.vue41
-rw-r--r--app/assets/javascripts/issues/new/index.js56
-rw-r--r--app/assets/javascripts/issues/new/queries/issues.query.graphql29
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue120
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/index.js24
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/actions.js36
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/index.js14
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/mutations.js19
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/state.js7
-rw-r--r--app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue43
-rw-r--r--app/assets/javascripts/issues/sentry_error_stack_trace/index.js22
-rw-r--r--app/assets/javascripts/issues/show.js59
-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
-rw-r--r--app/assets/javascripts/issues/show/constants.js22
-rw-r--r--app/assets/javascripts/issues/show/event_hub.js3
-rw-r--r--app/assets/javascripts/issues/show/graphql.js9
-rw-r--r--app/assets/javascripts/issues/show/incident.js101
-rw-r--r--app/assets/javascripts/issues/show/issue.js86
-rw-r--r--app/assets/javascripts/issues/show/mixins/animate.js13
-rw-r--r--app/assets/javascripts/issues/show/mixins/update.js10
-rw-r--r--app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql3
-rw-r--r--app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql9
-rw-r--r--app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql9
-rw-r--r--app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql3
-rw-r--r--app/assets/javascripts/issues/show/services/index.js29
-rw-r--r--app/assets/javascripts/issues/show/stores/index.js46
-rw-r--r--app/assets/javascripts/issues/show/utils/parse_data.js23
-rw-r--r--app/assets/javascripts/issues/show/utils/update_description.js36
53 files changed, 3591 insertions, 0 deletions
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
new file mode 100644
index 00000000000..b7b123dfd5f
--- /dev/null
+++ b/app/assets/javascripts/issues/constants.js
@@ -0,0 +1,25 @@
+import { __ } from '~/locale';
+
+export const IssuableStatus = {
+ Closed: 'closed',
+ Open: 'opened',
+ Reopened: 'reopened',
+};
+
+export const IssuableStatusText = {
+ [IssuableStatus.Closed]: __('Closed'),
+ [IssuableStatus.Open]: __('Open'),
+ [IssuableStatus.Reopened]: __('Open'),
+};
+
+export const IssuableType = {
+ Issue: 'issue',
+ Epic: 'epic',
+ MergeRequest: 'merge_request',
+ Alert: 'alert',
+};
+
+export const WorkspaceType = {
+ project: 'project',
+ group: 'group',
+};
diff --git a/app/assets/javascripts/issues/filtered_search_service_desk.js b/app/assets/javascripts/issues/filtered_search_service_desk.js
new file mode 100644
index 00000000000..bec207aa439
--- /dev/null
+++ b/app/assets/javascripts/issues/filtered_search_service_desk.js
@@ -0,0 +1,31 @@
+/* eslint-disable class-methods-use-this */
+import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
+import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
+
+const AUTHOR_PARAM_KEY = 'author_username';
+
+export default class FilteredSearchServiceDesk extends FilteredSearchManager {
+ constructor(supportBotData) {
+ super({
+ page: 'service_desk',
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
+ useDefaultState: true,
+ });
+
+ this.supportBotData = supportBotData;
+ }
+
+ canEdit(tokenName) {
+ return tokenName !== 'author';
+ }
+
+ modifyUrlParams(paramsArray) {
+ const supportBotParamPair = `${AUTHOR_PARAM_KEY}=${this.supportBotData.username}`;
+ const onlyValidParams = paramsArray.filter((param) => param.indexOf(AUTHOR_PARAM_KEY) === -1);
+
+ // unshift ensures author param is always first token element
+ onlyValidParams.unshift(supportBotParamPair);
+
+ return onlyValidParams;
+ }
+}
diff --git a/app/assets/javascripts/issues/form.js b/app/assets/javascripts/issues/form.js
new file mode 100644
index 00000000000..33371d065f9
--- /dev/null
+++ b/app/assets/javascripts/issues/form.js
@@ -0,0 +1,24 @@
+/* eslint-disable no-new */
+
+import $ from 'jquery';
+import IssuableForm from 'ee_else_ce/issuable/issuable_form';
+import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
+import GLForm from '~/gl_form';
+import { initTitleSuggestions, initTypePopover } from '~/issues/new';
+import LabelsSelect from '~/labels/labels_select';
+import MilestoneSelect from '~/milestones/milestone_select';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
+
+export default () => {
+ new ShortcutsNavigation();
+ new GLForm($('.issue-form'));
+ new IssuableForm($('.issue-form'));
+ new LabelsSelect();
+ new MilestoneSelect();
+ new IssuableTemplateSelectors({
+ warnTemplateOverride: true,
+ });
+
+ initTitleSuggestions();
+ initTypePopover();
+};
diff --git a/app/assets/javascripts/issues/init_filtered_search_service_desk.js b/app/assets/javascripts/issues/init_filtered_search_service_desk.js
new file mode 100644
index 00000000000..1901802c11c
--- /dev/null
+++ b/app/assets/javascripts/issues/init_filtered_search_service_desk.js
@@ -0,0 +1,11 @@
+import FilteredSearchServiceDesk from './filtered_search_service_desk';
+
+export function initFilteredSearchServiceDesk() {
+ if (document.querySelector('.filtered-search')) {
+ const supportBotData = JSON.parse(
+ document.querySelector('.js-service-desk-issues').dataset.supportBot,
+ );
+ const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData);
+ filteredSearchManager.setup();
+ }
+}
diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js
new file mode 100644
index 00000000000..c471875654b
--- /dev/null
+++ b/app/assets/javascripts/issues/issue.js
@@ -0,0 +1,113 @@
+import $ from 'jquery';
+import { joinPaths } from '~/lib/utils/url_utility';
+import CreateMergeRequestDropdown from '~/create_merge_request_dropdown';
+import createFlash from '~/flash';
+import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
+import axios from '~/lib/utils/axios_utils';
+import { addDelimiter } from '~/lib/utils/text_utility';
+import { __ } from '~/locale';
+
+export default class Issue {
+ constructor() {
+ if ($('.js-alert-moved-from-service-desk-warning').length) {
+ Issue.initIssueMovedFromServiceDeskDismissHandler();
+ }
+
+ if (document.querySelector('#related-branches')) {
+ Issue.initRelatedBranches();
+ }
+
+ Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
+
+ if (Issue.createMrDropdownWrap) {
+ this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap);
+ }
+
+ // Listen to state changes in the Vue app
+ this.issuableVueAppChangeHandler = (event) =>
+ this.updateTopState(event.detail.isClosed, event.detail.data);
+ document.addEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, this.issuableVueAppChangeHandler);
+ }
+
+ dispose() {
+ document.removeEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, this.issuableVueAppChangeHandler);
+ }
+
+ /**
+ * This method updates the top area of the issue.
+ *
+ * Once the issue state changes, either through a click on the top area (jquery)
+ * or a click on the bottom area (Vue) we need to update the top area.
+ *
+ * @param {Boolean} isClosed
+ * @param {Array} data
+ * @param {String} issueFailMessage
+ */
+ updateTopState(
+ isClosed,
+ data,
+ issueFailMessage = __('Unable to update this issue at this time.'),
+ ) {
+ if ('id' in data) {
+ const isClosedBadge = $('div.status-box-issue-closed');
+ const isOpenBadge = $('div.status-box-open');
+ const projectIssuesCounter = $('.issue_counter');
+
+ isClosedBadge.toggleClass('hidden', !isClosed);
+ isOpenBadge.toggleClass('hidden', isClosed);
+
+ $(document).trigger('issuable:change', isClosed);
+
+ let numProjectIssues = Number(
+ projectIssuesCounter.first().text().trim().replace(/[^\d]/, ''),
+ );
+ numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1;
+ projectIssuesCounter.text(addDelimiter(numProjectIssues));
+
+ if (this.createMergeRequestDropdown) {
+ this.createMergeRequestDropdown.checkAbilityToCreateBranch();
+ }
+ } else {
+ createFlash({
+ message: issueFailMessage,
+ });
+ }
+ }
+
+ static initIssueMovedFromServiceDeskDismissHandler() {
+ const alertMovedFromServiceDeskWarning = $('.js-alert-moved-from-service-desk-warning');
+
+ const trimmedPathname = window.location.pathname.slice(1);
+ const alertMovedFromServiceDeskDismissedKey = joinPaths(
+ trimmedPathname,
+ 'alert-issue-moved-from-service-desk-dismissed',
+ );
+
+ if (!localStorage.getItem(alertMovedFromServiceDeskDismissedKey)) {
+ alertMovedFromServiceDeskWarning.show();
+ }
+
+ alertMovedFromServiceDeskWarning.on('click', '.js-close', (e) => {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ alertMovedFromServiceDeskWarning.remove();
+ localStorage.setItem(alertMovedFromServiceDeskDismissedKey, true);
+ });
+ }
+
+ static initRelatedBranches() {
+ const $container = $('#related-branches');
+ axios
+ .get($container.data('url'))
+ .then(({ data }) => {
+ if ('html' in data) {
+ $container.html(data.html);
+ }
+ })
+ .catch(() =>
+ createFlash({
+ message: __('Failed to load related branches'),
+ }),
+ );
+ }
+}
diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js
new file mode 100644
index 00000000000..9613246d6a6
--- /dev/null
+++ b/app/assets/javascripts/issues/manual_ordering.js
@@ -0,0 +1,61 @@
+import Sortable from 'sortablejs';
+import {
+ getBoardSortableDefaultOptions,
+ sortableStart,
+} from '~/boards/mixins/sortable_default_options';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { s__ } from '~/locale';
+
+const updateIssue = (url, issueList, { move_before_id, move_after_id }) =>
+ axios
+ .put(`${url}/reorder`, {
+ move_before_id,
+ move_after_id,
+ group_full_path: issueList.dataset.groupFullPath,
+ })
+ .catch(() => {
+ createFlash({
+ message: s__("ManualOrdering|Couldn't save the order of the issues"),
+ });
+ });
+
+const initManualOrdering = (draggableSelector = 'li.issue') => {
+ const issueList = document.querySelector('.manual-ordering');
+
+ if (!issueList || !(gon.current_user_id > 0)) {
+ return;
+ }
+
+ Sortable.create(
+ issueList,
+ getBoardSortableDefaultOptions({
+ scroll: true,
+ fallbackTolerance: 1,
+ dataIdAttr: 'data-id',
+ fallbackOnBody: false,
+ group: {
+ name: 'issues',
+ },
+ draggable: draggableSelector,
+ onStart: () => {
+ sortableStart();
+ },
+ onUpdate: (event) => {
+ const el = event.item;
+
+ const url = el.getAttribute('url') || el.dataset.url;
+
+ const prev = el.previousElementSibling;
+ const next = el.nextElementSibling;
+
+ const beforeId = prev && parseInt(prev.dataset.id, 10);
+ const afterId = next && parseInt(next.dataset.id, 10);
+
+ updateIssue(url, issueList, { move_after_id: afterId, move_before_id: beforeId });
+ },
+ }),
+ );
+};
+
+export default initManualOrdering;
diff --git a/app/assets/javascripts/issues/new/components/title_suggestions.vue b/app/assets/javascripts/issues/new/components/title_suggestions.vue
new file mode 100644
index 00000000000..0a9cdb12519
--- /dev/null
+++ b/app/assets/javascripts/issues/new/components/title_suggestions.vue
@@ -0,0 +1,94 @@
+<script>
+import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import query from '../queries/issues.query.graphql';
+import TitleSuggestionsItem from './title_suggestions_item.vue';
+
+export default {
+ components: {
+ GlIcon,
+ TitleSuggestionsItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ search: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ issues: {
+ query,
+ debounce: 1000,
+ skip() {
+ return this.isSearchEmpty;
+ },
+ update: (data) => data.project.issues.edges.map(({ node }) => node),
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ search: this.search,
+ };
+ },
+ },
+ },
+ data() {
+ return {
+ issues: [],
+ loading: 0,
+ };
+ },
+ computed: {
+ isSearchEmpty() {
+ return !this.search.length;
+ },
+ showSuggestions() {
+ return !this.isSearchEmpty && this.issues.length && !this.loading;
+ },
+ },
+ watch: {
+ search() {
+ if (this.isSearchEmpty) {
+ this.issues = [];
+ }
+ },
+ },
+ helpText: __(
+ 'These existing issues have a similar title. It might be better to comment there instead of creating another similar issue.',
+ ),
+};
+</script>
+
+<template>
+ <div v-show="showSuggestions" class="form-group row">
+ <div v-once class="col-form-label col-sm-2 pt-0">
+ {{ __('Similar issues') }}
+ <gl-icon
+ v-gl-tooltip.bottom
+ :title="$options.helpText"
+ :aria-label="$options.helpText"
+ name="question-o"
+ class="text-secondary gl-cursor-help"
+ />
+ </div>
+ <div class="col-sm-10">
+ <ul class="list-unstyled m-0">
+ <li
+ v-for="(suggestion, index) in issues"
+ :key="suggestion.id"
+ :class="{
+ 'gl-mb-3': index !== issues.length - 1,
+ }"
+ >
+ <title-suggestions-item :suggestion="suggestion" />
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/new/components/title_suggestions_item.vue b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
new file mode 100644
index 00000000000..a01f4f747b9
--- /dev/null
+++ b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
@@ -0,0 +1,132 @@
+<script>
+import { GlLink, GlTooltip, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { __ } from '~/locale';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import timeago from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: {
+ GlTooltip,
+ GlLink,
+ GlIcon,
+ UserAvatarImage,
+ TimeagoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeago],
+ props: {
+ suggestion: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ counts() {
+ return [
+ {
+ id: uniqueId(),
+ icon: 'thumb-up',
+ tooltipTitle: __('Upvotes'),
+ count: this.suggestion.upvotes,
+ },
+ {
+ id: uniqueId(),
+ icon: 'comment',
+ tooltipTitle: __('Comments'),
+ count: this.suggestion.userNotesCount,
+ },
+ ].filter(({ count }) => count);
+ },
+ isClosed() {
+ return this.suggestion.state === 'closed';
+ },
+ stateIconClass() {
+ return this.isClosed ? 'gl-text-blue-500' : 'gl-text-green-500';
+ },
+ stateIconName() {
+ return this.isClosed ? 'issue-close' : 'issue-open-m';
+ },
+ stateTitle() {
+ return this.isClosed ? __('Closed') : __('Opened');
+ },
+ closedOrCreatedDate() {
+ return this.suggestion.closedAt || this.suggestion.createdAt;
+ },
+ hasUpdated() {
+ return this.suggestion.updatedAt !== this.suggestion.createdAt;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="suggestion-item">
+ <div class="d-flex align-items-center">
+ <gl-icon
+ v-if="suggestion.confidential"
+ v-gl-tooltip.bottom
+ :title="__('Confidential')"
+ name="eye-slash"
+ class="gl-cursor-help gl-mr-2 gl-text-orange-500"
+ />
+ <gl-link
+ :href="suggestion.webUrl"
+ target="_blank"
+ class="suggestion bold str-truncated-100 gl-text-gray-900!"
+ >
+ {{ suggestion.title }}
+ </gl-link>
+ </div>
+ <div class="text-secondary suggestion-footer">
+ <gl-icon ref="state" :name="stateIconName" :class="stateIconClass" class="gl-cursor-help" />
+ <gl-tooltip :target="() => $refs.state" placement="bottom">
+ <span class="d-block">
+ <span class="bold"> {{ stateTitle }} </span> {{ timeFormatted(closedOrCreatedDate) }}
+ </span>
+ <span class="text-tertiary">{{ tooltipTitle(closedOrCreatedDate) }}</span>
+ </gl-tooltip>
+ #{{ suggestion.iid }} &bull;
+ <timeago-tooltip
+ :time="suggestion.createdAt"
+ tooltip-placement="bottom"
+ class="gl-cursor-help"
+ />
+ {{ __('by') }}
+ <gl-link :href="suggestion.author.webUrl">
+ <user-avatar-image
+ :img-src="suggestion.author.avatarUrl"
+ :size="16"
+ css-classes="mr-0 float-none"
+ tooltip-placement="bottom"
+ class="d-inline-block"
+ >
+ <span class="bold d-block">{{ __('Author') }}</span> {{ suggestion.author.name }}
+ <span class="text-tertiary">@{{ suggestion.author.username }}</span>
+ </user-avatar-image>
+ </gl-link>
+ <template v-if="hasUpdated">
+ &bull; {{ __('updated') }}
+ <timeago-tooltip
+ :time="suggestion.updatedAt"
+ tooltip-placement="bottom"
+ class="gl-cursor-help"
+ />
+ </template>
+ <span class="suggestion-counts">
+ <span
+ v-for="{ count, icon, tooltipTitle, id } in counts"
+ :key="id"
+ v-gl-tooltip.bottom
+ :title="tooltipTitle"
+ class="gl-cursor-help gl-ml-3 text-tertiary"
+ >
+ <gl-icon :name="icon" /> {{ count }}
+ </span>
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/new/components/type_popover.vue b/app/assets/javascripts/issues/new/components/type_popover.vue
new file mode 100644
index 00000000000..a70e79b70f9
--- /dev/null
+++ b/app/assets/javascripts/issues/new/components/type_popover.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlIcon, GlPopover } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ issueTypes: __('Issue types'),
+ issue: __('Issue'),
+ incident: __('Incident'),
+ issueHelpText: __('For general work'),
+ incidentHelpText: __('For investigating IT service disruptions or outages'),
+ },
+ components: {
+ GlIcon,
+ GlPopover,
+ },
+};
+</script>
+
+<template>
+ <span id="popovercontainer">
+ <gl-icon id="issue-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" />
+ <gl-popover
+ target="issue-type-info"
+ container="popovercontainer"
+ :title="$options.i18n.issueTypes"
+ triggers="focus hover"
+ >
+ <ul class="gl-list-style-none gl-p-0 gl-m-0">
+ <li class="gl-mb-3">
+ <div class="gl-font-weight-bold">{{ $options.i18n.issue }}</div>
+ <span>{{ $options.i18n.issueHelpText }}</span>
+ </li>
+ <li>
+ <div class="gl-font-weight-bold">{{ $options.i18n.incident }}</div>
+ <span>{{ $options.i18n.incidentHelpText }}</span>
+ </li>
+ </ul>
+ </gl-popover>
+ </span>
+</template>
diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js
new file mode 100644
index 00000000000..59a7cbec627
--- /dev/null
+++ b/app/assets/javascripts/issues/new/index.js
@@ -0,0 +1,56 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import TitleSuggestions from './components/title_suggestions.vue';
+import TypePopover from './components/type_popover.vue';
+
+export function initTitleSuggestions() {
+ Vue.use(VueApollo);
+
+ const el = document.getElementById('js-suggestions');
+ const issueTitle = document.getElementById('issue_title');
+
+ if (!el) {
+ return undefined;
+ }
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ data() {
+ return {
+ search: issueTitle.value,
+ };
+ },
+ mounted() {
+ issueTitle.addEventListener('input', () => {
+ this.search = issueTitle.value;
+ });
+ },
+ render(createElement) {
+ return createElement(TitleSuggestions, {
+ props: {
+ projectPath: el.dataset.projectPath,
+ search: this.search,
+ },
+ });
+ },
+ });
+}
+
+export function initTypePopover() {
+ const el = document.getElementById('js-type-popover');
+
+ if (!el) {
+ return undefined;
+ }
+
+ return new Vue({
+ el,
+ render: (createElement) => createElement(TypePopover),
+ });
+}
diff --git a/app/assets/javascripts/issues/new/queries/issues.query.graphql b/app/assets/javascripts/issues/new/queries/issues.query.graphql
new file mode 100644
index 00000000000..dc0757b141f
--- /dev/null
+++ b/app/assets/javascripts/issues/new/queries/issues.query.graphql
@@ -0,0 +1,29 @@
+query issueSuggestion($fullPath: ID!, $search: String) {
+ project(fullPath: $fullPath) {
+ id
+ issues(search: $search, sort: updated_desc, first: 5) {
+ edges {
+ node {
+ id
+ iid
+ title
+ confidential
+ userNotesCount
+ upvotes
+ webUrl
+ state
+ closedAt
+ createdAt
+ updatedAt
+ author {
+ id
+ name
+ username
+ avatarUrl
+ webUrl
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
new file mode 100644
index 00000000000..1d48446b083
--- /dev/null
+++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
@@ -0,0 +1,120 @@
+<script>
+import { GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { sprintf, __, n__ } from '~/locale';
+import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
+import { parseIssuableData } from '~/issues/show/utils/parse_data';
+
+export default {
+ name: 'RelatedMergeRequests',
+ components: {
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ RelatedIssuableItem,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['isFetchingMergeRequests', 'mergeRequests', 'totalCount']),
+ closingMergeRequestsText() {
+ if (!this.hasClosingMergeRequest) {
+ return '';
+ }
+
+ const mrText = n__(
+ 'When this merge request is accepted',
+ 'When these merge requests are accepted',
+ this.totalCount,
+ );
+
+ return sprintf(__('%{mrText}, this issue will be closed automatically.'), { mrText });
+ },
+ },
+ mounted() {
+ this.setInitialState({ apiEndpoint: this.endpoint });
+ this.fetchMergeRequests();
+ },
+ created() {
+ this.hasClosingMergeRequest = parseIssuableData().hasClosingMergeRequest;
+ },
+ methods: {
+ ...mapActions(['setInitialState', 'fetchMergeRequests']),
+ getAssignees(mr) {
+ if (mr.assignees) {
+ return mr.assignees;
+ }
+
+ return mr.assignee ? [mr.assignee] : [];
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
+ <div class="card card-slim gl-mt-5">
+ <div class="card-header">
+ <div
+ class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0"
+ >
+ <gl-link
+ class="anchor gl-absolute gl-text-decoration-none"
+ href="#related-merge-requests"
+ aria-labelledby="related-merge-requests"
+ />
+ <h3 id="related-merge-requests" class="gl-font-base gl-m-0">
+ {{ __('Related merge requests') }}
+ </h3>
+ <template v-if="totalCount">
+ <gl-icon name="merge-request" class="gl-ml-5 gl-mr-2 gl-text-gray-500" />
+ <span data-testid="count">{{ totalCount }}</span>
+ </template>
+ </div>
+ </div>
+ <gl-loading-icon
+ v-if="isFetchingMergeRequests"
+ size="sm"
+ label="Fetching related merge requests"
+ class="gl-py-3"
+ />
+ <ul v-else class="content-list related-items-list">
+ <li v-for="mr in mergeRequests" :key="mr.id" class="list-item gl-m-0! gl-p-0!">
+ <related-issuable-item
+ :id-key="mr.id"
+ :display-reference="mr.reference"
+ :title="mr.title"
+ :milestone="mr.milestone"
+ :assignees="getAssignees(mr)"
+ :created-at="mr.created_at"
+ :closed-at="mr.closed_at"
+ :merged-at="mr.merged_at"
+ :path="mr.web_url"
+ :state="mr.state"
+ :is-merge-request="true"
+ :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status"
+ path-id-separator="!"
+ />
+ </li>
+ </ul>
+ </div>
+ <div
+ v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
+ class="issue-closed-by-widget second-block"
+ >
+ {{ closingMergeRequestsText }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js
new file mode 100644
index 00000000000..ce33cf7df1d
--- /dev/null
+++ b/app/assets/javascripts/issues/related_merge_requests/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import RelatedMergeRequests from './components/related_merge_requests.vue';
+import createStore from './store';
+
+export default function initRelatedMergeRequests() {
+ const relatedMergeRequestsElement = document.querySelector('#js-related-merge-requests');
+
+ if (relatedMergeRequestsElement) {
+ const { endpoint, projectPath, projectNamespace } = relatedMergeRequestsElement.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: relatedMergeRequestsElement,
+ components: {
+ RelatedMergeRequests,
+ },
+ store: createStore(),
+ render: (createElement) =>
+ createElement('related-merge-requests', {
+ props: { endpoint, projectNamespace, projectPath },
+ }),
+ });
+ }
+}
diff --git a/app/assets/javascripts/issues/related_merge_requests/store/actions.js b/app/assets/javascripts/issues/related_merge_requests/store/actions.js
new file mode 100644
index 00000000000..94abb50de89
--- /dev/null
+++ b/app/assets/javascripts/issues/related_merge_requests/store/actions.js
@@ -0,0 +1,36 @@
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import * as types from './mutation_types';
+
+const REQUEST_PAGE_COUNT = 100;
+
+export const setInitialState = ({ commit }, props) => {
+ commit(types.SET_INITIAL_STATE, props);
+};
+
+export const requestData = ({ commit }) => commit(types.REQUEST_DATA);
+
+export const receiveDataSuccess = ({ commit }, data) => commit(types.RECEIVE_DATA_SUCCESS, data);
+
+export const receiveDataError = ({ commit }) => commit(types.RECEIVE_DATA_ERROR);
+
+export const fetchMergeRequests = ({ state, dispatch }) => {
+ dispatch('requestData');
+
+ return axios
+ .get(`${state.apiEndpoint}?per_page=${REQUEST_PAGE_COUNT}`)
+ .then((res) => {
+ const { headers, data } = res;
+ const total = Number(normalizeHeaders(headers)['X-TOTAL']) || 0;
+
+ dispatch('receiveDataSuccess', { data, total });
+ })
+ .catch(() => {
+ dispatch('receiveDataError');
+ createFlash({
+ message: __('Something went wrong while fetching related merge requests.'),
+ });
+ });
+};
diff --git a/app/assets/javascripts/issues/related_merge_requests/store/index.js b/app/assets/javascripts/issues/related_merge_requests/store/index.js
new file mode 100644
index 00000000000..925cc36cd76
--- /dev/null
+++ b/app/assets/javascripts/issues/related_merge_requests/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ state: createState(),
+ actions,
+ mutations,
+ });
diff --git a/app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js b/app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js
new file mode 100644
index 00000000000..31d4fe032e1
--- /dev/null
+++ b/app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js
@@ -0,0 +1,4 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+export const REQUEST_DATA = 'REQUEST_DATA';
+export const RECEIVE_DATA_SUCCESS = 'RECEIVE_DATA_SUCCESS';
+export const RECEIVE_DATA_ERROR = 'RECEIVE_DATA_ERROR';
diff --git a/app/assets/javascripts/issues/related_merge_requests/store/mutations.js b/app/assets/javascripts/issues/related_merge_requests/store/mutations.js
new file mode 100644
index 00000000000..11ca28a5fb9
--- /dev/null
+++ b/app/assets/javascripts/issues/related_merge_requests/store/mutations.js
@@ -0,0 +1,19 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_STATE](state, { apiEndpoint }) {
+ state.apiEndpoint = apiEndpoint;
+ },
+ [types.REQUEST_DATA](state) {
+ state.isFetchingMergeRequests = true;
+ },
+ [types.RECEIVE_DATA_SUCCESS](state, { data, total }) {
+ state.isFetchingMergeRequests = false;
+ state.mergeRequests = data;
+ state.totalCount = total;
+ },
+ [types.RECEIVE_DATA_ERROR](state) {
+ state.isFetchingMergeRequests = false;
+ state.hasErrorFetchingMergeRequests = true;
+ },
+};
diff --git a/app/assets/javascripts/issues/related_merge_requests/store/state.js b/app/assets/javascripts/issues/related_merge_requests/store/state.js
new file mode 100644
index 00000000000..bc3468a025b
--- /dev/null
+++ b/app/assets/javascripts/issues/related_merge_requests/store/state.js
@@ -0,0 +1,7 @@
+export default () => ({
+ apiEndpoint: '',
+ isFetchingMergeRequests: false,
+ hasErrorFetchingMergeRequests: false,
+ mergeRequests: [],
+ totalCount: 0,
+});
diff --git a/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue
new file mode 100644
index 00000000000..1530e9a15b5
--- /dev/null
+++ b/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import Stacktrace from '~/error_tracking/components/stacktrace.vue';
+
+export default {
+ name: 'SentryErrorStackTrace',
+ components: {
+ Stacktrace,
+ GlLoadingIcon,
+ },
+ props: {
+ issueStackTracePath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState('details', ['loadingStacktrace', 'stacktraceData']),
+ ...mapGetters('details', ['stacktrace']),
+ },
+ mounted() {
+ this.startPollingStacktrace(this.issueStackTracePath);
+ },
+ methods: {
+ ...mapActions('details', ['startPollingStacktrace']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div :class="{ 'border-bottom-0': loadingStacktrace }" class="card card-slim mt-4 mb-0">
+ <div class="card-header border-bottom-0">
+ <h5 class="card-title my-1">{{ __('Stack trace') }}</h5>
+ </div>
+ </div>
+ <div v-if="loadingStacktrace" class="card">
+ <gl-loading-icon class="py-2" label="Fetching stack trace" size="sm" />
+ </div>
+ <stacktrace v-else :entries="stacktrace" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/sentry_error_stack_trace/index.js b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js
new file mode 100644
index 00000000000..8e9ee25e7a8
--- /dev/null
+++ b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import store from '~/error_tracking/store';
+import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue';
+
+export default function initSentryErrorStacktrace() {
+ const sentryErrorStackTraceEl = document.querySelector('#js-sentry-error-stack-trace');
+ if (sentryErrorStackTraceEl) {
+ const { issueStackTracePath } = sentryErrorStackTraceEl.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: sentryErrorStackTraceEl,
+ components: {
+ SentryErrorStackTrace,
+ },
+ store,
+ render: (createElement) =>
+ createElement('sentry-error-stack-trace', {
+ props: { issueStackTracePath },
+ }),
+ });
+ }
+}
diff --git a/app/assets/javascripts/issues/show.js b/app/assets/javascripts/issues/show.js
new file mode 100644
index 00000000000..e43e56d7b4e
--- /dev/null
+++ b/app/assets/javascripts/issues/show.js
@@ -0,0 +1,59 @@
+import loadAwardsHandler from '~/awards_handler';
+import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
+import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable';
+import { IssuableType } from '~/vue_shared/issuable/show/constants';
+import Issue from '~/issues/issue';
+import { initIncidentApp, initIncidentHeaderActions } from '~/issues/show/incident';
+import { initIssuableApp, initIssueHeaderActions } from '~/issues/show/issue';
+import { parseIssuableData } from '~/issues/show/utils/parse_data';
+import initNotesApp from '~/notes';
+import { store } from '~/notes/stores';
+import initRelatedMergeRequestsApp from '~/issues/related_merge_requests';
+import initSentryErrorStackTraceApp from '~/issues/sentry_error_stack_trace';
+import ZenMode from '~/zen_mode';
+
+export default function initShowIssue() {
+ initNotesApp();
+
+ const initialDataEl = document.getElementById('js-issuable-app');
+ const { issueType, ...issuableData } = parseIssuableData(initialDataEl);
+
+ switch (issueType) {
+ case IssuableType.Incident:
+ initIncidentApp(issuableData);
+ initIncidentHeaderActions(store);
+ break;
+ case IssuableType.Issue:
+ initIssuableApp(issuableData, store);
+ initIssueHeaderActions(store);
+ break;
+ default:
+ initIssueHeaderActions(store);
+ break;
+ }
+
+ initIssuableHeaderWarnings(store);
+ initSentryErrorStackTraceApp();
+ initRelatedMergeRequestsApp();
+
+ import(/* webpackChunkName: 'design_management' */ '~/design_management')
+ .then((module) => module.default())
+ .catch(() => {});
+
+ new ZenMode(); // eslint-disable-line no-new
+
+ if (issueType !== IssuableType.TestCase) {
+ const awardEmojiEl = document.getElementById('js-vue-awards-block');
+
+ new Issue(); // eslint-disable-line no-new
+ new ShortcutsIssuable(); // eslint-disable-line no-new
+ initIssuableSidebar();
+ if (awardEmojiEl) {
+ import('~/emoji/awards_app')
+ .then((m) => m.default(awardEmojiEl))
+ .catch(() => {});
+ } else {
+ loadAwardsHandler();
+ }
+ }
+}
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>
diff --git a/app/assets/javascripts/issues/show/constants.js b/app/assets/javascripts/issues/show/constants.js
new file mode 100644
index 00000000000..35f3bcdad70
--- /dev/null
+++ b/app/assets/javascripts/issues/show/constants.js
@@ -0,0 +1,22 @@
+import { __ } from '~/locale';
+
+export const IssueStateEvent = {
+ Close: 'CLOSE',
+ Reopen: 'REOPEN',
+};
+
+export const STATUS_PAGE_PUBLISHED = __('Published on status page');
+export const JOIN_ZOOM_MEETING = __('Join Zoom meeting');
+
+export const IssuableTypes = [
+ { value: 'issue', text: __('Issue'), icon: 'issue-type-issue' },
+ { value: 'incident', text: __('Incident'), icon: 'issue-type-incident' },
+];
+
+export const IssueTypePath = 'issues';
+export const IncidentTypePath = 'issues/incident';
+export const IncidentType = 'incident';
+
+export const issueState = { issueType: undefined, isDirty: false };
+
+export const POLLING_DELAY = 2000;
diff --git a/app/assets/javascripts/issues/show/event_hub.js b/app/assets/javascripts/issues/show/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/issues/show/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/issues/show/graphql.js b/app/assets/javascripts/issues/show/graphql.js
new file mode 100644
index 00000000000..5b8630f7d63
--- /dev/null
+++ b/app/assets/javascripts/issues/show/graphql.js
@@ -0,0 +1,9 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { defaultClient } from '~/sidebar/graphql';
+
+Vue.use(VueApollo);
+
+export default new VueApollo({
+ defaultClient,
+});
diff --git a/app/assets/javascripts/issues/show/incident.js b/app/assets/javascripts/issues/show/incident.js
new file mode 100644
index 00000000000..a260c31e1da
--- /dev/null
+++ b/app/assets/javascripts/issues/show/incident.js
@@ -0,0 +1,101 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import issuableApp from './components/app.vue';
+import incidentTabs from './components/incidents/incident_tabs.vue';
+import { issueState, IncidentType } from './constants';
+import apolloProvider from './graphql';
+import getIssueStateQuery from './queries/get_issue_state.query.graphql';
+import HeaderActions from './components/header_actions.vue';
+
+const bootstrapApollo = (state = {}) => {
+ return apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: getIssueStateQuery,
+ data: {
+ issueState: state,
+ },
+ });
+};
+
+export function initIncidentApp(issuableData = {}) {
+ const el = document.getElementById('js-issuable-app');
+
+ if (!el) {
+ return undefined;
+ }
+
+ bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
+
+ const {
+ canCreateIncident,
+ canUpdate,
+ iid,
+ projectNamespace,
+ projectPath,
+ projectId,
+ slaFeatureAvailable,
+ uploadMetricsFeatureAvailable,
+ } = issuableData;
+
+ const fullPath = `${projectNamespace}/${projectPath}`;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ components: {
+ issuableApp,
+ },
+ provide: {
+ issueType: IncidentType,
+ canCreateIncident,
+ canUpdate,
+ fullPath,
+ iid,
+ projectId,
+ slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
+ uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable),
+ },
+ render(createElement) {
+ return createElement('issuable-app', {
+ props: {
+ ...issuableData,
+ descriptionComponent: incidentTabs,
+ showTitleBorder: false,
+ },
+ });
+ },
+ });
+}
+
+export function initIncidentHeaderActions(store) {
+ const el = document.querySelector('.js-issue-header-actions');
+
+ if (!el) {
+ return undefined;
+ }
+
+ bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ store,
+ provide: {
+ canCreateIssue: parseBoolean(el.dataset.canCreateIncident),
+ canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue),
+ canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic),
+ canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
+ canReportSpam: parseBoolean(el.dataset.canReportSpam),
+ canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
+ iid: el.dataset.iid,
+ isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
+ issuePath: el.dataset.issuePath,
+ issueType: el.dataset.issueType,
+ newIssuePath: el.dataset.newIssuePath,
+ projectPath: el.dataset.projectPath,
+ projectId: el.dataset.projectId,
+ reportAbusePath: el.dataset.reportAbusePath,
+ submitAsSpamPath: el.dataset.submitAsSpamPath,
+ },
+ render: (createElement) => createElement(HeaderActions),
+ });
+}
diff --git a/app/assets/javascripts/issues/show/issue.js b/app/assets/javascripts/issues/show/issue.js
new file mode 100644
index 00000000000..60e90934af8
--- /dev/null
+++ b/app/assets/javascripts/issues/show/issue.js
@@ -0,0 +1,86 @@
+import Vue from 'vue';
+import { mapGetters } from 'vuex';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import IssuableApp from './components/app.vue';
+import HeaderActions from './components/header_actions.vue';
+import { issueState } from './constants';
+import apolloProvider from './graphql';
+import getIssueStateQuery from './queries/get_issue_state.query.graphql';
+
+const bootstrapApollo = (state = {}) => {
+ return apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: getIssueStateQuery,
+ data: {
+ issueState: state,
+ },
+ });
+};
+
+export function initIssuableApp(issuableData, store) {
+ const el = document.getElementById('js-issuable-app');
+
+ if (!el) {
+ return undefined;
+ }
+
+ bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
+
+ const { canCreateIncident, ...issuableProps } = issuableData;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ store,
+ provide: {
+ canCreateIncident,
+ },
+ computed: {
+ ...mapGetters(['getNoteableData']),
+ },
+ render(createElement) {
+ return createElement(IssuableApp, {
+ props: {
+ ...issuableProps,
+ isConfidential: this.getNoteableData?.confidential,
+ isLocked: this.getNoteableData?.discussion_locked,
+ issuableStatus: this.getNoteableData?.state,
+ id: this.getNoteableData?.id,
+ },
+ });
+ },
+ });
+}
+
+export function initIssueHeaderActions(store) {
+ const el = document.querySelector('.js-issue-header-actions');
+
+ if (!el) {
+ return undefined;
+ }
+
+ bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ store,
+ provide: {
+ canCreateIssue: parseBoolean(el.dataset.canCreateIssue),
+ canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue),
+ canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic),
+ canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
+ canReportSpam: parseBoolean(el.dataset.canReportSpam),
+ canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
+ iid: el.dataset.iid,
+ isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
+ issuePath: el.dataset.issuePath,
+ issueType: el.dataset.issueType,
+ newIssuePath: el.dataset.newIssuePath,
+ projectPath: el.dataset.projectPath,
+ projectId: el.dataset.projectId,
+ reportAbusePath: el.dataset.reportAbusePath,
+ submitAsSpamPath: el.dataset.submitAsSpamPath,
+ },
+ render: (createElement) => createElement(HeaderActions),
+ });
+}
diff --git a/app/assets/javascripts/issues/show/mixins/animate.js b/app/assets/javascripts/issues/show/mixins/animate.js
new file mode 100644
index 00000000000..4816393da1f
--- /dev/null
+++ b/app/assets/javascripts/issues/show/mixins/animate.js
@@ -0,0 +1,13 @@
+export default {
+ methods: {
+ animateChange() {
+ this.preAnimation = true;
+ this.pulseAnimation = false;
+
+ setTimeout(() => {
+ this.preAnimation = false;
+ this.pulseAnimation = true;
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/issues/show/mixins/update.js b/app/assets/javascripts/issues/show/mixins/update.js
new file mode 100644
index 00000000000..72be65b426f
--- /dev/null
+++ b/app/assets/javascripts/issues/show/mixins/update.js
@@ -0,0 +1,10 @@
+import eventHub from '../event_hub';
+
+export default {
+ methods: {
+ updateIssuable() {
+ this.formState.updateLoading = true;
+ eventHub.$emit('update.issuable');
+ },
+ },
+};
diff --git a/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql b/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql
new file mode 100644
index 00000000000..33b737d2315
--- /dev/null
+++ b/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql
@@ -0,0 +1,3 @@
+query issueState {
+ issueState @client
+}
diff --git a/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql b/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql
new file mode 100644
index 00000000000..e3e3a2bc667
--- /dev/null
+++ b/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql
@@ -0,0 +1,9 @@
+mutation promoteToEpic($input: PromoteToEpicInput!) {
+ promoteToEpic(input: $input) {
+ epic {
+ id
+ webPath
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql b/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql
new file mode 100644
index 00000000000..ec8d8f32d8b
--- /dev/null
+++ b/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql
@@ -0,0 +1,9 @@
+mutation updateIssue($input: UpdateIssueInput!) {
+ updateIssue(input: $input) {
+ issuable: issue {
+ id
+ state
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql b/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql
new file mode 100644
index 00000000000..d91ca746066
--- /dev/null
+++ b/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updateIssueState($issueType: String, $isDirty: Boolean) {
+ updateIssueState(issueType: $issueType, isDirty: $isDirty) @client
+}
diff --git a/app/assets/javascripts/issues/show/services/index.js b/app/assets/javascripts/issues/show/services/index.js
new file mode 100644
index 00000000000..dba07f623f9
--- /dev/null
+++ b/app/assets/javascripts/issues/show/services/index.js
@@ -0,0 +1,29 @@
+import axios from '~/lib/utils/axios_utils';
+
+export default class Service {
+ constructor(endpoint) {
+ this.endpoint = `${endpoint}.json`;
+ this.realtimeEndpoint = `${endpoint}/realtime_changes`;
+ }
+
+ getData() {
+ return axios.get(this.realtimeEndpoint);
+ }
+
+ deleteIssuable(payload) {
+ return axios.delete(this.endpoint, { params: payload });
+ }
+
+ updateIssuable(data) {
+ return axios.put(this.endpoint, data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ loadTemplates(templateNamesEndpoint) {
+ if (!templateNamesEndpoint) {
+ return Promise.resolve([]);
+ }
+
+ return axios.get(templateNamesEndpoint);
+ }
+}
diff --git a/app/assets/javascripts/issues/show/stores/index.js b/app/assets/javascripts/issues/show/stores/index.js
new file mode 100644
index 00000000000..a50913d3455
--- /dev/null
+++ b/app/assets/javascripts/issues/show/stores/index.js
@@ -0,0 +1,46 @@
+import { sanitize } from '~/lib/dompurify';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import updateDescription from '../utils/update_description';
+
+export default class Store {
+ constructor(initialState) {
+ this.state = initialState;
+ this.formState = {
+ title: '',
+ description: '',
+ lockedWarningVisible: false,
+ updateLoading: false,
+ lock_version: 0,
+ issuableTemplates: {},
+ };
+ }
+
+ updateState(data) {
+ if (this.stateShouldUpdate(data)) {
+ this.formState.lockedWarningVisible = true;
+ }
+
+ Object.assign(this.state, convertObjectPropsToCamelCase(data));
+ // find if there is an open details node inside of the issue description.
+ const descriptionSection = document.body.querySelector(
+ '.detail-page-description.content-block',
+ );
+ const details =
+ descriptionSection != null && descriptionSection.getElementsByTagName('details');
+
+ this.state.descriptionHtml = updateDescription(sanitize(data.description), details);
+ this.state.titleHtml = sanitize(data.title);
+ this.state.lock_version = data.lock_version;
+ }
+
+ stateShouldUpdate(data) {
+ return (
+ this.state.titleText !== data.title_text ||
+ this.state.descriptionText !== data.description_text
+ );
+ }
+
+ setFormState(state) {
+ this.formState = Object.assign(this.formState, state);
+ }
+}
diff --git a/app/assets/javascripts/issues/show/utils/parse_data.js b/app/assets/javascripts/issues/show/utils/parse_data.js
new file mode 100644
index 00000000000..f1e6bd2419a
--- /dev/null
+++ b/app/assets/javascripts/issues/show/utils/parse_data.js
@@ -0,0 +1,23 @@
+import * as Sentry from '@sentry/browser';
+import { sanitize } from '~/lib/dompurify';
+
+// We currently load + parse the data from the issue app and related merge request
+let cachedParsedData;
+
+export const parseIssuableData = (el) => {
+ try {
+ if (cachedParsedData) return cachedParsedData;
+
+ const parsedData = JSON.parse(el.dataset.initial);
+ parsedData.initialTitleHtml = sanitize(parsedData.initialTitleHtml);
+ parsedData.initialDescriptionHtml = sanitize(parsedData.initialDescriptionHtml);
+
+ cachedParsedData = parsedData;
+
+ return parsedData;
+ } catch (e) {
+ Sentry.captureException(e);
+
+ return {};
+ }
+};
diff --git a/app/assets/javascripts/issues/show/utils/update_description.js b/app/assets/javascripts/issues/show/utils/update_description.js
new file mode 100644
index 00000000000..c5811290e61
--- /dev/null
+++ b/app/assets/javascripts/issues/show/utils/update_description.js
@@ -0,0 +1,36 @@
+/**
+ * Function that replaces the open attribute for the <details> element.
+ *
+ * @param {String} descriptionHtml - The html string passed back from the server as a result of polling
+ * @param {Array} details - All detail nodes inside of the issue description.
+ */
+
+const updateDescription = (descriptionHtml = '', details) => {
+ let detailNodes = details;
+
+ if (!details.length) {
+ detailNodes = [];
+ }
+
+ const placeholder = document.createElement('div');
+ placeholder.innerHTML = descriptionHtml;
+
+ const newDetails = placeholder.getElementsByTagName('details');
+
+ if (newDetails.length !== detailNodes.length) {
+ return descriptionHtml;
+ }
+
+ Array.from(newDetails).forEach((el, i) => {
+ /*
+ * <details> has an open attribute that can have a value, "", "true", "false"
+ * and will show the dropdown, which is why we are setting the attribute
+ * explicitly to true.
+ */
+ if (detailNodes[i].open) el.setAttribute('open', true);
+ });
+
+ return placeholder.innerHTML;
+};
+
+export default updateDescription;