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/issue_show')
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue34
-rw-r--r--app/assets/javascripts/issue_show/components/header_actions.vue281
-rw-r--r--app/assets/javascripts/issue_show/constants.js11
-rw-r--r--app/assets/javascripts/issue_show/issue.js58
-rw-r--r--app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql8
-rw-r--r--app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql5
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
+ }
+}