diff options
Diffstat (limited to 'app/assets/javascripts/issues')
41 files changed, 1915 insertions, 521 deletions
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index 0a1a1324d7d..80344efc44c 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -30,6 +30,7 @@ export const issuableStatusText = { export const IssuableTypeText = { [TYPE_ISSUE]: __('issue'), + [TYPE_EPIC]: __('epic'), [TYPE_MERGE_REQUEST]: __('merge request'), [TYPE_ALERT]: __('alert'), [TYPE_INCIDENT]: __('incident'), diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue index 9febebf7e55..a756229e6ca 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -495,7 +495,6 @@ export default { :issuables-loading="isLoading" namespace="dashboard" recent-searches-storage-key="issues" - :search-input-placeholder="$options.i18n.searchPlaceholder" :search-tokens="searchTokens" :show-pagination-controls="showPaginationControls" show-work-item-type-icon diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index eec7c6bf842..3bd28c50800 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -3,21 +3,20 @@ import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; -import { TYPE_INCIDENT } from '~/issues/constants'; +import { initIssuableSidebar } from '~/issuable'; import Issue from '~/issues/issue'; import { initTitleSuggestions, initTypePopover, initTypeSelect } from '~/issues/new'; import { initRelatedMergeRequests } from '~/issues/related_merge_requests'; import { initRelatedIssues } from '~/related_issues'; -import { initIncidentApp, initIssueApp, initSentryErrorStackTrace } from '~/issues/show'; -import { parseIssuableData } from '~/issues/show/utils/parse_data'; +import { initIssuableApp, initSentryErrorStackTrace } from '~/issues/show'; import LabelsSelect from '~/labels/labels_select'; import initNotesApp from '~/notes'; import { store } from '~/notes/stores'; import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar'; +import initSidebarBundle from '~/sidebar/sidebar_bundle'; +import initWorkItemLinks from '~/work_items/components/work_item_links'; import ZenMode from '~/zen_mode'; import initAwardsApp from '~/emoji/awards_app'; -import initLinkedResources from '~/linked_resources'; import FilteredSearchServiceDesk from './filtered_search_service_desk'; export function initFilteredSearchServiceDesk() { @@ -42,33 +41,20 @@ export function initForm() { mountMilestoneDropdown(); } -export function initShow({ notesParams } = {}) { - const el = document.getElementById('js-issuable-app'); - - if (!el) { - return; - } - - const { issueType, ...issuableData } = parseIssuableData(el); - - if (issueType === TYPE_INCIDENT) { - initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }, store); - initLinkedResources(); - initRelatedIssues(TYPE_INCIDENT); - } else { - initIssueApp(issuableData, store); - } - +export function initShow() { new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new - initIssuableHeaderWarnings(store); + + initAwardsApp(document.getElementById('js-vue-awards-block')); + initIssuableApp(store); initIssuableSidebar(); - initNotesApp(notesParams); + initNotesApp(); + initRelatedIssues(); initRelatedMergeRequests(); initSentryErrorStackTrace(); - - initAwardsApp(document.getElementById('js-vue-awards-block')); + initSidebarBundle(store); + initWorkItemLinks(); import(/* webpackChunkName: 'design_management' */ '~/design_management') .then((module) => module.default()) diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js index b7fd99d8042..06bbcdc12ea 100644 --- a/app/assets/javascripts/issues/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -49,13 +49,8 @@ export default class Issue { issueFailMessage = __('Unable to update this issue at this time.'), ) { if ('id' in data) { - const isClosedBadge = $('.issuable-status-badge-closed'); - const isOpenBadge = $('.issuable-status-badge-open'); const projectIssuesCounter = $('.issue_counter'); - isClosedBadge.toggleClass('hidden', !isClosed); - isOpenBadge.toggleClass('hidden', isClosed); - $(document).trigger('issuable:change', isClosed); let numProjectIssues = Number( diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue index 9f7fca0ceca..3d62ea07f59 100644 --- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue +++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue @@ -82,7 +82,7 @@ export default { v-if="showCsvButtons" class="gl-w-full gl-sm-w-auto gl-sm-mr-3" :toggle-text="$options.i18n.importIssues" - data-qa-selector="import_issues_dropdown" + data-testid="import-issues-dropdown" > <csv-import-export-buttons :export-csv-path="exportCsvPathWithQuery" diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue index dde1a4fd2d6..22c0984ebdb 100644 --- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue @@ -10,6 +10,8 @@ import { newDateAsLocaleTime, } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; +import { STATE_CLOSED } from '~/work_items/constants'; +import { isMilestoneWidget, isStartAndDueDateWidget } from '~/work_items/utils'; export default { components: { @@ -26,9 +28,12 @@ export default { }, }, computed: { + milestone() { + return this.issue.milestone || this.issue.widgets?.find(isMilestoneWidget)?.milestone; + }, milestoneDate() { - if (this.issue.milestone?.dueDate) { - const { dueDate, startDate } = this.issue.milestone; + if (this.milestone.dueDate) { + const { dueDate, startDate } = this.milestone; const date = dateInWords(newDateAsLocaleTime(dueDate), true); const remainingTime = this.milestoneRemainingTime(dueDate, startDate); return `${date} (${remainingTime})`; @@ -36,15 +41,19 @@ export default { return __('Milestone'); }, milestoneLink() { - return this.issue.milestone.webPath || this.issue.milestone.webUrl; + return this.milestone.webPath || this.milestone.webUrl; }, dueDate() { - return this.issue.dueDate && dateInWords(newDateAsLocaleTime(this.issue.dueDate), true); + return this.issue.dueDate || this.issue.widgets?.find(isStartAndDueDateWidget)?.dueDate; + }, + dueDateText() { + return this.dueDate && dateInWords(newDateAsLocaleTime(this.dueDate), true); + }, + isClosed() { + return this.issue.state === STATUS_CLOSED || this.issue.state === STATE_CLOSED; }, showDueDateInRed() { - return ( - isInPast(newDateAsLocaleTime(this.issue.dueDate)) && this.issue.state !== STATUS_CLOSED - ); + return isInPast(newDateAsLocaleTime(this.dueDate)) && !this.isClosed; }, timeEstimate() { return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate; @@ -57,11 +66,14 @@ export default { if (dueDate && isInPast(due)) { return __('Past due'); - } else if (dueDate && isToday(due)) { + } + if (dueDate && isToday(due)) { return __('Today'); - } else if (startDate && isInFuture(start)) { + } + if (startDate && isInFuture(start)) { return __('Upcoming'); - } else if (dueDate) { + } + if (dueDate) { return getTimeRemainingInWords(due); } return ''; @@ -73,7 +85,7 @@ export default { <template> <span> <span - v-if="issue.milestone" + v-if="milestone" class="issuable-milestone gl-mr-3 gl-text-truncate gl-max-w-26 gl-display-inline-block gl-vertical-align-bottom" data-testid="issuable-milestone" > @@ -84,11 +96,11 @@ export default { class="gl-font-sm gl-text-gray-500!" > <gl-icon name="clock" :size="12" /> - {{ issue.milestone.title }} + {{ milestone.title }} </gl-link> </span> <span - v-if="issue.dueDate" + v-if="dueDate" v-gl-tooltip class="issuable-due-date gl-mr-3" :class="{ 'gl-text-red-500': showDueDateInRed }" @@ -96,7 +108,7 @@ export default { data-testid="issuable-due-date" > <gl-icon name="calendar" :size="12" /> - {{ dueDate }} + {{ dueDateText }} </span> <span v-if="timeEstimate" diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index c50b48ca0d8..3d8ed3af816 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -99,8 +99,6 @@ import { import eventHub from '../eventhub'; import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; import searchLabelsQuery from '../queries/search_labels.query.graphql'; -import searchMilestonesQuery from '../queries/search_milestones.query.graphql'; -import searchUsersQuery from '../queries/search_users.query.graphql'; import setSortPreferenceMutation from '../queries/set_sort_preference.mutation.graphql'; import { convertToApiParams, @@ -204,11 +202,6 @@ export default { required: false, default: () => [], }, - eeIsOkrsEnabled: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -411,9 +404,10 @@ export default { title: TOKEN_TITLE_MILESTONE, icon: 'clock', token: MilestoneToken, - fetchMilestones: this.fetchMilestones, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`, shouldSkipSort: true, + fullPath: this.fullPath, + isProject: this.isProject, }, { type: TOKEN_TYPE_LABEL, @@ -640,32 +634,13 @@ export default { fetchLatestLabels(search) { return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY); }, - fetchMilestones(search) { - return this.$apollo - .query({ - query: searchMilestonesQuery, - variables: { fullPath: this.fullPath, search, isProject: this.isProject }, - }) - .then(({ data }) => data[this.namespace]?.milestones.nodes); - }, fetchUsers(search) { - if (gon.features?.newGraphqlUsersAutocomplete) { - return this.$apollo - .query({ - query: usersAutocompleteQuery, - variables: { fullPath: this.fullPath, search, isProject: this.isProject }, - }) - .then(({ data }) => data[this.namespace]?.autocompleteUsers); - } - return this.$apollo .query({ - query: searchUsersQuery, + query: usersAutocompleteQuery, variables: { fullPath: this.fullPath, search, isProject: this.isProject }, }) - .then(({ data }) => - data[this.namespace]?.[`${this.namespace}Members`].nodes.map((member) => member.user), - ); + .then(({ data }) => data[this.namespace]?.autocompleteUsers); }, getExportCsvPathWithQuery() { return `${this.exportCsvPath}${window.location.search}`; @@ -966,7 +941,6 @@ export default { v-if="hasAnyIssues" :namespace="fullPath" recent-searches-storage-key="issues" - :search-input-placeholder="$options.i18n.searchPlaceholder" :search-tokens="searchTokens" :has-scoped-labels-feature="hasScopedLabelsFeature" :initial-filter-value="filterTokens" @@ -1037,14 +1011,11 @@ export default { > {{ $options.i18n.editIssues }} </gl-button> - <gl-button - v-if="showNewIssueLink && !eeIsOkrsEnabled" - :href="newIssuePath" - variant="confirm" - > - {{ $options.i18n.newIssueLabel }} - </gl-button> - <slot name="new-objective-button"></slot> + <slot name="new-issuable-button"> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </slot> <new-resource-dropdown v-if="showNewIssueDropdown" :query="$options.searchProjectsQuery" @@ -1059,7 +1030,7 @@ export default { no-caret :toggle-text="$options.i18n.actionsLabel" text-sr-only - data-qa-selector="issues_list_more_actions_dropdown" + data-testid="issues-list-more-actions-dropdown" > <csv-import-export-buttons v-if="showCsvButtons" diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 85e300b6474..682c7629962 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -121,7 +121,6 @@ export const i18n = { reorderError: __('An error occurred while reordering issues.'), deleteError: __('An error occurred while deleting an issuable.'), rssLabel: __('Subscribe to RSS feed'), - searchPlaceholder: __('Search or filter results...'), upvotes: __('Upvotes'), titles: __('Titles'), descriptions: __('Descriptions'), diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql index 3b49c0efb14..f3173f0e33a 100644 --- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql @@ -11,6 +11,7 @@ fragment IssueFragment on Issue { moved state title + titleHtml updatedAt closedAt upvotes diff --git a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql index 040240cde99..941e71b7ca7 100644 --- a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql @@ -1,6 +1,11 @@ #import "./milestone.fragment.graphql" -query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) { +query searchMilestones( + $fullPath: ID! + $search: String + $isProject: Boolean = false + $state: MilestoneStateEnum +) { group(fullPath: $fullPath) @skip(if: $isProject) { id milestones( @@ -8,7 +13,7 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa includeAncestors: true includeDescendants: true sort: EXPIRED_LAST_DUE_DATE_ASC - state: active + state: $state ) { nodes { ...Milestone @@ -21,7 +26,7 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa searchTitle: $search includeAncestors: true sort: EXPIRED_LAST_DUE_DATE_ASC - state: active + state: $state ) { nodes { ...Milestone diff --git a/app/assets/javascripts/issues/list/queries/search_users.query.graphql b/app/assets/javascripts/issues/list/queries/search_users.query.graphql deleted file mode 100644 index 6a1967a8875..00000000000 --- a/app/assets/javascripts/issues/list/queries/search_users.query.graphql +++ /dev/null @@ -1,29 +0,0 @@ -#import "./user.fragment.graphql" - -query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) { - group(fullPath: $fullPath) @skip(if: $isProject) { - id - groupMembers(search: $search, relations: [DIRECT, INHERITED, SHARED_FROM_GROUPS]) { - nodes { - id - user { - ...User - } - } - } - } - project(fullPath: $fullPath) @include(if: $isProject) { - id - projectMembers( - search: $search - relations: [DIRECT, INHERITED, INVITED_GROUPS, SHARED_INTO_ANCESTORS] - ) { - nodes { - id - user { - ...User - } - } - } - } -} diff --git a/app/assets/javascripts/issues/list/queries/user.fragment.graphql b/app/assets/javascripts/issues/list/queries/user.fragment.graphql deleted file mode 100644 index 3e5bc0f7b93..00000000000 --- a/app/assets/javascripts/issues/list/queries/user.fragment.graphql +++ /dev/null @@ -1,6 +0,0 @@ -fragment User on User { - id - avatarUrl - name - username -} 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 index d819a371c69..5e81f7ad4f6 100644 --- 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 @@ -4,7 +4,6 @@ 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', @@ -19,6 +18,11 @@ export default { type: String, required: true, }, + hasClosingMergeRequest: { + type: Boolean, + required: false, + default: false, + }, projectNamespace: { type: String, required: true, @@ -48,9 +52,6 @@ export default { this.setInitialState({ apiEndpoint: this.endpoint }); this.fetchMergeRequests(); }, - created() { - this.hasClosingMergeRequest = parseIssuableData().hasClosingMergeRequest; - }, methods: { ...mapActions(['setInitialState', 'fetchMergeRequests']), getAssignees(mr) { diff --git a/app/assets/javascripts/issues/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js index 196084093c8..413b48b9720 100644 --- a/app/assets/javascripts/issues/related_merge_requests/index.js +++ b/app/assets/javascripts/issues/related_merge_requests/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import RelatedMergeRequests from './components/related_merge_requests.vue'; import createStore from './store'; @@ -9,7 +10,7 @@ export function initRelatedMergeRequests() { return undefined; } - const { endpoint, projectPath, projectNamespace } = el.dataset; + const { endpoint, hasClosingMergeRequest, projectPath, projectNamespace } = el.dataset; return new Vue({ el, @@ -17,7 +18,12 @@ export function initRelatedMergeRequests() { store: createStore(), render: (createElement) => createElement(RelatedMergeRequests, { - props: { endpoint, projectNamespace, projectPath }, + props: { + endpoint, + hasClosingMergeRequest: parseBoolean(hasClosingMergeRequest), + projectNamespace, + projectPath, + }, }), }); } diff --git a/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue b/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue new file mode 100644 index 00000000000..ab9e70ae223 --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue @@ -0,0 +1,59 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { + noSearchResultsTitle, + noSearchResultsDescription, + infoBannerUserNote, + noOpenIssuesTitle, + noClosedIssuesTitle, +} from '../constants'; + +export default { + i18n: { + noSearchResultsTitle, + noSearchResultsDescription, + infoBannerUserNote, + noOpenIssuesTitle, + noClosedIssuesTitle, + }, + components: { + GlEmptyState, + }, + inject: ['emptyStateSvgPath'], + props: { + hasSearch: { + type: Boolean, + required: true, + }, + isOpenTab: { + type: Boolean, + required: true, + }, + }, + computed: { + content() { + if (this.hasSearch) { + return { + title: noSearchResultsTitle, + description: noSearchResultsDescription, + svgHeight: 150, + }; + } + if (this.isOpenTab) { + return { title: noOpenIssuesTitle, description: infoBannerUserNote }; + } + + return { title: noClosedIssuesTitle, svgHeight: 150 }; + }, + }, +}; +</script> + +<template> + <gl-empty-state + :description="content.description" + :title="content.title" + :svg-path="emptyStateSvgPath" + :svg-height="content.svgHeight" + /> +</template> diff --git a/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue new file mode 100644 index 00000000000..9dbed2c2579 --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue @@ -0,0 +1,74 @@ +<script> +import { GlEmptyState, GlLink } from '@gitlab/ui'; +import { + noIssuesSignedOutButtonText, + infoBannerTitle, + infoBannerUserNote, + infoBannerAdminNote, + learnMore, +} from '../constants'; + +export default { + i18n: { + noIssuesSignedOutButtonText, + infoBannerTitle, + infoBannerUserNote, + infoBannerAdminNote, + learnMore, + }, + components: { + GlEmptyState, + GlLink, + }, + inject: [ + 'emptyStateSvgPath', + 'isSignedIn', + 'signInPath', + 'canAdminIssues', + 'isServiceDeskEnabled', + 'serviceDeskEmailAddress', + 'serviceDeskHelpPath', + ], + computed: { + canSeeEmailAddress() { + return this.canAdminIssues && this.isServiceDeskEnabled; + }, + }, +}; +</script> + +<template> + <div v-if="isSignedIn"> + <gl-empty-state + :title="$options.i18n.infoBannerTitle" + :svg-path="emptyStateSvgPath" + content-class="gl-max-w-80!" + > + <template #description> + <p v-if="canSeeEmailAddress"> + {{ $options.i18n.infoBannerAdminNote }} <br /><code>{{ serviceDeskEmailAddress }}</code> + </p> + <p>{{ $options.i18n.infoBannerUserNote }}</p> + <gl-link :href="serviceDeskHelpPath"> + {{ $options.i18n.learnMore }} + </gl-link> + </template> + </gl-empty-state> + </div> + + <gl-empty-state + v-else + :title="$options.i18n.infoBannerTitle" + :svg-path="emptyStateSvgPath" + :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" + :primary-button-link="signInPath" + content-class="gl-max-w-80!" + > + <template #description> + <p>{{ $options.i18n.infoBannerUserNote }}</p> + <gl-link :href="serviceDeskHelpPath"> + {{ $options.i18n.learnMore }} + </gl-link> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/issues/service_desk/components/info_banner.vue b/app/assets/javascripts/issues/service_desk/components/info_banner.vue new file mode 100644 index 00000000000..5667ee2f31d --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/components/info_banner.vue @@ -0,0 +1,64 @@ +<script> +import { GlLink, GlButton } from '@gitlab/ui'; +import { + infoBannerTitle, + infoBannerAdminNote, + infoBannerUserNote, + enableServiceDesk, + learnMore, +} from '../constants'; + +export default { + name: 'InfoBanner', + components: { + GlLink, + GlButton, + }, + inject: [ + 'serviceDeskCalloutSvgPath', + 'serviceDeskEmailAddress', + 'canAdminIssues', + 'canEditProjectSettings', + 'serviceDeskSettingsPath', + 'isServiceDeskEnabled', + 'serviceDeskHelpPath', + ], + i18n: { infoBannerTitle, infoBannerAdminNote, infoBannerUserNote, enableServiceDesk, learnMore }, + computed: { + canSeeEmailAddress() { + return this.canAdminIssues && this.isServiceDeskEnabled; + }, + canEnableServiceDesk() { + return this.canEditProjectSettings && !this.isServiceDeskEnabled; + }, + }, +}; +</script> + +<template> + <div class="gl-border-b gl-pb-3 gl-display-flex gl-align-items-flex-start"> + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> + <img + :src="serviceDeskCalloutSvgPath" + alt="" + class="gl-display-none gl-sm-display-block gl-p-5" + /> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + <div class="gl-mt-3 gl-ml-3"> + <h5>{{ $options.i18n.infoBannerTitle }}</h5> + <p v-if="canSeeEmailAddress"> + {{ $options.i18n.infoBannerAdminNote }} <code>{{ serviceDeskEmailAddress }}</code> + </p> + <p> + {{ $options.i18n.infoBannerUserNote }} + <gl-link :href="serviceDeskHelpPath">{{ $options.i18n.learnMore }}</gl-link + >. + </p> + <p v-if="canEnableServiceDesk" class="gl-mt-3"> + <gl-button :href="serviceDeskSettingsPath" variant="confirm">{{ + $options.i18n.enableServiceDesk + }}</gl-button> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue new file mode 100644 index 00000000000..4b59672428b --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue @@ -0,0 +1,599 @@ +<script> +import * as Sentry from '@sentry/browser'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { isEmpty } from 'lodash'; +import { fetchPolicies } from '~/lib/graphql'; +import { isPositiveInteger } from '~/lib/utils/number_utils'; +import axios from '~/lib/utils/axios_utils'; +import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants'; +import { + convertToSearchQuery, + convertToApiParams, + getInitialPageParams, + getFilterTokens, + isSortKey, + getSortOptions, + getSortKey, +} from '~/issues/list/utils'; +import { + OPERATORS_IS_NOT, + OPERATORS_IS_NOT_OR, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { + MAX_LIST_SIZE, + ISSUE_REFERENCE, + PARAM_STATE, + PARAM_FIRST_PAGE_SIZE, + PARAM_LAST_PAGE_SIZE, + PARAM_PAGE_AFTER, + PARAM_PAGE_BEFORE, + PARAM_SORT, + CREATED_DESC, + UPDATED_DESC, + RELATIVE_POSITION_ASC, + urlSortParams, +} from '~/issues/list/constants'; +import { createAlert, VARIANT_INFO } from '~/alert'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; +import searchProjectMembers from '~/graphql_shared/queries/project_user_members_search.query.graphql'; +import getServiceDeskIssuesQuery from 'ee_else_ce/issues/service_desk/queries/get_service_desk_issues.query.graphql'; +import getServiceDeskIssuesCounts from 'ee_else_ce/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql'; +import searchProjectLabelsQuery from '../queries/search_project_labels.query.graphql'; +import searchProjectMilestonesQuery from '../queries/search_project_milestones.query.graphql'; +import setSortingPreferenceMutation from '../queries/set_sorting_preference.mutation.graphql'; +import reorderServiceDeskIssuesMutation from '../queries/reorder_service_desk_issues.mutation.graphql'; +import { + errorFetchingCounts, + errorFetchingIssues, + issueRepositioningMessage, + reorderError, + SERVICE_DESK_BOT_USERNAME, + STATUS_OPEN, + STATUS_CLOSED, + STATUS_ALL, + WORKSPACE_PROJECT, +} from '../constants'; +import { convertToUrlParams } from '../utils'; +import { + searchWithinTokenBase, + assigneeTokenBase, + milestoneTokenBase, + labelTokenBase, + releaseTokenBase, + reactionTokenBase, + confidentialityTokenBase, +} from '../search_tokens'; +import InfoBanner from './info_banner.vue'; +import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue'; +import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue'; + +export default { + i18n: { + errorFetchingCounts, + errorFetchingIssues, + issueRepositioningMessage, + reorderError, + }, + issuableListTabs, + components: { + IssuableList, + InfoBanner, + EmptyStateWithAnyIssues, + EmptyStateWithoutAnyIssues, + }, + mixins: [glFeatureFlagMixin()], + inject: [ + 'releasesPath', + 'autocompleteAwardEmojisPath', + 'hasBlockedIssuesFeature', + 'hasIterationsFeature', + 'hasIssueWeightsFeature', + 'hasIssuableHealthStatusFeature', + 'groupPath', + 'emptyStateSvgPath', + 'isProject', + 'isSignedIn', + 'fullPath', + 'isServiceDeskSupported', + 'hasAnyIssues', + 'initialSort', + 'isIssueRepositioningDisabled', + ], + props: { + eeSearchTokens: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + serviceDeskIssues: [], + serviceDeskIssuesCounts: {}, + filterTokens: [], + pageInfo: {}, + pageParams: {}, + sortKey: CREATED_DESC, + state: STATUS_OPEN, + pageSize: DEFAULT_PAGE_SIZE, + issuesError: '', + }; + }, + apollo: { + serviceDeskIssues: { + query: getServiceDeskIssuesQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data.project.issues.nodes ?? []; + }, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + // We need this for handling loading state when using frontend cache + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details + notifyOnNetworkStatusChange: true, + result({ data }) { + if (!data) { + return; + } + this.pageInfo = data?.project.issues.pageInfo ?? {}; + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingIssues; + Sentry.captureException(error); + }, + skip() { + return this.shouldSkipQuery; + }, + }, + serviceDeskIssuesCounts: { + query: getServiceDeskIssuesCounts, + variables() { + return this.queryVariables; + }, + update(data) { + return data?.project ?? {}; + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingCounts; + Sentry.captureException(error); + }, + skip() { + return this.shouldSkipQuery; + }, + context: { + isSingleRequest: true, + }, + }, + }, + computed: { + queryVariables() { + const isIidSearch = ISSUE_REFERENCE.test(this.searchQuery); + return { + fullPath: this.fullPath, + iid: isIidSearch ? this.searchQuery.slice(1) : undefined, + isSignedIn: this.isSignedIn, + authorUsername: SERVICE_DESK_BOT_USERNAME, + sort: this.sortKey, + state: this.state, + ...this.pageParams, + ...this.apiFilterParams, + search: isIidSearch ? undefined : this.searchQuery, + }; + }, + shouldSkipQuery() { + return !this.hasAnyIssues || isEmpty(this.pageParams); + }, + sortOptions() { + return getSortOptions({ + hasBlockedIssuesFeature: this.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: this.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: this.hasIssueWeightsFeature, + }); + }, + tabCounts() { + const { openedIssues, closedIssues, allIssues } = this.serviceDeskIssuesCounts; + return { + [STATUS_OPEN]: openedIssues?.count, + [STATUS_CLOSED]: closedIssues?.count, + [STATUS_ALL]: allIssues?.count, + }; + }, + currentTabCount() { + return this.tabCounts[this.state] ?? 0; + }, + showPaginationControls() { + return ( + this.serviceDeskIssues.length > 0 && + (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage) + ); + }, + showPageSizeControls() { + return this.currentTabCount > DEFAULT_PAGE_SIZE; + }, + isLoading() { + return this.$apollo.loading; + }, + isOpenTab() { + return this.state === STATUS_OPEN; + }, + urlParams() { + return { + sort: urlSortParams[this.sortKey], + state: this.state, + ...this.urlFilterParams, + first_page_size: this.pageParams.firstPageSize, + last_page_size: this.pageParams.lastPageSize, + page_after: this.pageParams.afterCursor ?? undefined, + page_before: this.pageParams.beforeCursor ?? undefined, + }; + }, + hasAnyServiceDeskIssue() { + return this.hasSearch || Boolean(this.tabCounts.all); + }, + isInfoBannerVisible() { + return this.isServiceDeskSupported && this.hasAnyServiceDeskIssue; + }, + canShowIssuesList() { + return this.isLoading || this.issuesError.length || this.hasAnyServiceDeskIssue; + }, + hasOrFeature() { + return this.glFeatures.orIssuableQueries; + }, + hasSearch() { + return Boolean( + this.searchQuery || + Object.keys(this.urlFilterParams).length || + this.pageParams.afterCursor || + this.pageParams.beforeCursor, + ); + }, + apiFilterParams() { + return convertToApiParams(this.filterTokens); + }, + urlFilterParams() { + return convertToUrlParams(this.filterTokens); + }, + searchQuery() { + return convertToSearchQuery(this.filterTokens); + }, + searchTokens() { + const preloadedUsers = []; + + if (gon.current_user_id) { + preloadedUsers.push({ + id: convertToGraphQLId(TYPENAME_USER, gon.current_user_id), + name: gon.current_user_fullname, + username: gon.current_username, + avatar_url: gon.current_user_avatar_url, + }); + } + + const tokens = [ + { + ...searchWithinTokenBase, + }, + { + ...assigneeTokenBase, + operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, + fetchUsers: this.fetchUsers, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, + preloadedUsers, + }, + { + ...milestoneTokenBase, + fetchMilestones: this.fetchMilestones, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`, + }, + { + ...labelTokenBase, + operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, + fetchLabels: this.fetchLabels, + fetchLatestLabels: this.glFeatures.frontendCaching ? this.fetchLatestLabels : null, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, + }, + ]; + + if (this.isProject) { + tokens.push({ + ...releaseTokenBase, + fetchReleases: this.fetchReleases, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-release`, + }); + } + + if (this.isSignedIn) { + tokens.push({ + ...reactionTokenBase, + fetchEmojis: this.fetchEmojis, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-my_reaction`, + }); + + tokens.push({ + ...confidentialityTokenBase, + }); + } + + if (this.eeSearchTokens.length) { + tokens.push(...this.eeSearchTokens); + } + + tokens.sort((a, b) => a.title.localeCompare(b.title)); + + return tokens; + }, + isManualOrdering() { + return this.sortKey === RELATIVE_POSITION_ASC; + }, + }, + watch: { + $route(newValue, oldValue) { + if (newValue.fullPath !== oldValue.fullPath) { + this.updateData(getParameterByName(PARAM_SORT)); + } + }, + }, + created() { + this.updateData(this.initialSort); + this.cache = {}; + }, + methods: { + fetchWithCache(path, cacheName, searchKey, search) { + if (this.cache[cacheName]) { + const data = search + ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey }) + : this.cache[cacheName].slice(0, MAX_LIST_SIZE); + return Promise.resolve(data); + } + + return axios.get(path).then(({ data }) => { + this.cache[cacheName] = data; + return data.slice(0, MAX_LIST_SIZE); + }); + }, + fetchUsers(search) { + return this.$apollo + .query({ + query: searchProjectMembers, + variables: { fullPath: this.fullPath, search }, + }) + .then(({ data }) => + data[WORKSPACE_PROJECT]?.[`${WORKSPACE_PROJECT}Members`].nodes.map( + (member) => member.user, + ), + ); + }, + fetchMilestones(search) { + return this.$apollo + .query({ + query: searchProjectMilestonesQuery, + variables: { fullPath: this.fullPath, search }, + }) + .then(({ data }) => data[WORKSPACE_PROJECT]?.milestones.nodes); + }, + fetchEmojis(search) { + return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search); + }, + fetchReleases(search) { + return this.fetchWithCache(this.releasesPath, 'releases', 'tag', search); + }, + fetchLabelsWithFetchPolicy(search, fetchPolicy = fetchPolicies.CACHE_FIRST) { + return this.$apollo + .query({ + query: searchProjectLabelsQuery, + variables: { fullPath: this.fullPath, search }, + fetchPolicy, + }) + .then(({ data }) => data[WORKSPACE_PROJECT]?.labels.nodes) + .then((labels) => + // TODO remove once we can search by title-only on the backend + // https://gitlab.com/gitlab-org/gitlab/-/issues/346353 + labels.filter((label) => label.title.toLowerCase().includes(search.toLowerCase())), + ); + }, + fetchLabels(search) { + return this.fetchLabelsWithFetchPolicy(search); + }, + fetchLatestLabels(search) { + return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY); + }, + handleClickTab(state) { + if (this.state === state) { + return; + } + this.state = state; + this.pageParams = getInitialPageParams(this.pageSize); + + this.$router.push({ query: this.urlParams }); + }, + handleFilter(tokens) { + this.filterTokens = tokens; + this.pageParams = getInitialPageParams(this.pageSize); + + this.$router.push({ query: this.urlParams }); + }, + handleDismissAlert() { + this.issuesError = ''; + }, + handleNextPage() { + this.pageParams = { + afterCursor: this.pageInfo.endCursor, + firstPageSize: this.pageSize, + }; + scrollUp(); + + this.$router.push({ query: this.urlParams }); + }, + handlePreviousPage() { + this.pageParams = { + beforeCursor: this.pageInfo.startCursor, + lastPageSize: this.pageSize, + }; + scrollUp(); + + this.$router.push({ query: this.urlParams }); + }, + handlePageSizeChange(newPageSize) { + const pageParam = getParameterByName(PARAM_LAST_PAGE_SIZE) ? 'lastPageSize' : 'firstPageSize'; + this.pageParams[pageParam] = newPageSize; + this.pageSize = newPageSize; + scrollUp(); + + this.$router.push({ query: this.urlParams }); + }, + handleSort(sortKey) { + if (this.sortKey === sortKey) { + return; + } + + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + return; + } + + this.sortKey = sortKey; + this.pageParams = getInitialPageParams(this.pageSize); + + if (this.isSignedIn) { + this.saveSortPreference(sortKey); + } + + this.$router.push({ query: this.urlParams }); + }, + saveSortPreference(sortKey) { + this.$apollo + .mutate({ + mutation: setSortingPreferenceMutation, + variables: { input: { issuesSort: sortKey } }, + }) + .then(({ data }) => { + if (data.userPreferencesUpdate.errors.length) { + throw new Error(data.userPreferencesUpdate.errors); + } + }) + .catch((error) => { + Sentry.captureException(error); + }); + }, + handleReorder({ newIndex, oldIndex }) { + const issueToMove = this.serviceDeskIssues[oldIndex]; + const isDragDropDownwards = newIndex > oldIndex; + const isMovingToBeginning = newIndex === 0; + const isMovingToEnd = newIndex === this.serviceDeskIssues.length - 1; + + let moveBeforeId; + let moveAfterId; + + if (isDragDropDownwards) { + const afterIndex = isMovingToEnd ? newIndex : newIndex + 1; + moveBeforeId = this.serviceDeskIssues[newIndex].id; + moveAfterId = this.serviceDeskIssues[afterIndex].id; + } else { + const beforeIndex = isMovingToBeginning ? newIndex : newIndex - 1; + moveBeforeId = this.serviceDeskIssues[beforeIndex].id; + moveAfterId = this.serviceDeskIssues[newIndex].id; + } + + return axios + .put(joinPaths(issueToMove.webPath, 'reorder'), { + move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId), + move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId), + }) + .then(() => { + const serializedVariables = JSON.stringify(this.queryVariables); + return this.$apollo.mutate({ + mutation: reorderServiceDeskIssuesMutation, + variables: { oldIndex, newIndex, namespace: this.namespace, serializedVariables }, + }); + }) + .catch((error) => { + this.issuesError = this.$options.i18n.reorderError; + Sentry.captureException(error); + }); + }, + updateData(sortValue) { + const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE); + const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE); + const state = getParameterByName(PARAM_STATE); + + const defaultSortKey = state === STATUS_CLOSED ? UPDATED_DESC : CREATED_DESC; + const dashboardSortKey = getSortKey(sortValue); + const graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase(); + + let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; + + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + sortKey = defaultSortKey; + } + + this.filterTokens = getFilterTokens(window.location.search); + + this.pageParams = getInitialPageParams( + this.pageSize, + isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined, + isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined, + getParameterByName(PARAM_PAGE_AFTER), + getParameterByName(PARAM_PAGE_BEFORE), + ); + this.sortKey = sortKey; + this.state = state || STATUS_OPEN; + }, + showIssueRepositioningMessage() { + createAlert({ + message: this.$options.i18n.issueRepositioningMessage, + variant: VARIANT_INFO, + }); + }, + }, +}; +</script> + +<template> + <section> + <info-banner v-if="isInfoBannerVisible" /> + <issuable-list + v-if="canShowIssuesList" + namespace="service-desk" + recent-searches-storage-key="service-desk-issues" + :error="issuesError" + :search-tokens="searchTokens" + :issuables-loading="isLoading" + :initial-filter-value="filterTokens" + :show-filtered-search-friendly-text="hasOrFeature" + :show-pagination-controls="showPaginationControls" + :show-page-size-change-controls="showPageSizeControls" + :sort-options="sortOptions" + :initial-sort-by="sortKey" + :is-manual-ordering="isManualOrdering" + :issuables="serviceDeskIssues" + :tabs="$options.issuableListTabs" + :tab-counts="tabCounts" + :current-tab="state" + :default-page-size="pageSize" + :has-next-page="pageInfo.hasNextPage" + :has-previous-page="pageInfo.hasPreviousPage" + sync-filter-and-sort + use-keyset-pagination + @click-tab="handleClickTab" + @dismiss-alert="handleDismissAlert" + @filter="handleFilter" + @sort="handleSort" + @reorder="handleReorder" + @next-page="handleNextPage" + @previous-page="handlePreviousPage" + @page-size-change="handlePageSizeChange" + > + <template #empty-state> + <empty-state-with-any-issues :has-search="hasSearch" :is-open-tab="isOpenTab" /> + </template> + </issuable-list> + + <empty-state-without-any-issues v-else /> + </section> +</template> diff --git a/app/assets/javascripts/issues/service_desk/constants.js b/app/assets/javascripts/issues/service_desk/constants.js new file mode 100644 index 00000000000..e498a4f39a1 --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/constants.js @@ -0,0 +1,254 @@ +import { __, s__ } from '~/locale'; +import { + FILTERED_SEARCH_TERM, + OPERATOR_IS, + OPERATOR_NOT, + OPERATOR_OR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_EPIC, + TOKEN_TYPE_HEALTH, + TOKEN_TYPE_ITERATION, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_TYPE, + TOKEN_TYPE_WEIGHT, + TOKEN_TYPE_SEARCH_WITHIN, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { + ALTERNATIVE_FILTER, + API_PARAM, + NORMAL_FILTER, + SPECIAL_FILTER, + URL_PARAM, +} from '~/issues/list/constants'; + +export const SERVICE_DESK_BOT_USERNAME = 'support-bot'; +export const ISSUE_REFERENCE = /^#\d+$/; + +export const STATUS_ALL = 'all'; +export const STATUS_CLOSED = 'closed'; +export const STATUS_OPEN = 'opened'; + +export const WORKSPACE_PROJECT = 'project'; + +export const filtersMap = { + [FILTERED_SEARCH_TERM]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'search', + }, + [URL_PARAM]: { + [undefined]: { + [NORMAL_FILTER]: 'search', + }, + }, + }, + [TOKEN_TYPE_SEARCH_WITHIN]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'in', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'in', + }, + }, + }, + [TOKEN_TYPE_ASSIGNEE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'assigneeUsernames', + [SPECIAL_FILTER]: 'assigneeId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'assignee_username[]', + [SPECIAL_FILTER]: 'assignee_id', + [ALTERNATIVE_FILTER]: 'assignee_username', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[assignee_username][]', + }, + [OPERATOR_OR]: { + [NORMAL_FILTER]: 'or[assignee_username][]', + }, + }, + }, + [TOKEN_TYPE_MILESTONE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'milestoneTitle', + [SPECIAL_FILTER]: 'milestoneWildcardId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'milestone_title', + [SPECIAL_FILTER]: 'milestone_title', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[milestone_title]', + [SPECIAL_FILTER]: 'not[milestone_title]', + }, + }, + }, + [TOKEN_TYPE_LABEL]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'labelName', + [SPECIAL_FILTER]: 'labelName', + [ALTERNATIVE_FILTER]: 'labelNames', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'label_name[]', + [SPECIAL_FILTER]: 'label_name[]', + [ALTERNATIVE_FILTER]: 'label_name', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[label_name][]', + }, + [OPERATOR_OR]: { + [ALTERNATIVE_FILTER]: 'or[label_name][]', + }, + }, + }, + [TOKEN_TYPE_TYPE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'types', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'type[]', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[type][]', + }, + }, + }, + [TOKEN_TYPE_RELEASE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'releaseTag', + [SPECIAL_FILTER]: 'releaseTagWildcardId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'release_tag', + [SPECIAL_FILTER]: 'release_tag', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[release_tag]', + }, + }, + }, + [TOKEN_TYPE_MY_REACTION]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'myReactionEmoji', + [SPECIAL_FILTER]: 'myReactionEmoji', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'my_reaction_emoji', + [SPECIAL_FILTER]: 'my_reaction_emoji', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[my_reaction_emoji]', + }, + }, + }, + [TOKEN_TYPE_CONFIDENTIAL]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'confidential', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'confidential', + }, + }, + }, + [TOKEN_TYPE_ITERATION]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'iterationId', + [SPECIAL_FILTER]: 'iterationWildcardId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'iteration_id', + [SPECIAL_FILTER]: 'iteration_id', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[iteration_id]', + [SPECIAL_FILTER]: 'not[iteration_id]', + }, + }, + }, + [TOKEN_TYPE_EPIC]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'epicId', + [SPECIAL_FILTER]: 'epicId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'epic_id', + [SPECIAL_FILTER]: 'epic_id', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[epic_id]', + }, + }, + }, + [TOKEN_TYPE_WEIGHT]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'weight', + [SPECIAL_FILTER]: 'weight', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'weight', + [SPECIAL_FILTER]: 'weight', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[weight]', + }, + }, + }, + [TOKEN_TYPE_HEALTH]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'healthStatusFilter', + [SPECIAL_FILTER]: 'healthStatusFilter', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'health_status', + [SPECIAL_FILTER]: 'health_status', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[health_status]', + }, + }, + }, +}; + +export const errorFetchingCounts = __('An error occurred while getting issue counts'); +export const errorFetchingIssues = __('An error occurred while loading issues'); +export const noOpenIssuesTitle = __('There are no open issues'); +export const noClosedIssuesTitle = __('There are no closed issues'); +export const noIssuesSignedOutButtonText = __('Register / Sign In'); +export const noSearchResultsDescription = __( + 'To widen your search, change or remove filters above', +); +export const noSearchResultsTitle = __('Sorry, your filter produced no results'); +export const issueRepositioningMessage = __( + 'Issues are being rebalanced at the moment, so manual reordering is disabled.', +); +export const reorderError = __('An error occurred while reordering issues.'); +export const infoBannerTitle = s__( + 'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab', +); +export const infoBannerAdminNote = s__('ServiceDesk|Your users can send emails to this address:'); +export const infoBannerUserNote = s__( + 'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.', +); +export const enableServiceDesk = s__('ServiceDesk|Enable Service Desk'); +export const learnMore = __('Learn more about Service Desk'); +export const titles = __('Titles'); +export const descriptions = __('Descriptions'); +export const no = __('No'); +export const yes = __('Yes'); diff --git a/app/assets/javascripts/issues/service_desk/graphql.js b/app/assets/javascripts/issues/service_desk/graphql.js new file mode 100644 index 00000000000..e01973f1e8a --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/graphql.js @@ -0,0 +1,24 @@ +import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql'; + +let client; + +const typePolicies = { + Project: { + fields: { + issues: { + merge: true, + }, + }, + }, +}; + +export async function gqlClient() { + if (client) return client; + client = gon.features?.frontendCaching + ? await createApolloClientWithCaching( + {}, + { localCacheKey: 'service_desk_list', cacheConfig: { typePolicies } }, + ) + : createDefaultClient({}, { cacheConfig: { typePolicies } }); + return client; +} diff --git a/app/assets/javascripts/issues/service_desk/index.js b/app/assets/javascripts/issues/service_desk/index.js new file mode 100644 index 00000000000..579cf343477 --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/index.js @@ -0,0 +1,82 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ServiceDeskListApp from 'ee_else_ce/issues/service_desk/components/service_desk_list_app.vue'; +import { gqlClient } from './graphql'; + +export async function mountServiceDeskListApp() { + const el = document.querySelector('.js-service-desk-list'); + + if (!el) { + return null; + } + + const { + projectDataReleasesPath, + projectDataAutocompleteAwardEmojisPath, + projectDataHasBlockedIssuesFeature, + projectDataHasIterationsFeature, + projectDataHasIssueWeightsFeature, + projectDataHasIssuableHealthStatusFeature, + projectDataGroupPath, + projectDataEmptyStateSvgPath, + projectDataFullPath, + projectDataIsProject, + projectDataIsSignedIn, + projectDataSignInPath, + projectDataHasAnyIssues, + projectDataInitialSort, + projectDataIsIssueRepositioningDisabled, + serviceDeskEmailAddress, + canAdminIssues, + canEditProjectSettings, + serviceDeskCalloutSvgPath, + serviceDeskSettingsPath, + serviceDeskHelpPath, + isServiceDeskSupported, + isServiceDeskEnabled, + } = el.dataset; + + Vue.use(VueApollo); + Vue.use(VueRouter); + + return new Vue({ + el, + name: 'ServiceDeskListRoot', + apolloProvider: new VueApollo({ + defaultClient: await gqlClient(), + }), + router: new VueRouter({ + base: window.location.pathname, + mode: 'history', + routes: [{ path: '/' }], + }), + provide: { + releasesPath: projectDataReleasesPath, + autocompleteAwardEmojisPath: projectDataAutocompleteAwardEmojisPath, + hasBlockedIssuesFeature: parseBoolean(projectDataHasBlockedIssuesFeature), + hasIterationsFeature: parseBoolean(projectDataHasIterationsFeature), + hasIssueWeightsFeature: parseBoolean(projectDataHasIssueWeightsFeature), + hasIssuableHealthStatusFeature: parseBoolean(projectDataHasIssuableHealthStatusFeature), + groupPath: projectDataGroupPath, + emptyStateSvgPath: projectDataEmptyStateSvgPath, + fullPath: projectDataFullPath, + isProject: parseBoolean(projectDataIsProject), + isSignedIn: parseBoolean(projectDataIsSignedIn), + serviceDeskEmailAddress, + canAdminIssues: parseBoolean(canAdminIssues), + canEditProjectSettings: parseBoolean(canEditProjectSettings), + serviceDeskCalloutSvgPath, + serviceDeskSettingsPath, + serviceDeskHelpPath, + isServiceDeskSupported: parseBoolean(isServiceDeskSupported), + isServiceDeskEnabled: parseBoolean(isServiceDeskEnabled), + signInPath: projectDataSignInPath, + hasAnyIssues: parseBoolean(projectDataHasAnyIssues), + initialSort: projectDataInitialSort, + isIssueRepositioningDisabled: parseBoolean(projectDataIsIssueRepositioningDisabled), + }, + render: (createComponent) => createComponent(ServiceDeskListApp), + }); +} diff --git a/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues.query.graphql b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues.query.graphql new file mode 100644 index 00000000000..d8cd28f5cf1 --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues.query.graphql @@ -0,0 +1,67 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "./issue.fragment.graphql" + +query getServiceDeskIssues( + $hideUsers: Boolean = false + $isSignedIn: Boolean = false + $fullPath: ID! + $iid: String + $search: String + $sort: IssueSort + $state: IssuableState + $in: [IssuableSearchableField!] + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $confidential: Boolean + $labelName: [String] + $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $releaseTag: [String!] + $releaseTagWildcardId: ReleaseTagWildcardId + $types: [IssueType!] + $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput + $beforeCursor: String + $afterCursor: String + $firstPageSize: Int + $lastPageSize: Int +) { + project(fullPath: $fullPath) @persist { + id + issues( + iid: $iid + search: $search + sort: $sort + state: $state + in: $in + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + or: $or + before: $beforeCursor + after: $afterCursor + first: $firstPageSize + last: $lastPageSize + ) { + __persist + pageInfo { + ...PageInfo + } + nodes { + __persist + ...IssueFragment + } + } + } +} diff --git a/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql new file mode 100644 index 00000000000..008cde60b74 --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql @@ -0,0 +1,82 @@ +query getServiceDeskIssuesCount( + $fullPath: ID! + $iid: String + $search: String + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $confidential: Boolean + $labelName: [String] + $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $releaseTag: [String!] + $releaseTagWildcardId: ReleaseTagWildcardId + $types: [IssueType!] + $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput +) { + project(fullPath: $fullPath) { + id + openedIssues: issues( + state: opened + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + or: $or + ) { + count + } + closedIssues: issues( + state: closed + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + or: $or + ) { + count + } + allIssues: issues( + state: all + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + or: $or + ) { + count + } + } +} diff --git a/app/assets/javascripts/issues/service_desk/queries/issue.fragment.graphql b/app/assets/javascripts/issues/service_desk/queries/issue.fragment.graphql new file mode 100644 index 00000000000..f72663ae5f6 --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/queries/issue.fragment.graphql @@ -0,0 +1,61 @@ +fragment IssueFragment on Issue { + id + iid + confidential + createdAt + downvotes + dueDate + hidden + humanTimeEstimate + mergeRequestsCount + moved + state + title + updatedAt + closedAt + upvotes + userDiscussionsCount @include(if: $isSignedIn) + webPath + webUrl + type + assignees @skip(if: $hideUsers) { + nodes { + __persist + id + avatarUrl + name + username + webUrl + } + } + author @skip(if: $hideUsers) { + __persist + id + avatarUrl + name + username + webUrl + } + externalAuthor + labels { + nodes { + __persist + id + color + title + description + } + } + milestone { + __persist + id + dueDate + startDate + webPath + title + } + taskCompletionStatus { + completedCount + count + } +} diff --git a/app/assets/javascripts/issues/service_desk/queries/label.fragment.graphql b/app/assets/javascripts/issues/service_desk/queries/label.fragment.graphql new file mode 100644 index 00000000000..bb1d8f1ac9b --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/queries/label.fragment.graphql @@ -0,0 +1,6 @@ +fragment Label on Label { + id + color + textColor + title +} diff --git a/app/assets/javascripts/issues/service_desk/queries/milestone.fragment.graphql b/app/assets/javascripts/issues/service_desk/queries/milestone.fragment.graphql new file mode 100644 index 00000000000..3cdf69bf585 --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/queries/milestone.fragment.graphql @@ -0,0 +1,4 @@ +fragment Milestone on Milestone { + id + title +} diff --git a/app/assets/javascripts/issues/service_desk/queries/reorder_service_desk_issues.mutation.graphql b/app/assets/javascripts/issues/service_desk/queries/reorder_service_desk_issues.mutation.graphql new file mode 100644 index 00000000000..2da7850d77d --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/queries/reorder_service_desk_issues.mutation.graphql @@ -0,0 +1,13 @@ +mutation reorderServiceDeskIssues( + $oldIndex: Int + $newIndex: Int + $namespace: String + $serializedVariables: String +) { + reorderIssues( + oldIndex: $oldIndex + newIndex: $newIndex + namespace: $namespace + serializedVariables: $serializedVariables + ) @client +} diff --git a/app/assets/javascripts/issues/service_desk/queries/search_project_labels.query.graphql b/app/assets/javascripts/issues/service_desk/queries/search_project_labels.query.graphql new file mode 100644 index 00000000000..89ce14134b4 --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/queries/search_project_labels.query.graphql @@ -0,0 +1,14 @@ +#import "./label.fragment.graphql" + +query searchProjectLabels($fullPath: ID!, $search: String) { + project(fullPath: $fullPath) @persist { + id + labels(searchTerm: $search, includeAncestorGroups: true) { + __persist + nodes { + __persist + ...Label + } + } + } +} diff --git a/app/assets/javascripts/issues/service_desk/queries/search_project_milestones.query.graphql b/app/assets/javascripts/issues/service_desk/queries/search_project_milestones.query.graphql new file mode 100644 index 00000000000..f34166be87d --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/queries/search_project_milestones.query.graphql @@ -0,0 +1,17 @@ +#import "./milestone.fragment.graphql" + +query searchProjectMilestones($fullPath: ID!, $search: String) { + project(fullPath: $fullPath) { + id + milestones( + searchTitle: $search + includeAncestors: true + sort: EXPIRED_LAST_DUE_DATE_ASC + state: active + ) { + nodes { + ...Milestone + } + } + } +} diff --git a/app/assets/javascripts/issues/service_desk/queries/set_sorting_preference.mutation.graphql b/app/assets/javascripts/issues/service_desk/queries/set_sorting_preference.mutation.graphql new file mode 100644 index 00000000000..b01ae3863cd --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/queries/set_sorting_preference.mutation.graphql @@ -0,0 +1,5 @@ +mutation setSortingPreference($input: UserPreferencesUpdateInput!) { + userPreferencesUpdate(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/issues/service_desk/search_tokens.js b/app/assets/javascripts/issues/service_desk/search_tokens.js new file mode 100644 index 00000000000..72750f518e4 --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/search_tokens.js @@ -0,0 +1,97 @@ +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { + OPERATORS_IS, + TOKEN_TITLE_ASSIGNEE, + TOKEN_TITLE_CONFIDENTIAL, + TOKEN_TITLE_LABEL, + TOKEN_TITLE_MILESTONE, + TOKEN_TITLE_MY_REACTION, + TOKEN_TITLE_RELEASE, + TOKEN_TITLE_SEARCH_WITHIN, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_SEARCH_WITHIN, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { titles, descriptions, yes, no } from './constants'; + +const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); +const EmojiToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); +const LabelToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'); +const MilestoneToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); +const ReleaseToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'); + +export const searchWithinTokenBase = { + type: TOKEN_TYPE_SEARCH_WITHIN, + title: TOKEN_TITLE_SEARCH_WITHIN, + icon: 'search', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATORS_IS, + options: [ + { icon: 'title', value: 'TITLE', title: titles }, + { + icon: 'text-description', + value: 'DESCRIPTION', + title: descriptions, + }, + ], +}; + +export const assigneeTokenBase = { + type: TOKEN_TYPE_ASSIGNEE, + title: TOKEN_TITLE_ASSIGNEE, + icon: 'user', + token: UserToken, + dataType: 'user', +}; + +export const milestoneTokenBase = { + type: TOKEN_TYPE_MILESTONE, + title: TOKEN_TITLE_MILESTONE, + icon: 'clock', + token: MilestoneToken, + shouldSkipSort: true, +}; + +export const labelTokenBase = { + type: TOKEN_TYPE_LABEL, + title: TOKEN_TITLE_LABEL, + icon: 'labels', + token: LabelToken, +}; + +export const releaseTokenBase = { + type: TOKEN_TYPE_RELEASE, + title: TOKEN_TITLE_RELEASE, + icon: 'rocket', + token: ReleaseToken, +}; + +export const reactionTokenBase = { + type: TOKEN_TYPE_MY_REACTION, + title: TOKEN_TITLE_MY_REACTION, + icon: 'thumb-up', + token: EmojiToken, + unique: true, +}; + +export const confidentialityTokenBase = { + type: TOKEN_TYPE_CONFIDENTIAL, + title: TOKEN_TITLE_CONFIDENTIAL, + icon: 'eye-slash', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATORS_IS, + options: [ + { icon: 'eye-slash', value: 'yes', title: yes }, + { icon: 'eye', value: 'no', title: no }, + ], +}; diff --git a/app/assets/javascripts/issues/service_desk/utils.js b/app/assets/javascripts/issues/service_desk/utils.js new file mode 100644 index 00000000000..86f76da3880 --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/utils.js @@ -0,0 +1,37 @@ +import { + OPERATOR_OR, + TOKEN_TYPE_LABEL, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { isSpecialFilter, isNotEmptySearchToken } from '~/issues/list/utils'; +import { + ALTERNATIVE_FILTER, + NORMAL_FILTER, + SPECIAL_FILTER, + URL_PARAM, +} from '~/issues/list/constants'; +import { filtersMap } from './constants'; + +const getFilterType = ({ type, value: { data, operator } }) => { + const isUnionedLabel = type === TOKEN_TYPE_LABEL && operator === OPERATOR_OR; + + if (isUnionedLabel) { + return ALTERNATIVE_FILTER; + } + if (isSpecialFilter(type, data)) { + return SPECIAL_FILTER; + } + return NORMAL_FILTER; +}; + +export const convertToUrlParams = (filterTokens) => { + const urlParamsMap = filterTokens.filter(isNotEmptySearchToken).reduce((acc, token) => { + const filterType = getFilterType(token); + const urlParam = filtersMap[token.type][URL_PARAM][token.value.operator]?.[filterType]; + return acc.set( + urlParam, + acc.has(urlParam) ? [acc.get(urlParam), token.value.data].flat() : token.value.data, + ); + }, new Map()); + + return Object.fromEntries(urlParamsMap); +}; diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 26c3db647a3..d59692d2a28 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -1,49 +1,36 @@ <script> -import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; import Visibility from 'visibilityjs'; import { createAlert } from '~/alert'; -import { - issuableStatusText, - STATUS_CLOSED, - TYPE_EPIC, - TYPE_INCIDENT, - TYPE_ISSUE, - WORKSPACE_PROJECT, -} from '~/issues/constants'; +import { TYPE_EPIC, TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; +import updateDescription from '~/issues/show/utils/update_description'; +import { sanitize } from '~/lib/dompurify'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; +import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; -import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; -import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, 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 HeaderActions from './header_actions.vue'; import IssueHeader from './issue_header.vue'; import PinnedLinks from './pinned_links.vue'; +import StickyHeader from './sticky_header.vue'; import TitleComponent from './title.vue'; export default { - WORKSPACE_PROJECT, components: { - GlIcon, - GlBadge, - GlIntersectionObserver, HeaderActions, IssueHeader, TitleComponent, EditedComponent, FormComponent, PinnedLinks, - ConfidentialityBadge, - }, - directives: { - GlTooltip: GlTooltipDirective, + StickyHeader, }, props: { author: { @@ -234,21 +221,26 @@ export default { }, }, 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, - taskCompletionStatus: this.initialTaskCompletionStatus, - lock_version: this.lockVersion, - }); - return { - store, - state: store.state, + formState: { + title: '', + description: '', + lockedWarningVisible: false, + updateLoading: false, + lock_version: 0, + issuableTemplates: {}, + }, + state: { + titleHtml: this.initialTitleHtml, + titleText: this.initialTitleText, + descriptionHtml: this.initialDescriptionHtml, + descriptionText: this.initialDescriptionText, + updatedAt: this.updatedAt, + updatedByName: this.updatedByName, + updatedByPath: this.updatedByPath, + taskCompletionStatus: this.initialTaskCompletionStatus, + lock_version: this.lockVersion, + }, showForm: false, templatesRequested: false, isStickyHeaderShowing: false, @@ -264,17 +256,9 @@ export default { headerClasses() { return this.issuableType === TYPE_INCIDENT ? 'gl-mb-3' : 'gl-mb-6'; }, - issuableTemplates() { - return this.store.formState.issuableTemplates; - }, - formState() { - return this.store.formState; - }, issueChanged() { const { - store: { - formState: { description, title }, - }, + formState: { description, title }, initialDescriptionText, initialTitleText, } = this; @@ -292,26 +276,13 @@ export default { defaultErrorMessage() { return sprintf(__('Error updating %{issuableType}'), { issuableType: this.issuableType }); }, - isClosed() { - return this.issuableStatus === STATUS_CLOSED; - }, + pinnedLinkClasses() { return this.showTitleBorder ? 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6' : ''; }, - statusIcon() { - if (this.issuableType === TYPE_EPIC) { - return this.isClosed ? 'epic-closed' : 'epic'; - } - return this.isClosed ? 'issue-closed' : 'issues'; - }, - statusVariant() { - return this.isClosed ? 'info' : 'success'; - }, - statusText() { - return issuableStatusText[this.issuableStatus]; - }, + shouldShowStickyHeader() { return [TYPE_INCIDENT, TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType); }, @@ -322,7 +293,7 @@ export default { this.poll = new Poll({ resource: this.service, method: 'getData', - successCallback: (res) => this.store.updateState(res.data), + successCallback: (res) => this.updateState(res.data), errorCallback(err) { throw new Error(err); }, @@ -360,23 +331,37 @@ export default { } return undefined; }, + updateState(data) { + const stateShouldUpdate = + this.state.titleText !== data.title_text || + this.state.descriptionText !== data.description_text; - updateStoreState() { + if (stateShouldUpdate) { + 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; + }, + refetchData() { return this.service .getData() .then((res) => res.data) - .then((data) => { - this.store.updateState(data); - }) - .catch(() => { - createAlert({ - message: this.defaultErrorMessage, - }); - }); + .then(this.updateState) + .catch(() => createAlert({ message: this.defaultErrorMessage })); }, setFormState(state) { - this.store.setFormState(state); + this.formState = { ...this.formState, ...state }; }, updateFormState(templates = {}) { @@ -416,7 +401,7 @@ export default { this.templatesRequested = true; this.requestTemplatesAndShowForm(); } else { - this.updateAndShowForm(this.issuableTemplates); + this.updateAndShowForm(this.formState.issuableTemplates); } }, @@ -427,10 +412,7 @@ export default { async updateIssuable() { this.setFormState({ updateLoading: true }); - const { - store: { formState }, - issueState, - } = this; + const { formState, issueState } = this; const issuablePayload = issueState.isDirty ? { ...formState, issue_type: issueState.issueType } : formState; @@ -464,7 +446,7 @@ export default { visitUrl(URI); } }) - .then(this.updateStoreState) + .then(this.refetchData) .then(() => { eventHub.$emit('close.form'); }) @@ -518,7 +500,7 @@ export default { this.poll.enable(); this.poll.makeDelayedRequest(POLLING_DELAY); - this.updateStoreState(); + this.refetchData(); }, }, }; @@ -531,7 +513,7 @@ export default { :endpoint="endpoint" :form-state="formState" :initial-description-text="initialDescriptionText" - :issuable-templates="issuableTemplates" + :issuable-templates="formState.issuableTemplates" :markdown-docs-path="markdownDocsPath" :markdown-preview-path="markdownPreviewPath" :project-path="projectPath" @@ -559,61 +541,19 @@ export default { </template> </title-component> - <gl-intersection-observer + <sticky-header 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" - > - <gl-badge :variant="statusVariant" class="gl-mr-2"> - <gl-icon :name="statusIcon" /> - <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ - statusText - }}</span></gl-badge - > - <span - v-if="isLocked" - v-gl-tooltip.bottom - data-testid="locked" - class="issuable-warning-icon" - :title="__('This issue is locked. Only project members can comment.')" - > - <gl-icon name="lock" :aria-label="__('Locked')" /> - </span> - <confidentiality-badge - v-if="isConfidential" - data-testid="confidential" - :workspace-type="$options.WORKSPACE_PROJECT" - :issuable-type="issuableType" - /> - <span - v-if="isHidden" - v-gl-tooltip.bottom - :title="__('This issue is hidden because its author has been banned')" - data-testid="hidden" - class="issuable-warning-icon" - > - <gl-icon name="spam" /> - </span> - <a - href="#top" - class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-text-black-normal" - :title="state.titleText" - > - {{ state.titleText }} - </a> - </div> - </div> - </transition> - </gl-intersection-observer> + :is-confidential="isConfidential" + :is-hidden="isHidden" + :is-locked="isLocked" + :issuable-status="issuableStatus" + :issuable-type="issuableType" + :show="isStickyHeaderShowing" + :title="state.titleText" + :title-html="state.titleHtml" + @hide="hideStickyHeader" + @show="showStickyHeader" + /> <slot name="header"> <issue-header diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 90f01603f96..acbba216601 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -307,7 +307,8 @@ export default { ); taskListItems?.forEach((item) => { - const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate }); + const provide = { canUpdate: this.canUpdate, issuableType: this.issuableType }; + const dropdown = this.createTaskListItemActions(provide); this.insertNextToTaskListItemText(dropdown, item); this.addPointerEventListeners(item, '.task-list-item-actions'); this.hasTaskListItemActions = true; diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 1ade5e654e9..81e5c30a264 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -79,62 +79,25 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [trackingMixin, glFeatureFlagMixin()], - inject: { - canCreateIssue: { - default: false, - }, - canDestroyIssue: { - default: false, - }, - canPromoteToEpic: { - default: false, - }, - canReopenIssue: { - default: false, - }, - canReportSpam: { - default: false, - }, - canUpdateIssue: { - default: false, - }, - iid: { - default: '', - }, - issuableId: { - default: '', - }, - isIssueAuthor: { - default: false, - }, - issuePath: { - default: '', - }, - issueType: { - default: TYPE_ISSUE, - }, - newIssuePath: { - default: '', - }, - projectPath: { - default: '', - }, - submitAsSpamPath: { - default: '', - }, - reportedUserId: { - default: '', - }, - reportedFromUrl: { - default: '', - }, - issuableEmailAddress: { - default: '', - }, - fullPath: { - default: '', - }, - }, + inject: [ + 'canCreateIssue', + 'canDestroyIssue', + 'canPromoteToEpic', + 'canReopenIssue', + 'canReportSpam', + 'canUpdateIssue', + 'iid', + 'isIssueAuthor', + 'issuePath', + 'issueType', + 'newIssuePath', + 'projectPath', + 'submitAsSpamPath', + 'reportedUserId', + 'reportedFromUrl', + 'issuableEmailAddress', + 'fullPath', + ], data() { return { isReportAbuseDrawerOpen: false, @@ -256,7 +219,7 @@ export default { mutation: updateIssueMutation, variables: { input: { - iid: this.iid.toString(), + iid: String(this.iid), projectPath: this.projectPath, stateEvent: this.isClosed ? ISSUE_STATE_EVENT_REOPEN : ISSUE_STATE_EVENT_CLOSE, }, @@ -501,7 +464,7 @@ export default { >{{ copyMailAddressText }}</gl-dropdown-item > </template> - <gl-dropdown-divider v-if="showToggleIssueStateButton || canDestroyIssue || canReportSpam" /> + <gl-dropdown-divider v-if="canDestroyIssue || canReportSpam || !isIssueAuthor" /> <gl-dropdown-item v-if="canReportSpam" :href="submitAsSpamPath" diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue index ac64c35bf15..ab1bb9253f4 100644 --- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue @@ -107,10 +107,7 @@ export default { </script> <template> - <div - class="create-timeline-event gl-relative gl-display-flex gl-align-items-start" - :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }" - > + <div class="create-timeline-event gl-relative gl-display-flex gl-align-items-start"> <div v-if="hasTimelineEvents" class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-flex-shrink-0 gl-p-3 gl-z-index-1" diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 4ec64ef838d..2909a4d2666 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -43,7 +43,7 @@ export default { variables() { return { fullPath: this.fullPath, - iid: this.iid, + iid: String(this.iid), }; }, update(data) { diff --git a/app/assets/javascripts/issues/show/components/sticky_header.vue b/app/assets/javascripts/issues/show/components/sticky_header.vue new file mode 100644 index 00000000000..bcf10ee92bb --- /dev/null +++ b/app/assets/javascripts/issues/show/components/sticky_header.vue @@ -0,0 +1,130 @@ +<script> +import { GlBadge, GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; +import { + issuableStatusText, + STATUS_CLOSED, + TYPE_EPIC, + WORKSPACE_PROJECT, +} from '~/issues/constants'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; + +export default { + WORKSPACE_PROJECT, + components: { + ConfidentialityBadge, + GlBadge, + GlIcon, + GlIntersectionObserver, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + props: { + isConfidential: { + type: Boolean, + required: false, + default: false, + }, + isHidden: { + type: Boolean, + required: false, + default: false, + }, + isLocked: { + type: Boolean, + required: false, + default: false, + }, + issuableStatus: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: true, + }, + show: { + type: Boolean, + required: false, + default: false, + }, + title: { + type: String, + required: true, + }, + titleHtml: { + type: String, + required: true, + }, + }, + computed: { + isClosed() { + return this.issuableStatus === STATUS_CLOSED; + }, + statusIcon() { + if (this.issuableType === TYPE_EPIC) { + return this.isClosed ? 'epic-closed' : 'epic'; + } + return this.isClosed ? 'issue-closed' : 'issues'; + }, + statusText() { + return issuableStatusText[this.issuableStatus]; + }, + statusVariant() { + return this.isClosed ? 'info' : 'success'; + }, + }, +}; +</script> + +<template> + <gl-intersection-observer @appear="$emit('hide')" @disappear="$emit('show')"> + <transition name="issuable-header-slide"> + <div + v-if="show" + 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-gap-2 gl-mx-auto gl-px-5" + > + <gl-badge :variant="statusVariant"> + <gl-icon :name="statusIcon" /> + <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ statusText }}</span> + </gl-badge> + <span + v-if="isLocked" + v-gl-tooltip.bottom + data-testid="locked" + class="issuable-warning-icon" + :title="__('This issue is locked. Only project members can comment.')" + > + <gl-icon name="lock" :aria-label="__('Locked')" /> + </span> + <confidentiality-badge + v-if="isConfidential" + :issuable-type="issuableType" + :workspace-type="$options.WORKSPACE_PROJECT" + /> + <span + v-if="isHidden" + v-gl-tooltip.bottom + :title="__('This issue is hidden because its author has been banned')" + data-testid="hidden" + class="issuable-warning-icon" + > + <gl-icon name="spam" /> + </span> + <a + v-safe-html="titleHtml || title" + href="#top" + class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-text-black-normal" + > + </a> + </div> + </div> + </transition> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue index 64b916caddb..55e2e857050 100644 --- a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue +++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue @@ -1,5 +1,6 @@ <script> import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; +import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; import { __, s__ } from '~/locale'; import eventHub from '../event_hub'; @@ -13,7 +14,12 @@ export default { GlDisclosureDropdown, GlDisclosureDropdownItem, }, - inject: ['canUpdate'], + inject: ['canUpdate', 'issuableType'], + computed: { + showConvertToTaskItem() { + return [TYPE_INCIDENT, TYPE_ISSUE].includes(this.issuableType); + }, + }, methods: { convertToTask() { eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos); @@ -37,12 +43,17 @@ export default { text-sr-only toggle-class="task-list-item-actions gl-opacity-0 gl-p-2! " > - <gl-disclosure-dropdown-item class="gl-ml-2!" @action="convertToTask"> + <gl-disclosure-dropdown-item + v-if="showConvertToTaskItem" + class="gl-ml-2!" + data-testid="convert" + @action="convertToTask" + > <template #list-item> {{ $options.i18n.convertToTask }} </template> </gl-disclosure-dropdown-item> - <gl-disclosure-dropdown-item class="gl-ml-2!" @action="deleteTaskListItem"> + <gl-disclosure-dropdown-item class="gl-ml-2!" data-testid="delete" @action="deleteTaskListItem"> <template #list-item> <span class="gl-text-red-500!">{{ $options.i18n.delete }}</span> </template> diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index a27f86bd9c3..b94f88f690e 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -6,13 +6,15 @@ import { apolloProvider } from '~/graphql_shared/issuable_client'; import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; +import initLinkedResources from '~/linked_resources'; import IssueApp from './components/app.vue'; -import HeaderActions from './components/header_actions.vue'; +import DescriptionComponent from './components/description.vue'; import IncidentTabs from './components/incidents/incident_tabs.vue'; import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; import { issueState } from './constants'; import getIssueStateQuery from './queries/get_issue_state.query.graphql'; import createRouter from './components/incidents/router'; +import { parseIssuableData } from './utils/parse_data'; const bootstrapApollo = (state = {}) => { return apolloProvider.clients.defaultClient.cache.writeQuery({ @@ -23,14 +25,15 @@ const bootstrapApollo = (state = {}) => { }); }; -export function initIncidentApp(issueData = {}, store) { +export function initIssuableApp(store) { const el = document.getElementById('js-issuable-app'); if (!el) { return undefined; } - bootstrapApollo({ ...issueState, issueType: TYPE_INCIDENT }); + const issuableData = parseIssuableData(el); + const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData)); const { authorId, @@ -38,137 +41,72 @@ export function initIncidentApp(issueData = {}, store) { authorUsername, authorWebUrl, canCreateIncident, - canUpdate, - canUpdateTimelineEvent, + fullPath, iid, issuableId, + issueType, + hasIterationsFeature, + // for issue + registerPath, + signInPath, + // for incident + canUpdate, + canUpdateTimelineEvent, currentPath, currentTab, - projectNamespace, - projectPath, - projectId, hasLinkedAlerts, + projectId, slaFeatureAvailable, uploadMetricsFeatureAvailable, - } = issueData; - const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData)); - - const fullPath = `${projectNamespace}/${projectPath}`; - const router = createRouter(currentPath, currentTab); - - return new Vue({ - el, - name: 'DescriptionRoot', - apolloProvider, - store, - router, - provide: { - issueType: TYPE_INCIDENT, - canCreateIncident, - canUpdateTimelineEvent, - canUpdate, - fullPath, - iid, - issuableId, - projectId, - hasLinkedAlerts: parseBoolean(hasLinkedAlerts), - slaFeatureAvailable: parseBoolean(slaFeatureAvailable), - uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable), - contentEditorOnIssues: gon.features.contentEditorOnIssues, - // for HeaderActions component - canCreateIssue: parseBoolean(headerActionsData.canCreateIncident), - canDestroyIssue: parseBoolean(headerActionsData.canDestroyIssue), - canPromoteToEpic: parseBoolean(headerActionsData.canPromoteToEpic), - canReopenIssue: parseBoolean(headerActionsData.canReopenIssue), - canReportSpam: parseBoolean(headerActionsData.canReportSpam), - canUpdateIssue: parseBoolean(headerActionsData.canUpdateIssue), - isIssueAuthor: parseBoolean(headerActionsData.isIssueAuthor), - issuePath: headerActionsData.issuePath, - newIssuePath: headerActionsData.newIssuePath, - projectPath: headerActionsData.projectPath, - reportAbusePath: headerActionsData.reportAbusePath, - reportedUserId: headerActionsData.reportedUserId, - reportedFromUrl: headerActionsData.reportedFromUrl, - submitAsSpamPath: headerActionsData.submitAsSpamPath, - issuableEmailAddress: headerActionsData.issuableEmailAddress, - }, - computed: { - ...mapGetters(['getNoteableData']), - }, - render(createElement) { - return createElement(IssueApp, { - props: { - ...issueData, - author: { - id: authorId, - name: authorName, - username: authorUsername, - webUrl: authorWebUrl, - }, - issueId: Number(issuableId), - issuableStatus: this.getNoteableData?.state, - issuableType: TYPE_INCIDENT, - descriptionComponent: IncidentTabs, - showTitleBorder: false, - isConfidential: this.getNoteableData?.confidential, - }, - }); - }, - }); -} - -export function initIssueApp(issueData, store) { - const el = document.getElementById('js-issuable-app'); + } = issuableData; - if (!el) { - return undefined; - } + const issueProvideData = { registerPath, signInPath }; + const incidentProvideData = { + canUpdate, + canUpdateTimelineEvent, + hasLinkedAlerts: parseBoolean(hasLinkedAlerts), + projectId, + slaFeatureAvailable: parseBoolean(slaFeatureAvailable), + uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable), + }; - const { fullPath, registerPath, signInPath } = el.dataset; - const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData)); + bootstrapApollo({ ...issueState, issueType }); scrollToTargetOnResize(); - bootstrapApollo({ ...issueState, issueType: TYPE_ISSUE }); - - const { - authorId, - authorName, - authorUsername, - authorWebUrl, - canCreateIncident, - hasIssueWeightsFeature, - hasIterationsFeature, - ...issueProps - } = issueData; + if (issueType === TYPE_INCIDENT) { + initLinkedResources(); + } return new Vue({ el, name: 'DescriptionRoot', apolloProvider, store, + router: issueType === TYPE_INCIDENT ? createRouter(currentPath, currentTab) : undefined, provide: { canCreateIncident, fullPath, - registerPath, - signInPath, - hasIssueWeightsFeature, + iid, + issuableId, + issueType, hasIterationsFeature, + ...(issueType === TYPE_ISSUE && issueProvideData), + ...(issueType === TYPE_INCIDENT && incidentProvideData), // for HeaderActions component - canCreateIssue: parseBoolean(headerActionsData.canCreateIssue), + canCreateIssue: + issueType === TYPE_INCIDENT + ? parseBoolean(headerActionsData.canCreateIncident) + : parseBoolean(headerActionsData.canCreateIssue), canDestroyIssue: parseBoolean(headerActionsData.canDestroyIssue), canPromoteToEpic: parseBoolean(headerActionsData.canPromoteToEpic), canReopenIssue: parseBoolean(headerActionsData.canReopenIssue), canReportSpam: parseBoolean(headerActionsData.canReportSpam), canUpdateIssue: parseBoolean(headerActionsData.canUpdateIssue), - iid: headerActionsData.iid, - issuableId: headerActionsData.issuableId, isIssueAuthor: parseBoolean(headerActionsData.isIssueAuthor), issuePath: headerActionsData.issuePath, - issueType: headerActionsData.issueType, newIssuePath: headerActionsData.newIssuePath, projectPath: headerActionsData.projectPath, - projectId: headerActionsData.projectId, reportAbusePath: headerActionsData.reportAbusePath, reportedUserId: headerActionsData.reportedUserId, reportedFromUrl: headerActionsData.reportedFromUrl, @@ -181,67 +119,27 @@ export function initIssueApp(issueData, store) { render(createElement) { return createElement(IssueApp, { props: { - ...issueProps, + ...issuableData, author: { id: authorId, name: authorName, username: authorUsername, webUrl: authorWebUrl, }, + descriptionComponent: issueType === TYPE_INCIDENT ? IncidentTabs : DescriptionComponent, isConfidential: this.getNoteableData?.confidential, isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, + issuableType: issueType, issueId: this.getNoteableData?.id, issueIid: this.getNoteableData?.iid, + showTitleBorder: issueType !== TYPE_INCIDENT, }, }); }, }); } -export function initHeaderActions(store, type = '') { - const el = document.querySelector('.js-issue-header-actions'); - - if (!el) { - return undefined; - } - - bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); - - const canCreate = - type === TYPE_INCIDENT ? el.dataset.canCreateIncident : el.dataset.canCreateIssue; - - return new Vue({ - el, - name: 'HeaderActionsRoot', - apolloProvider, - store, - provide: { - canCreateIssue: parseBoolean(canCreate), - 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, - issuableId: el.dataset.issuableId, - 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, - reportedUserId: parseInt(el.dataset.reportedUserId, 10), - reportedFromUrl: el.dataset.reportedFromUrl, - submitAsSpamPath: el.dataset.submitAsSpamPath, - issuableEmailAddress: el.dataset.issuableEmailAddress, - fullPath: el.dataset.projectPath, - }, - render: (createElement) => createElement(HeaderActions), - }); -} - export function initSentryErrorStackTrace() { const el = document.querySelector('#js-sentry-error-stack-trace'); diff --git a/app/assets/javascripts/issues/show/stores/index.js b/app/assets/javascripts/issues/show/stores/index.js deleted file mode 100644 index a50913d3455..00000000000 --- a/app/assets/javascripts/issues/show/stores/index.js +++ /dev/null @@ -1,46 +0,0 @@ -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); - } -} |