diff --git a/ee/app/assets/javascripts/escalation_policies/components/escalation_policies_wrapper.vue b/ee/app/assets/javascripts/escalation_policies/components/escalation_policies_wrapper.vue index 529c9f5bb9ddab9d5d4396e715882f0790ce4f60..ff971037a6382f1d3022eddce28e3459c65adb46 100644 --- a/ee/app/assets/javascripts/escalation_policies/components/escalation_policies_wrapper.vue +++ b/ee/app/assets/javascripts/escalation_policies/components/escalation_policies_wrapper.vue @@ -1,5 +1,13 @@ <script> -import { GlEmptyState, GlButton, GlModalDirective, GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { + GlEmptyState, + GlButton, + GlModalDirective, + GlLoadingIcon, + GlAlert, + GlSprintf, + GlLink, +} from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; import { addEscalationPolicyModalId } from '../constants'; @@ -16,6 +24,9 @@ export const i18n = { description: s__( "EscalationPolicies|Choose who to email if those contacted first about an alert don't respond.", ), + unauthorizedDescription: s__( + "EscalationPolicies|Choose who to email if those contacted first about an alert don't respond. To access this feature, ask %{linkStart}a project Owner%{linkEnd} to grant you at least the Maintainer role.", + ), button: s__('EscalationPolicies|Add an escalation policy'), }, policyCreatedAlert: { @@ -34,13 +45,20 @@ export default { GlButton, GlLoadingIcon, GlAlert, + GlSprintf, + GlLink, AddEscalationPolicyModal, EscalationPolicy, }, directives: { GlModal: GlModalDirective, }, - inject: ['projectPath', 'emptyEscalationPoliciesSvgPath'], + inject: [ + 'projectPath', + 'emptyEscalationPoliciesSvgPath', + 'userCanCreateEscalationPolicy', + 'accessLevelDescriptionPath', + ], data() { return { escalationPolicies: [], @@ -108,10 +126,19 @@ export default { <gl-empty-state v-else :title="$options.i18n.emptyState.title" - :description="$options.i18n.emptyState.description" :svg-path="emptyEscalationPoliciesSvgPath" > - <template #actions> + <template #description> + <p v-if="userCanCreateEscalationPolicy"> + {{ $options.i18n.emptyState.description }} + </p> + <gl-sprintf v-else :message="$options.i18n.emptyState.unauthorizedDescription"> + <template #link="{ content }"> + <gl-link :href="accessLevelDescriptionPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + <template v-if="userCanCreateEscalationPolicy" #actions> <gl-button v-gl-modal="$options.addEscalationPolicyModalId" variant="confirm"> {{ $options.i18n.emptyState.button }} </gl-button> diff --git a/ee/app/assets/javascripts/escalation_policies/index.js b/ee/app/assets/javascripts/escalation_policies/index.js index 090f375e7ec757b758960b0a7287353dcadf1c8d..632c12d9503f75c438fb5ebe71e89db331555b25 100644 --- a/ee/app/assets/javascripts/escalation_policies/index.js +++ b/ee/app/assets/javascripts/escalation_policies/index.js @@ -1,6 +1,7 @@ import { defaultDataIdFromObject } from '@apollo/client/core'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { parseBoolean } from '~/lib/utils/common_utils'; import createDefaultClient from '~/lib/graphql'; import EscalationPoliciesWrapper from './components/escalation_policies_wrapper.vue'; @@ -28,7 +29,12 @@ export default () => { if (!el) return null; - const { emptyEscalationPoliciesSvgPath, projectPath = '' } = el.dataset; + const { + emptyEscalationPoliciesSvgPath, + projectPath = '', + userCanCreateEscalationPolicy, + accessLevelDescriptionPath, + } = el.dataset; return new Vue({ el, @@ -36,6 +42,8 @@ export default () => { provide: { projectPath, emptyEscalationPoliciesSvgPath, + userCanCreateEscalationPolicy: parseBoolean(userCanCreateEscalationPolicy), + accessLevelDescriptionPath, }, render(createElement) { return createElement(EscalationPoliciesWrapper); diff --git a/ee/app/assets/javascripts/oncall_schedules/components/oncall_schedules_wrapper.vue b/ee/app/assets/javascripts/oncall_schedules/components/oncall_schedules_wrapper.vue index a52cca85527757f0ea63e672c39a9f4b99ffb44b..4713702ef6257e99a4d81f0c4a430d73b8351e75 100644 --- a/ee/app/assets/javascripts/oncall_schedules/components/oncall_schedules_wrapper.vue +++ b/ee/app/assets/javascripts/oncall_schedules/components/oncall_schedules_wrapper.vue @@ -60,6 +60,7 @@ export default { 'projectPath', 'escalationPoliciesPath', 'userCanCreateSchedule', + 'accessLevelDescriptionPath', ], data() { return { @@ -170,7 +171,7 @@ export default { </p> <gl-sprintf v-else :message="$options.i18n.emptyState.unauthorizedDescription"> <template #link="{ content }"> - <gl-link href="project_members?sort=access_level_desc">{{ content }}</gl-link> + <gl-link :href="accessLevelDescriptionPath">{{ content }}</gl-link> </template> </gl-sprintf> </template> diff --git a/ee/app/assets/javascripts/oncall_schedules/index.js b/ee/app/assets/javascripts/oncall_schedules/index.js index 22000bd28cc0f8882878dca0d319fffac92023be..72f19027f247ff8b57ef99a3b58b6923748bee29 100644 --- a/ee/app/assets/javascripts/oncall_schedules/index.js +++ b/ee/app/assets/javascripts/oncall_schedules/index.js @@ -18,6 +18,7 @@ export default () => { timezones, escalationPoliciesPath, userCanCreateSchedule, + accessLevelDescriptionPath, } = el.dataset; apolloProvider.clients.defaultClient.cache.writeQuery({ @@ -36,6 +37,7 @@ export default () => { timezones: JSON.parse(timezones), escalationPoliciesPath, userCanCreateSchedule: parseBoolean(userCanCreateSchedule), + accessLevelDescriptionPath, }, render(createElement) { return createElement(OnCallSchedulesWrapper); diff --git a/ee/app/helpers/incident_management/escalation_policy_helper.rb b/ee/app/helpers/incident_management/escalation_policy_helper.rb index f020c36849eb702d8bc2fa55c2d8018d66c21e8c..265f26809af453f340c6915f10abe4ef859e85f2 100644 --- a/ee/app/helpers/incident_management/escalation_policy_helper.rb +++ b/ee/app/helpers/incident_management/escalation_policy_helper.rb @@ -4,8 +4,16 @@ module IncidentManagement module EscalationPolicyHelper def escalation_policy_data(project) { - 'project-path' => project.full_path, - 'empty_escalation_policies_svg_path' => image_path('illustrations/empty-state/empty-escalation.svg') + 'project-path' => project.full_path, + 'empty_escalation_policies_svg_path' => image_path('illustrations/empty-state/empty-escalation.svg'), + 'user_can_create_escalation_policy' => can?( + current_user, + :admin_incident_management_escalation_policy, project + ).to_s, + 'access_level_description_path' => Gitlab::Routing.url_helpers.project_project_members_url( + project, + sort: 'access_level_desc' + ) } end end diff --git a/ee/app/helpers/incident_management/oncall_schedule_helper.rb b/ee/app/helpers/incident_management/oncall_schedule_helper.rb index 9cb26a0ca1922b5ac96a15a59f1fc18f7cbf1d09..8f30c23fb6bf3273c0171f0f71826c87cbafff1f 100644 --- a/ee/app/helpers/incident_management/oncall_schedule_helper.rb +++ b/ee/app/helpers/incident_management/oncall_schedule_helper.rb @@ -8,7 +8,11 @@ def oncall_schedule_data(project) 'empty-oncall-schedules-svg-path' => image_path('illustrations/empty-state/empty-on-call.svg'), 'timezones' => timezone_data(format: :full).to_json, 'escalation-policies-path' => project_incident_management_escalation_policies_path(project), - 'user_can_create_schedule' => can?(current_user, :admin_incident_management_oncall_schedule, project).to_s + 'user_can_create_schedule' => can?(current_user, :admin_incident_management_oncall_schedule, project).to_s, + 'access_level_description_path' => Gitlab::Routing.url_helpers.project_project_members_url( + project, + sort: 'access_level_desc' + ) } end end diff --git a/ee/spec/frontend/escalation_policies/escalation_policy_wrapper_spec.js b/ee/spec/frontend/escalation_policies/escalation_policy_wrapper_spec.js index 438d41cdc63dc0d35f6f29d0477df3b51a1e07b7..b1929e54fc8748a8487e8305dd0d4c649ebb0857 100644 --- a/ee/spec/frontend/escalation_policies/escalation_policy_wrapper_spec.js +++ b/ee/spec/frontend/escalation_policies/escalation_policy_wrapper_spec.js @@ -1,18 +1,26 @@ -import { GlEmptyState, GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { GlEmptyState, GlLoadingIcon, GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; import { nextTick } from 'vue'; -import EscalationPoliciesWrapper from 'ee/escalation_policies/components/escalation_policies_wrapper.vue'; +import EscalationPoliciesWrapper, { + i18n, +} from 'ee/escalation_policies/components/escalation_policies_wrapper.vue'; import EscalationPolicy from 'ee/escalation_policies/components/escalation_policy.vue'; import AddEscalationPolicyModal from 'ee/escalation_policies/components/add_edit_escalation_policy_modal.vue'; import { parsePolicy } from 'ee/escalation_policies/utils'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import mockEscalationPolicies from './mocks/mockPolicies.json'; describe('Escalation Policies Wrapper', () => { let wrapper; const emptyEscalationPoliciesSvgPath = 'illustration/path.svg'; const projectPath = 'group/project'; + const accessLevelDescriptionPath = 'group/project/-/project_members?sort=access_level_desc'; - function mountComponent({ loading = false, escalationPolicies = [] } = {}) { + function mountComponent({ + loading = false, + escalationPolicies = [], + userCanCreateEscalationPolicy = true, + isShallowExtendedMount = true, + } = {}) { const $apollo = { queries: { escalationPolicies: { @@ -20,20 +28,30 @@ describe('Escalation Policies Wrapper', () => { }, }, }; - wrapper = shallowMountExtended(EscalationPoliciesWrapper, { + + const mountProps = { provide: { emptyEscalationPoliciesSvgPath, projectPath, + userCanCreateEscalationPolicy, + accessLevelDescriptionPath, }, mocks: { $apollo, }, + stubs: { + GlSprintf, + }, data() { return { escalationPolicies, }; }, - }); + }; + + wrapper = isShallowExtendedMount + ? shallowMountExtended(EscalationPoliciesWrapper, mountProps) + : mountExtended(EscalationPoliciesWrapper, mountProps); } beforeEach(() => { @@ -76,6 +94,24 @@ describe('Escalation Policies Wrapper', () => { }); }); + describe('Escalation policy empty state', () => { + it('should allow to create policy when user is at least a maintainer', () => { + mountComponent({ isShallowExtendedMount: false }); + + expect(findEmptyState().props('title')).toBe(i18n.emptyState.title); + expect(wrapper.findByText(i18n.emptyState.description).exists()).toBe(true); + expect(wrapper.findByRole('button', { name: i18n.emptyState.button }).exists()).toBe(true); + }); + + it('should show message about role restrictions when user is below maintainer level', () => { + mountComponent({ userCanCreateEscalationPolicy: false, isShallowExtendedMount: false }); + + expect(findEmptyState().props('title')).toBe(i18n.emptyState.title); + expect(wrapper.findComponent(GlLink).exists()).toBe(true); + expect(wrapper.findByRole('button', { name: i18n.emptyState.button }).exists()).toBe(false); + }); + }); + describe('Escalation policy created alert', () => { it('should display alert when when policy created', async () => { mountComponent({ diff --git a/ee/spec/frontend/oncall_schedule/oncall_schedule_wrapper_spec.js b/ee/spec/frontend/oncall_schedule/oncall_schedule_wrapper_spec.js index 11d6515b7fad8759555384055f5ee33c7a742388..a14b75075e016fc46be281224b78954df26e54a9 100644 --- a/ee/spec/frontend/oncall_schedule/oncall_schedule_wrapper_spec.js +++ b/ee/spec/frontend/oncall_schedule/oncall_schedule_wrapper_spec.js @@ -21,6 +21,7 @@ describe('On-call schedule wrapper', () => { const emptyOncallSchedulesSvgPath = 'illustration/path.svg'; const projectPath = 'group/project'; const escalationPoliciesPath = 'group/project/-/escalation_policies'; + const accessLevelDescriptionPath = 'group/project/-/project_members?sort=access_level_desc'; function mountComponent({ loading = false, @@ -48,6 +49,7 @@ describe('On-call schedule wrapper', () => { escalationPoliciesPath, userCanCreateSchedule, timezones: mockTimezones, + accessLevelDescriptionPath, }, directives: { GlTooltip: createMockDirective(), @@ -85,6 +87,7 @@ describe('On-call schedule wrapper', () => { projectPath, escalationPoliciesPath, userCanCreateSchedule: true, + accessLevelDescriptionPath, }, }); } diff --git a/ee/spec/helpers/incident_management/escalation_policy_helper_spec.rb b/ee/spec/helpers/incident_management/escalation_policy_helper_spec.rb index be22a16cf0c1398c12414f2d282f4dc20f46eebc..71fea6ab7dc79650e857df6e0d4f0614648fed88 100644 --- a/ee/spec/helpers/incident_management/escalation_policy_helper_spec.rb +++ b/ee/spec/helpers/incident_management/escalation_policy_helper_spec.rb @@ -2,16 +2,30 @@ require 'spec_helper' -RSpec.describe IncidentManagement::EscalationPolicyHelper do +RSpec.describe IncidentManagement::EscalationPolicyHelper, feature_category: :incident_management do + let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project) } describe '#escalation_policy_data' do subject(:data) { helper.escalation_policy_data(project) } - it 'returns scalation policies data' do + before do + allow(helper).to receive(:current_user) { user } + allow(helper).to receive(:can?).with( + user, + :admin_incident_management_escalation_policy, project + ).and_return(false) + end + + it 'returns escalation policies data' do is_expected.to eq( 'project-path' => project.full_path, - 'empty_escalation_policies_svg_path' => helper.image_path('illustrations/empty-state/empty-escalation.svg') + 'empty_escalation_policies_svg_path' => helper.image_path('illustrations/empty-state/empty-escalation.svg'), + 'user_can_create_escalation_policy' => 'false', + 'access_level_description_path' => Gitlab::Routing.url_helpers.project_project_members_url( + project, + sort: 'access_level_desc' + ) ) end end diff --git a/ee/spec/helpers/incident_management/oncall_schedule_helper_spec.rb b/ee/spec/helpers/incident_management/oncall_schedule_helper_spec.rb index 06ffbab2bc9a3a6f230a6609ef6a1e5d719339dc..2341de26c8a5d785e66c5d61883489910d155f23 100644 --- a/ee/spec/helpers/incident_management/oncall_schedule_helper_spec.rb +++ b/ee/spec/helpers/incident_management/oncall_schedule_helper_spec.rb @@ -20,7 +20,11 @@ 'empty-oncall-schedules-svg-path' => helper.image_path('illustrations/empty-state/empty-on-call.svg'), 'timezones' => helper.timezone_data(format: :full).to_json, 'escalation-policies-path' => project_incident_management_escalation_policies_path(project), - 'user_can_create_schedule' => 'false' + 'user_can_create_schedule' => 'false', + 'access_level_description_path' => Gitlab::Routing.url_helpers.project_project_members_url( + project, + sort: 'access_level_desc' + ) ) end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 95d2015d78cfc907f602c57667654401fb996685..75c2c38d1e1836865127e31954e7f8b5fdb86feb 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -16436,6 +16436,9 @@ msgstr "" msgid "EscalationPolicies|Choose who to email if those contacted first about an alert don't respond." msgstr "" +msgid "EscalationPolicies|Choose who to email if those contacted first about an alert don't respond. To access this feature, ask %{linkStart}a project Owner%{linkEnd} to grant you at least the Maintainer role." +msgstr "" + msgid "EscalationPolicies|Create an escalation policy in GitLab" msgstr ""