diff options
Diffstat (limited to 'app/assets/javascripts/issue_show')
6 files changed, 382 insertions, 15 deletions
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 22db0f1cfc1..61e5db0970a 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -136,6 +136,16 @@ export default { type: String, required: true, }, + isConfidential: { + type: Boolean, + required: false, + default: false, + }, + isLocked: { + type: Boolean, + required: false, + default: false, + }, issuableType: { type: String, required: false, @@ -217,8 +227,8 @@ export default { defaultErrorMessage() { return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType }); }, - isOpenStatus() { - return this.issuableStatus === IssuableStatus.Open; + isClosed() { + return this.issuableStatus === IssuableStatus.Closed; }, pinnedLinkClasses() { return this.showTitleBorder @@ -226,13 +236,13 @@ export default { : ''; }, statusIcon() { - return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close'; + return this.isClosed ? 'mobile-issue-close' : 'issue-open-m'; }, statusText() { return IssuableStatusText[this.issuableStatus]; }, shouldShowStickyHeader() { - return this.isStickyHeaderShowing && this.issuableType === IssuableType.Issue; + return this.issuableType === IssuableType.Issue; }, }, created() { @@ -432,10 +442,14 @@ export default { :show-inline-edit-button="showInlineEditButton" /> - <gl-intersection-observer @appear="hideStickyHeader" @disappear="showStickyHeader"> + <gl-intersection-observer + v-if="shouldShowStickyHeader" + @appear="hideStickyHeader" + @disappear="showStickyHeader" + > <transition name="issuable-header-slide"> <div - v-if="shouldShowStickyHeader" + 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" > @@ -444,11 +458,17 @@ export default { > <p class="issuable-status-box status-box gl-my-0" - :class="[isOpenStatus ? 'status-box-open' : 'status-box-issue-closed']" + :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> <p class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" :title="state.titleText" diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue new file mode 100644 index 00000000000..4c8c86390f4 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/header_actions.vue @@ -0,0 +1,281 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import { IssuableType } from '~/issuable_show/constants'; +import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { __, sprintf } from '~/locale'; +import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql'; +import updateIssueMutation from '../queries/update_issue.mutation.graphql'; + +export default { + components: { + GlButton, + GlDropdown, + GlDropdownItem, + GlIcon, + GlLink, + GlModal, + }, + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: __('Yes, close issue'), + attributes: [{ variant: 'warning' }], + }, + 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...', + ), + }, + inject: { + canCreateIssue: { + default: false, + }, + canPromoteToEpic: { + default: false, + }, + canReopenIssue: { + default: false, + }, + canReportSpam: { + default: false, + }, + canUpdateIssue: { + default: false, + }, + iid: { + default: '', + }, + isIssueAuthor: { + default: false, + }, + issueType: { + default: IssuableType.Issue, + }, + newIssuePath: { + default: '', + }, + projectPath: { + default: '', + }, + reportAbusePath: { + default: '', + }, + submitAsSpamPath: { + default: '', + }, + }, + data() { + return { + isUpdatingState: false, + }; + }, + computed: { + ...mapGetters(['getNoteableData']), + isClosed() { + return this.getNoteableData.state === IssuableStatus.Closed; + }, + buttonText() { + return this.isClosed + ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueType }) + : sprintf(__('Close %{issueType}'), { issueType: this.issueType }); + }, + qaSelector() { + return this.isClosed ? 'reopen_issue_button' : 'close_issue_button'; + }, + buttonVariant() { + return this.isClosed ? 'default' : 'warning'; + }, + 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; + }, + }, + methods: { + toggleIssueState() { + if (!this.isClosed && this.getNoteableData?.blocked_by_issues?.length) { + this.$refs.blockedByIssuesModal.show(); + return; + } + + this.invokeUpdateIssueMutation(); + }, + invokeUpdateIssueMutation() { + this.isUpdatingState = 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) { + createFlash({ message: data.updateIssue.errors.join('. ') }); + return; + } + + 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('issuable_vue_app:change', payload)); + }) + .catch(() => createFlash({ message: __('Update failed. Please try again.') })) + .finally(() => { + this.isUpdatingState = false; + }); + }, + promoteToEpic() { + this.isUpdatingState = true; + + this.$apollo + .mutate({ + mutation: promoteToEpicMutation, + variables: { + input: { + iid: this.iid, + projectPath: this.projectPath, + }, + }, + }) + .then(({ data }) => { + if (data.promoteToEpic.errors.length) { + createFlash({ message: data.promoteToEpic.errors.join('; ') }); + return; + } + + createFlash({ + message: this.$options.i18n.promoteSuccessMessage, + type: FLASH_TYPES.SUCCESS, + }); + + visitUrl(data.promoteToEpic.epic.webPath); + }) + .catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage })) + .finally(() => { + this.isUpdatingState = false; + }); + }, + }, +}; +</script> + +<template> + <div class="detail-page-header-actions"> + <gl-dropdown class="gl-display-block gl-display-sm-none!" block :text="dropdownText"> + <gl-dropdown-item + v-if="showToggleIssueStateButton" + :disabled="isUpdatingState" + @click="toggleIssueState" + > + {{ buttonText }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> + {{ newIssueTypeText }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canPromoteToEpic" :disabled="isUpdatingState" @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> + </gl-dropdown> + + <gl-button + v-if="showToggleIssueStateButton" + class="gl-display-none gl-display-sm-inline-flex!" + category="secondary" + :data-qa-selector="qaSelector" + :loading="isUpdatingState" + :variant="buttonVariant" + @click="toggleIssueState" + > + {{ buttonText }} + </gl-button> + + <gl-dropdown + class="gl-display-none gl-display-sm-inline-flex!" + toggle-class="gl-border-0! gl-shadow-none!" + no-caret + right + > + <template #button-content> + <gl-icon name="ellipsis_v" aria-hidden="true" /> + <span class="gl-sr-only">{{ dropdownText }}</span> + </template> + + <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> + {{ newIssueTypeText }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="canPromoteToEpic" + :disabled="isUpdatingState" + 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> + </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 getNoteableData.blocked_by_issues" :key="issue.iid"> + <gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link> + </li> + </ul> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js index 6bc6ed2b372..a5ca91dffd4 100644 --- a/app/assets/javascripts/issue_show/constants.js +++ b/app/assets/javascripts/issue_show/constants.js @@ -1,13 +1,15 @@ import { __ } from '~/locale'; export const IssuableStatus = { - Open: 'opened', Closed: 'closed', + Open: 'opened', + Reopened: 'reopened', }; export const IssuableStatusText = { - [IssuableStatus.Open]: __('Open'), [IssuableStatus.Closed]: __('Closed'), + [IssuableStatus.Open]: __('Open'), + [IssuableStatus.Reopened]: __('Open'), }; export const IssuableType = { @@ -16,5 +18,10 @@ export const IssuableType = { MergeRequest: 'merge_request', }; +export const IssueStateEvent = { + Close: 'CLOSE', + Reopen: 'REOPEN', +}; + export const STATUS_PAGE_PUBLISHED = __('Published on status page'); export const JOIN_ZOOM_MEETING = __('Join Zoom meeting'); diff --git a/app/assets/javascripts/issue_show/issue.js b/app/assets/javascripts/issue_show/issue.js index f9f61d5aa64..8260460828b 100644 --- a/app/assets/javascripts/issue_show/issue.js +++ b/app/assets/javascripts/issue_show/issue.js @@ -1,16 +1,62 @@ import Vue from 'vue'; -import issuableApp from './components/app.vue'; +import VueApollo from 'vue-apollo'; +import { mapGetters } from 'vuex'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import IssuableApp from './components/app.vue'; +import HeaderActions from './components/header_actions.vue'; -export default function initIssuableApp(issuableData) { +export function initIssuableApp(issuableData, store) { return new Vue({ el: document.getElementById('js-issuable-app'), - components: { - issuableApp, + store, + computed: { + ...mapGetters(['getNoteableData']), }, render(createElement) { - return createElement('issuable-app', { - props: issuableData, + return createElement(IssuableApp, { + props: { + ...issuableData, + isConfidential: this.getNoteableData?.confidential, + isLocked: this.getNoteableData?.discussion_locked, + issuableStatus: this.getNoteableData?.state, + }, }); }, }); } + +export function initIssueHeaderActions(store) { + const el = document.querySelector('.js-issue-header-actions'); + + if (!el) { + return undefined; + } + + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + store, + provide: { + canCreateIssue: parseBoolean(el.dataset.canCreateIssue), + 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), + issueType: el.dataset.issueType, + newIssuePath: el.dataset.newIssuePath, + projectPath: el.dataset.projectPath, + reportAbusePath: el.dataset.reportAbusePath, + submitAsSpamPath: el.dataset.submitAsSpamPath, + }, + render: createElement => createElement(HeaderActions), + }); +} diff --git a/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql b/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql new file mode 100644 index 00000000000..12d05af0f5e --- /dev/null +++ b/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql @@ -0,0 +1,8 @@ +mutation promoteToEpic($input: PromoteToEpicInput!) { + promoteToEpic(input: $input) { + epic { + webPath + } + errors + } +} diff --git a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql b/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql new file mode 100644 index 00000000000..9c28fdded21 --- /dev/null +++ b/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql @@ -0,0 +1,5 @@ +mutation updateIssue($input: UpdateIssueInput!) { + updateIssue(input: $input) { + errors + } +} |