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