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