From aaca4b9de271b8801d775fa281bd8ad0d6d0ef6e Mon Sep 17 00:00:00 2001 From: Paulina Sedlak-Jakubowska <psedlak-jakubowska@gitlab.com> Date: Tue, 11 Jul 2023 02:24:01 +0000 Subject: [PATCH] Provide typePolicies for cached issues list Instruct the client about how to merge incoming and existing objects. --- app/assets/images/service_desk_callout.svg | 1 + .../service_desk/components/info_banner.vue | 64 +++++++++++++++ .../components/service_desk_list_app.vue | 21 ++++- .../javascripts/service_desk/constants.js | 11 ++- .../javascripts/service_desk/graphql.js | 24 ++++++ app/assets/javascripts/service_desk/index.js | 35 ++++++-- app/controllers/projects/issues_controller.rb | 2 +- .../projects/issues/service_desk.html.haml | 10 ++- spec/features/issues/service_desk_spec.rb | 17 +++- .../components/info_banner_spec.js | 81 +++++++++++++++++++ .../components/service_desk_list_app_spec.js | 21 ++++- 11 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 app/assets/images/service_desk_callout.svg create mode 100644 app/assets/javascripts/service_desk/components/info_banner.vue create mode 100644 app/assets/javascripts/service_desk/graphql.js create mode 100644 spec/frontend/service_desk/components/info_banner_spec.js diff --git a/app/assets/images/service_desk_callout.svg b/app/assets/images/service_desk_callout.svg new file mode 100644 index 0000000000000..2886388279ee4 --- /dev/null +++ b/app/assets/images/service_desk_callout.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><rect width="7" height="1" x="59" y="38" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M60.5 42a3.5 3.5 0 0 0 0-7v7z"/><rect width="7" height="1" x="12" y="38" fill="#E1DBF2" transform="matrix(-1 0 0 1 31 0)" rx=".5"/><path fill="#6B4FBB" d="M17.5 42a3.5 3.5 0 0 1 0-7v7z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M39 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M35 56a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M26.5 40c0 4.143 3.355 7.5 7.494 7.5h10.012A7.497 7.497 0 0 0 51.5 40c0-4.143-3.355-7.5-7.494-7.5H33.994A7.497 7.497 0 0 0 26.5 40zm-3 0c0-5.799 4.698-10.5 10.494-10.5h10.012C49.802 29.5 54.5 34.2 54.5 40c0 5.799-4.698 10.5-10.494 10.5H33.994C28.198 50.5 23.5 45.8 23.5 40z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M35.255 42.406a1 1 0 1 1 1.872-.703 2.001 2.001 0 0 0 3.76-.038 1 1 0 1 1 1.886.665 4 4 0 0 1-7.518.076zM31.5 40a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm15 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/><path fill="#6B4FBB" d="M38 22h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2zm0 3h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z" style="mix-blend-mode:multiply"/></g></svg> \ No newline at end of file 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 0000000000000..8aaced839a52d --- /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 index e0fbc2e4748c0..e8b05642e7d39 100644 --- a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue +++ b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue @@ -14,6 +14,7 @@ import { searchPlaceholder, SERVICE_DESK_BOT_USERNAME, } from '../constants'; +import InfoBanner from './info_banner.vue'; export default { i18n: { @@ -26,8 +27,16 @@ export default { components: { GlEmptyState, IssuableList, + InfoBanner, }, - inject: ['emptyStateSvgPath', 'isProject', 'isSignedIn', 'fullPath'], + inject: [ + 'emptyStateSvgPath', + 'isProject', + 'isSignedIn', + 'fullPath', + 'isServiceDeskSupported', + 'hasAnyIssues', + ], data() { return { serviceDeskIssues: [], @@ -52,12 +61,18 @@ export default { // 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, @@ -94,6 +109,9 @@ export default { [STATUS_ALL]: allIssues?.count, }; }, + isInfoBannerVisible() { + return this.isServiceDeskSupported && this.hasAnyIssues; + }, }, methods: { handleClickTab(state) { @@ -108,6 +126,7 @@ export default { <template> <section> + <info-banner v-if="isInfoBannerVisible" /> <issuable-list namespace="service-desk" recent-searches-storage-key="issues" diff --git a/app/assets/javascripts/service_desk/constants.js b/app/assets/javascripts/service_desk/constants.js index ff6128e096711..685ad7387926f 100644 --- a/app/assets/javascripts/service_desk/constants.js +++ b/app/assets/javascripts/service_desk/constants.js @@ -1,4 +1,4 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; export const SERVICE_DESK_BOT_USERNAME = 'support-bot'; @@ -6,3 +6,12 @@ export const errorFetchingCounts = __('An error occurred while getting issue cou 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 0000000000000..e01973f1e8a2d --- /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 index 04fc8f2e746eb..a9172f9654079 100644 --- a/app/assets/javascripts/service_desk/index.js +++ b/app/assets/javascripts/service_desk/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { gqlClient } from '~/issues/list/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { gqlClient } from './graphql'; import ServiceDeskListApp from './components/service_desk_list_app.vue'; export async function mountServiceDeskListApp() { @@ -11,7 +11,21 @@ export async function mountServiceDeskListApp() { return null; } - const { emptyStateSvgPath, fullPath, isProject, isSignedIn } = el.dataset; + const { + projectDataEmptyStateSvgPath, + projectDataFullPath, + projectDataIsProject, + projectDataIsSignedIn, + projectDataHasAnyIssues, + serviceDeskEmailAddress, + canAdminIssues, + canEditProjectSettings, + serviceDeskCalloutSvgPath, + serviceDeskSettingsPath, + serviceDeskHelpPath, + isServiceDeskSupported, + isServiceDeskEnabled, + } = el.dataset; Vue.use(VueApollo); @@ -22,10 +36,19 @@ export async function mountServiceDeskListApp() { defaultClient: await gqlClient(), }), provide: { - emptyStateSvgPath, - fullPath, - isProject: parseBoolean(isProject), - isSignedIn: parseBoolean(isSignedIn), + 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/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index e01e7350b1281..941a480951c54 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -58,7 +58,7 @@ class Projects::IssuesController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?) end - before_action only: :index do + before_action only: [:index, :service_desk] do push_frontend_feature_flag(:or_issuable_queries, project) push_frontend_feature_flag(:frontend_caching, project&.group) end diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml index 8f2ddf8510cef..9793f21e4a967 100644 --- a/app/views/projects/issues/service_desk.html.haml +++ b/app/views/projects/issues/service_desk.html.haml @@ -9,7 +9,15 @@ .js-service-desk-issues.service-desk-issues{ data: { support_bot: support_bot_attrs } } - if ::Feature.enabled?(:service_desk_vue_list, @project) - .js-service-desk-list{ data: project_issues_list_data(@project, current_user) } + .js-service-desk-list{ data: { project_data: project_issues_list_data(@project, current_user), + service_desk_email_address: @project.service_desk_address, + can_admin_issues: can?(current_user, :admin_issue, @project).to_s, + can_edit_project_settings: can?(current_user, :admin_project, @project).to_s, + service_desk_callout_svg_path: image_path('service_desk_callout.svg'), + service_desk_settings_path: edit_project_path(@project, anchor: 'js-service-desk'), + service_desk_help_path: help_page_path('user/project/service_desk'), + is_service_desk_supported: Gitlab::ServiceDesk.supported?.to_s, + is_service_desk_enabled: @project.service_desk_enabled?.to_s } } - else .top-area = render 'shared/issuable/nav', type: :issues diff --git a/spec/features/issues/service_desk_spec.rb b/spec/features/issues/service_desk_spec.rb index ba200bb9524bd..27e2f9fb68fa9 100644 --- a/spec/features/issues/service_desk_spec.rb +++ b/spec/features/issues/service_desk_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Service Desk Issue Tracker', :js, feature_category: :team_planning do +RSpec.describe 'Service Desk Issue Tracker', :js, feature_category: :service_desk do let(:project) { create(:project, :private, service_desk_enabled: true) } let_it_be(:user) { create(:user) } @@ -181,6 +181,7 @@ context 'when service_desk_vue_list feature flag is enabled' do before do stub_feature_flags(service_desk_vue_list: true) + stub_feature_flags(frontend_caching: true) end context 'when there are issues' do @@ -189,6 +190,20 @@ let_it_be(:service_desk_issue) { create(:issue, project: project, title: 'Help from email', author: support_bot, service_desk_reply_to: 'service.desk@example.com') } let_it_be(:other_user_issue) { create(:issue, project: project, author: other_user) } + describe 'service desk info content' do + before do + visit service_desk_project_issues_path(project) + end + + it 'displays the small info box, documentation, a button to configure service desk, and the address' do + aggregate_failures do + expect(page).to have_link('Learn more', href: help_page_path('user/project/service_desk')) + expect(page).not_to have_link('Enable Service Desk') + expect(page).to have_content(project.service_desk_address) + end + end + end + describe 'issues list' do before do visit service_desk_project_issues_path(project) diff --git a/spec/frontend/service_desk/components/info_banner_spec.js b/spec/frontend/service_desk/components/info_banner_spec.js new file mode 100644 index 0000000000000..7487d5d8b64e2 --- /dev/null +++ b/spec/frontend/service_desk/components/info_banner_spec.js @@ -0,0 +1,81 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink, GlButton } from '@gitlab/ui'; +import InfoBanner from '~/service_desk/components/info_banner.vue'; +import { infoBannerAdminNote, enableServiceDesk } from '~/service_desk/constants'; + +describe('InfoBanner', () => { + let wrapper; + + const defaultProvide = { + serviceDeskCalloutSvgPath: 'callout.svg', + serviceDeskEmailAddress: 'sd@gmail.com', + canAdminIssues: true, + canEditProjectSettings: true, + serviceDeskSettingsPath: 'path/to/project/settings', + serviceDeskHelpPath: 'path/to/documentation', + isServiceDeskEnabled: true, + }; + + const findEnableSDButton = () => wrapper.findComponent(GlButton); + + const mountComponent = (provide) => { + return shallowMount(InfoBanner, { + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { + GlLink, + GlButton, + }, + }); + }; + + beforeEach(() => { + wrapper = mountComponent(); + }); + + describe('Service Desk email address', () => { + it('renders when user can admin issues and service desk is enabled', () => { + expect(wrapper.text()).toContain(infoBannerAdminNote); + expect(wrapper.text()).toContain(wrapper.vm.serviceDeskEmailAddress); + }); + + it('does not render, when user can not admin issues', () => { + wrapper = mountComponent({ canAdminIssues: false }); + + expect(wrapper.text()).not.toContain(infoBannerAdminNote); + expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); + }); + + it('does not render, when service desk is not setup', () => { + wrapper = mountComponent({ isServiceDeskEnabled: false }); + + expect(wrapper.text()).not.toContain(infoBannerAdminNote); + expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); + }); + }); + + describe('Link to Service Desk settings', () => { + it('renders when user can edit settings and service desk is not enabled', () => { + wrapper = mountComponent({ isServiceDeskEnabled: false }); + + expect(wrapper.text()).toContain(enableServiceDesk); + expect(findEnableSDButton().exists()).toBe(true); + }); + + it('does not render when service desk is enabled', () => { + wrapper = mountComponent(); + + expect(wrapper.text()).not.toContain(enableServiceDesk); + expect(findEnableSDButton().exists()).toBe(false); + }); + + it('does not render when user cannot edit settings', () => { + wrapper = mountComponent({ canEditProjectSettings: false }); + + expect(wrapper.text()).not.toContain(enableServiceDesk); + expect(findEnableSDButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/service_desk/components/service_desk_list_app_spec.js b/spec/frontend/service_desk/components/service_desk_list_app_spec.js index 14132ae183027..2ac789745aa3c 100644 --- a/spec/frontend/service_desk/components/service_desk_list_app_spec.js +++ b/spec/frontend/service_desk/components/service_desk_list_app_spec.js @@ -10,6 +10,7 @@ import { STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants'; import getServiceDeskIssuesQuery from '~/service_desk/queries/get_service_desk_issues.query.graphql'; import getServiceDeskIssuesCountsQuery from '~/service_desk/queries/get_service_desk_issues_counts.query.graphql'; import ServiceDeskListApp from '~/service_desk/components/service_desk_list_app.vue'; +import InfoBanner from '~/service_desk/components/info_banner.vue'; import { getServiceDeskIssuesQueryResponse, getServiceDeskIssuesCountsQueryResponse, @@ -27,6 +28,8 @@ describe('ServiceDeskListApp', () => { isProject: true, isSignedIn: true, fullPath: 'path/to/project', + isServiceDeskSupported: true, + hasAnyIssues: true, }; const defaultQueryResponse = getServiceDeskIssuesQueryResponse; @@ -37,6 +40,7 @@ describe('ServiceDeskListApp', () => { .mockResolvedValue(getServiceDeskIssuesCountsQueryResponse); const findIssuableList = () => wrapper.findComponent(IssuableList); + const findInfoBanner = () => wrapper.findComponent(InfoBanner); const mountComponent = ({ provide = {}, @@ -98,7 +102,20 @@ describe('ServiceDeskListApp', () => { }); }); - describe('events', () => { + describe('InfoBanner', () => { + it('renders when Service Desk is supported and has any number of issues', () => { + expect(findInfoBanner().exists()).toBe(true); + }); + + it('does not render, when there are no issues', async () => { + wrapper = mountComponent({ provide: { hasAnyIssues: false } }); + await waitForPromises(); + + expect(findInfoBanner().exists()).toBe(false); + }); + }); + + describe('Events', () => { describe('when "click-tab" event is emitted by IssuableList', () => { beforeEach(() => { mountComponent(); @@ -112,7 +129,7 @@ describe('ServiceDeskListApp', () => { }); }); - describe('errors', () => { + describe('Errors', () => { describe.each` error | mountOption | message ${'fetching issues'} | ${'serviceDeskIssuesQueryResponse'} | ${ServiceDeskListApp.i18n.errorFetchingIssues} -- GitLab