diff options
Diffstat (limited to 'app/assets/javascripts/service_desk')
8 files changed, 534 insertions, 0 deletions
diff --git a/app/assets/javascripts/service_desk/components/info_banner.vue b/app/assets/javascripts/service_desk/components/info_banner.vue new file mode 100644 index 00000000000..8aaced839a5 --- /dev/null +++ b/app/assets/javascripts/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" target="_blank">{{ $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/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue new file mode 100644 index 00000000000..e8b05642e7d --- /dev/null +++ b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue @@ -0,0 +1,151 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { fetchPolicies } from '~/lib/graphql'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { issuableListTabs } from '~/vue_shared/issuable/list/constants'; +import { STATUS_OPEN, STATUS_CLOSED, STATUS_ALL } from '~/issues/constants'; +import getServiceDeskIssuesQuery from '../queries/get_service_desk_issues.query.graphql'; +import getServiceDeskIssuesCounts from '../queries/get_service_desk_issues_counts.query.graphql'; +import { + errorFetchingCounts, + errorFetchingIssues, + noSearchNoFilterTitle, + searchPlaceholder, + SERVICE_DESK_BOT_USERNAME, +} from '../constants'; +import InfoBanner from './info_banner.vue'; + +export default { + i18n: { + errorFetchingCounts, + errorFetchingIssues, + noSearchNoFilterTitle, + searchPlaceholder, + }, + issuableListTabs, + components: { + GlEmptyState, + IssuableList, + InfoBanner, + }, + inject: [ + 'emptyStateSvgPath', + 'isProject', + 'isSignedIn', + 'fullPath', + 'isServiceDeskSupported', + 'hasAnyIssues', + ], + data() { + return { + serviceDeskIssues: [], + serviceDeskIssuesCounts: {}, + searchTokens: [], + sortOptions: [], + state: STATUS_OPEN, + issuesError: null, + }; + }, + 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.hasAnyIssues; + }, + }, + serviceDeskIssuesCounts: { + query: getServiceDeskIssuesCounts, + variables() { + return this.queryVariables; + }, + update(data) { + return data?.project ?? {}; + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingCounts; + Sentry.captureException(error); + }, + context: { + isSingleRequest: true, + }, + }, + }, + computed: { + queryVariables() { + return { + fullPath: this.fullPath, + isProject: this.isProject, + isSignedIn: this.isSignedIn, + authorUsername: SERVICE_DESK_BOT_USERNAME, + state: this.state, + }; + }, + tabCounts() { + const { openedIssues, closedIssues, allIssues } = this.serviceDeskIssuesCounts; + return { + [STATUS_OPEN]: openedIssues?.count, + [STATUS_CLOSED]: closedIssues?.count, + [STATUS_ALL]: allIssues?.count, + }; + }, + isInfoBannerVisible() { + return this.isServiceDeskSupported && this.hasAnyIssues; + }, + }, + methods: { + handleClickTab(state) { + if (this.state === state) { + return; + } + this.state = state; + }, + }, +}; +</script> + +<template> + <section> + <info-banner v-if="isInfoBannerVisible" /> + <issuable-list + namespace="service-desk" + recent-searches-storage-key="issues" + :error="issuesError" + :search-input-placeholder="$options.i18n.searchPlaceholder" + :search-tokens="searchTokens" + :sort-options="sortOptions" + :issuables="serviceDeskIssues" + :tabs="$options.issuableListTabs" + :tab-counts="tabCounts" + :current-tab="state" + @click-tab="handleClickTab" + > + <template #empty-state> + <gl-empty-state + :svg-path="emptyStateSvgPath" + :title="$options.i18n.noSearchNoFilterTitle" + /> + </template> + </issuable-list> + </section> +</template> diff --git a/app/assets/javascripts/service_desk/constants.js b/app/assets/javascripts/service_desk/constants.js new file mode 100644 index 00000000000..685ad738792 --- /dev/null +++ b/app/assets/javascripts/service_desk/constants.js @@ -0,0 +1,17 @@ +import { __, s__ } from '~/locale'; + +export const SERVICE_DESK_BOT_USERNAME = 'support-bot'; + +export const errorFetchingCounts = __('An error occurred while getting issue counts'); +export const errorFetchingIssues = __('An error occurred while loading issues'); +export const noSearchNoFilterTitle = __('Please select at least one filter to see results'); +export const searchPlaceholder = __('Search or filter results...'); +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'); diff --git a/app/assets/javascripts/service_desk/graphql.js b/app/assets/javascripts/service_desk/graphql.js new file mode 100644 index 00000000000..e01973f1e8a --- /dev/null +++ b/app/assets/javascripts/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/service_desk/index.js b/app/assets/javascripts/service_desk/index.js new file mode 100644 index 00000000000..a9172f96540 --- /dev/null +++ b/app/assets/javascripts/service_desk/index.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { gqlClient } from './graphql'; +import ServiceDeskListApp from './components/service_desk_list_app.vue'; + +export async function mountServiceDeskListApp() { + const el = document.querySelector('.js-service-desk-list'); + + if (!el) { + return null; + } + + const { + projectDataEmptyStateSvgPath, + projectDataFullPath, + projectDataIsProject, + projectDataIsSignedIn, + projectDataHasAnyIssues, + serviceDeskEmailAddress, + canAdminIssues, + canEditProjectSettings, + serviceDeskCalloutSvgPath, + serviceDeskSettingsPath, + serviceDeskHelpPath, + isServiceDeskSupported, + isServiceDeskEnabled, + } = el.dataset; + + Vue.use(VueApollo); + + return new Vue({ + el, + name: 'ServiceDeskListRoot', + apolloProvider: new VueApollo({ + defaultClient: await gqlClient(), + }), + provide: { + 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), + hasAnyIssues: parseBoolean(projectDataHasAnyIssues), + }, + render: (createComponent) => createComponent(ServiceDeskListApp), + }); +} diff --git a/app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql b/app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql new file mode 100644 index 00000000000..c678b8dd8ab --- /dev/null +++ b/app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql @@ -0,0 +1,72 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "./issue.fragment.graphql" + +query getServiceDeskIssues( + $hideUsers: Boolean = false + $isProject: 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!] + $crmContactId: String + $crmOrganizationId: String + $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput + $beforeCursor: String + $afterCursor: String + $firstPageSize: Int + $lastPageSize: Int +) { + project(fullPath: $fullPath) @include(if: $isProject) @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 + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId + not: $not + or: $or + before: $beforeCursor + after: $afterCursor + first: $firstPageSize + last: $lastPageSize + ) { + __persist + pageInfo { + ...PageInfo + } + nodes { + __persist + ...IssueFragment + } + } + } +} diff --git a/app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql b/app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql new file mode 100644 index 00000000000..c2ba397d76f --- /dev/null +++ b/app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql @@ -0,0 +1,91 @@ +query getServiceDeskIssuesCount( + $isProject: Boolean = false + $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!] + $crmContactId: String + $crmOrganizationId: String + $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput +) { + project(fullPath: $fullPath) @include(if: $isProject) { + 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 + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId + 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 + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId + 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 + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId + not: $not + or: $or + ) { + count + } + } +} diff --git a/app/assets/javascripts/service_desk/queries/issue.fragment.graphql b/app/assets/javascripts/service_desk/queries/issue.fragment.graphql new file mode 100644 index 00000000000..3b49c0efb14 --- /dev/null +++ b/app/assets/javascripts/service_desk/queries/issue.fragment.graphql @@ -0,0 +1,60 @@ +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 + } + labels { + nodes { + __persist + id + color + title + description + } + } + milestone { + __persist + id + dueDate + startDate + webPath + title + } + taskCompletionStatus { + completedCount + count + } +} |