Commit 24a3a72d authored by Sarah Yasonik's avatar Sarah Yasonik Committed by Phil Hughes

Add escalation policy dropdown to incidents sidebar

Adds widget to incidents to allow the user to set
or remove an escalation policy. This is used in
conjuction with the escalation status on incidents
to manage paging and escalations for the issue.
Co-Authored-By: default avatarSarah Yasonik <syasonik@gitlab.com>
parent 3d6d5913
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
GlIcon, GlIcon,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { kebabCase, snakeCase } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issues/constants'; import { IssuableType } from '~/issues/constants';
...@@ -221,6 +222,12 @@ export default { ...@@ -221,6 +222,12 @@ export default {
// MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311 // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311
return this.issuableAttribute === IssuableType.Epic; return this.issuableAttribute === IssuableType.Epic;
}, },
formatIssuableAttribute() {
return {
kebab: kebabCase(this.issuableAttribute),
snake: snakeCase(this.issuableAttribute),
};
},
}, },
methods: { methods: {
updateAttribute(attributeId) { updateAttribute(attributeId) {
...@@ -300,26 +307,28 @@ export default { ...@@ -300,26 +307,28 @@ export default {
<sidebar-editable-item <sidebar-editable-item
ref="editable" ref="editable"
:title="attributeTypeTitle" :title="attributeTypeTitle"
:data-testid="`${issuableAttribute}-edit`" :data-testid="`${formatIssuableAttribute.kebab}-edit`"
:tracking="tracking" :tracking="tracking"
:loading="updating || loading" :loading="updating || loading"
@open="handleOpen" @open="handleOpen"
@close="handleClose" @close="handleClose"
> >
<template #collapsed> <template #collapsed>
<slot name="value-collapsed" :current-attribute="currentAttribute">
<div
v-if="isClassicSidebar"
v-gl-tooltip.left.viewport
:title="attributeTypeTitle"
class="sidebar-collapsed-icon"
>
<gl-icon :aria-label="attributeTypeTitle" :name="attributeTypeIcon" />
<span class="collapse-truncated-title">
{{ attributeTitle }}
</span>
</div>
</slot>
<div <div
v-if="isClassicSidebar" :data-testid="`select-${formatIssuableAttribute.kebab}`"
v-gl-tooltip.left.viewport
:title="attributeTypeTitle"
class="sidebar-collapsed-icon"
>
<gl-icon :size="16" :aria-label="attributeTypeTitle" :name="attributeTypeIcon" />
<span class="collapse-truncated-title">
{{ attributeTitle }}
</span>
</div>
<div
:data-testid="`select-${issuableAttribute}`"
:class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'" :class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'"
> >
<span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span> <span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span>
...@@ -337,7 +346,7 @@ export default { ...@@ -337,7 +346,7 @@ export default {
v-gl-tooltip="tooltipText" v-gl-tooltip="tooltipText"
class="gl-text-gray-900! gl-font-weight-bold" class="gl-text-gray-900! gl-font-weight-bold"
:href="attributeUrl" :href="attributeUrl"
:data-qa-selector="`${issuableAttribute}_link`" :data-qa-selector="`${formatIssuableAttribute.snake}_link`"
> >
{{ attributeTitle }} {{ attributeTitle }}
<span v-if="isAttributeOverdue(currentAttribute)">{{ $options.i18n.expired }}</span> <span v-if="isAttributeOverdue(currentAttribute)">{{ $options.i18n.expired }}</span>
...@@ -359,7 +368,7 @@ export default { ...@@ -359,7 +368,7 @@ export default {
> >
<gl-search-box-by-type ref="search" v-model="searchTerm" /> <gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item <gl-dropdown-item
:data-testid="`no-${issuableAttribute}-item`" :data-testid="`no-${formatIssuableAttribute.kebab}-item`"
:is-check-item="true" :is-check-item="true"
:is-checked="isAttributeChecked($options.noAttributeId)" :is-checked="isAttributeChecked($options.noAttributeId)"
@click="updateAttribute($options.noAttributeId)" @click="updateAttribute($options.noAttributeId)"
...@@ -389,7 +398,7 @@ export default { ...@@ -389,7 +398,7 @@ export default {
:key="attrItem.id" :key="attrItem.id"
:is-check-item="true" :is-check-item="true"
:is-checked="isAttributeChecked(attrItem.id)" :is-checked="isAttributeChecked(attrItem.id)"
:data-testid="`${issuableAttribute}-items`" :data-testid="`${formatIssuableAttribute.kebab}-items`"
@click="updateAttribute(attrItem.id)" @click="updateAttribute(attrItem.id)"
> >
{{ attrItem.title }} {{ attrItem.title }}
......
...@@ -38,7 +38,10 @@ export default { ...@@ -38,7 +38,10 @@ export default {
</script> </script>
<template> <template>
<div data-testid="helpPane" class="time-tracking-help-state"> <div
data-testid="helpPane"
class="sidebar-help-state gl-bg-white gl-border-gray-100 gl-border-t-solid gl-border-b-solid gl-border-1"
>
<div class="time-tracking-info"> <div class="time-tracking-info">
<h4>{{ __('Track time with quick actions') }}</h4> <h4>{{ __('Track time with quick actions') }}</h4>
<p>{{ __('Quick actions can be used in description and comment boxes.') }}</p> <p>{{ __('Quick actions can be used in description and comment boxes.') }}</p>
......
<script> <script>
import { GlIcon, GlLink, GlModal, GlModalDirective, GlLoadingIcon } from '@gitlab/ui'; import { GlIcon, GlLink, GlModal, GlButton, GlModalDirective, GlLoadingIcon } from '@gitlab/ui';
import { IssuableType } from '~/issues/constants'; import { IssuableType } from '~/issues/constants';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { timeTrackingQueries } from '~/sidebar/constants'; import { timeTrackingQueries } from '~/sidebar/constants';
...@@ -21,6 +21,7 @@ export default { ...@@ -21,6 +21,7 @@ export default {
GlIcon, GlIcon,
GlLink, GlLink,
GlModal, GlModal,
GlButton,
GlLoadingIcon, GlLoadingIcon,
TimeTrackingCollapsedState, TimeTrackingCollapsedState,
TimeTrackingSpentOnlyPane, TimeTrackingSpentOnlyPane,
...@@ -187,7 +188,11 @@ export default { ...@@ -187,7 +188,11 @@ export default {
</script> </script>
<template> <template>
<div v-cloak class="time-tracker time-tracking-component-wrap" data-testid="time-tracker"> <div
v-cloak
class="time-tracker time-tracking-component-wrap sidebar-help-wrap"
data-testid="time-tracker"
>
<time-tracking-collapsed-state <time-tracking-collapsed-state
v-if="showCollapsed" v-if="showCollapsed"
:show-comparison-state="showComparisonState" :show-comparison-state="showComparisonState"
...@@ -198,25 +203,21 @@ export default { ...@@ -198,25 +203,21 @@ export default {
:time-spent-human-readable="humanTotalTimeSpent" :time-spent-human-readable="humanTotalTimeSpent"
:time-estimate-human-readable="humanTimeEstimate" :time-estimate-human-readable="humanTimeEstimate"
/> />
<div class="hide-collapsed gl-line-height-20 gl-text-gray-900"> <div
class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center"
>
{{ __('Time tracking') }} {{ __('Time tracking') }}
<gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" inline /> <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" inline />
<div <gl-button
v-if="!showHelpState" :data-testid="showHelpState ? 'closeHelpButton' : 'helpButton'"
data-testid="helpButton" category="tertiary"
class="help-button float-right" size="small"
@click="toggleHelpState(true)" variant="link"
class="gl-ml-auto"
@click="toggleHelpState(!showHelpState)"
> >
<gl-icon name="question-o" /> <gl-icon :name="showHelpState ? 'close' : 'question-o'" class="gl-text-gray-900!" />
</div> </gl-button>
<div
v-else
data-testid="closeHelpButton"
class="close-help-button float-right"
@click="toggleHelpState(false)"
>
<gl-icon name="close" />
</div>
</div> </div>
<div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed"> <div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed">
<div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane"> <div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane">
......
...@@ -742,6 +742,26 @@ ...@@ -742,6 +742,26 @@
} }
} }
.sidebar-help-wrap {
.sidebar-help-state {
margin: 16px -20px -20px;
padding: 16px 20px;
}
.help-state-toggle-enter-active {
transition: all 0.8s ease;
}
.help-state-toggle-leave-active {
transition: all 0.5s ease;
}
.help-state-toggle-enter,
.help-state-toggle-leave-active {
opacity: 0;
}
}
.time-tracker { .time-tracker {
.sidebar-collapsed-icon { .sidebar-collapsed-icon {
> .stopwatch-svg { > .stopwatch-svg {
...@@ -759,11 +779,6 @@ ...@@ -759,11 +779,6 @@
} }
} }
.help-button,
.close-help-button {
cursor: pointer;
}
.compare-meter { .compare-meter {
&.over_estimate { &.over_estimate {
.time-remaining, .time-remaining,
...@@ -776,31 +791,6 @@ ...@@ -776,31 +791,6 @@
.compare-display-container { .compare-display-container {
font-size: 13px; font-size: 13px;
} }
.time-tracking-help-state {
background: $white;
margin: 16px -20px -20px;
padding: 16px 20px;
border-top: 1px solid $border-gray-light;
border-bottom: 1px solid $border-gray-light;
a:hover {
color: $btn-white-active;
}
}
.help-state-toggle-enter-active {
transition: all 0.8s ease;
}
.help-state-toggle-leave-active {
transition: all 0.5s ease;
}
.help-state-toggle-enter,
.help-state-toggle-leave-active {
opacity: 0;
}
} }
.issuable-todo-btn { .issuable-todo-btn {
......
import { s__, __ } from '~/locale';
import { SIDEBAR_ESCALATION_POLICY_TITLE, none } from '../../constants';
export const i18nHelpText = {
title: s__('IncidentManagement|Page your team with escalation policies'),
detail: s__(
'IncidentManagement|Use escalation policies to automatically page your team when incidents are created.',
),
linkText: __('Learn more'),
};
export const i18nPolicyText = {
paged: s__('IncidentManagement|Paged'),
title: SIDEBAR_ESCALATION_POLICY_TITLE,
none,
};
<script>
import { GlIcon, GlButton } from '@gitlab/ui';
import { i18nPolicyText } from './constants';
import EscalationPolicyHelpState from './escalation_policy_help_state.vue';
import EscalationPolicyCollapsedState from './escalation_policy_collapsed_state.vue';
export default {
i18n: i18nPolicyText,
components: {
GlIcon,
GlButton,
EscalationPolicyCollapsedState,
EscalationPolicyHelpState,
},
data() {
return {
showHelp: false,
};
},
methods: {
toggleHelpState() {
this.showHelp = !this.showHelp;
},
},
};
</script>
<template>
<div data-testid="escalation-policy-edit">
<div class="hide-collapsed sidebar-help-wrap">
<div class="gl-line-height-2 gl-text-gray-900 gl-display-flex gl-align-items-center gl-mb-2">
<span>{{ $options.i18n.title }}</span>
<gl-button
:data-testid="showHelp ? 'close-help-button' : 'help-button'"
category="tertiary"
size="small"
variant="link"
class="gl-ml-auto"
@click="toggleHelpState"
>
<gl-icon :name="showHelp ? 'close' : 'question-o'" class="gl-text-gray-900!" />
</gl-button>
</div>
<div data-testid="select-escalation-policy" class="hide-collapsed gl-line-height-14">
<span class="gl-text-gray-500">
{{ $options.i18n.none }}
</span>
</div>
<escalation-policy-help-state v-if="showHelp" />
</div>
<escalation-policy-collapsed-state />
</div>
</template>
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { i18nPolicyText } from './constants';
export default {
i18n: i18nPolicyText,
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
},
props: {
value: {
type: String,
required: false,
default: null,
},
},
computed: {
policyText() {
return this.value ? this.$options.i18n.paged : this.$options.i18n.none;
},
tooltipText() {
const policyName = this.value || this.$options.i18n.none;
return `${this.$options.i18n.title}: ${policyName}`;
},
},
};
</script>
<template>
<div
v-gl-tooltip.left.viewport
:title="tooltipText"
class="sidebar-collapsed-icon"
data-testid="sidebar-collapsed-attribute-title"
>
<gl-icon :aria-label="$options.i18n.title" name="mobile" />
<span class="collapse-truncated-title">
{{ policyText }}
</span>
</div>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { i18nHelpText } from './constants';
export default {
POLICIES_PATH: helpPagePath('operations/incident_management/escalation_policies.md'),
i18n: i18nHelpText,
components: {
GlButton,
},
};
</script>
<template>
<transition name="help-state-toggle">
<div
class="sidebar-help-state gl-bg-white gl-border-gray-100 gl-border-t-solid gl-border-b-solid gl-border-1"
>
<div>
<h4>{{ $options.i18n.title }}</h4>
<p>{{ $options.i18n.detail }}</p>
<gl-button :href="$options.POLICIES_PATH">{{ $options.i18n.linkText }}</gl-button>
</div>
</div>
</transition>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import { IssuableType } from '~/issues/constants';
import { IssuableAttributeType } from '../../constants';
import SidebarDropdownWidget from '../sidebar_dropdown_widget.vue';
import EscalationPoliciesEmptyState from './escalation_policies_empty_state.vue';
import EscalationPolicyCollapsedState from './escalation_policy_collapsed_state.vue';
export default {
INDEX_PATH: '-/escalation_policies',
components: {
SidebarDropdownWidget,
EscalationPolicyCollapsedState,
GlLink,
EscalationPoliciesEmptyState,
},
props: {
projectPath: {
required: true,
type: String,
},
iid: {
required: true,
type: String,
},
escalationsPossible: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
policiesPath() {
return joinPaths(gon.relative_url_root || '/', this.projectPath, this.$options.INDEX_PATH);
},
},
created() {
this.issuableType = IssuableType.Issue;
this.issuableAttribute = IssuableAttributeType.EscalationPolicy;
},
};
</script>
<template>
<sidebar-dropdown-widget
v-if="escalationsPossible"
:attr-workspace-path="projectPath"
:workspace-path="projectPath"
:iid="iid"
:issuable-type="issuableType"
:issuable-attribute="issuableAttribute"
>
<template #value-collapsed="{ currentAttribute }">
<escalation-policy-collapsed-state :value="currentAttribute && currentAttribute.title" />
</template>
<template #value="{ attributeTitle }">
<gl-link class="gl-text-gray-900! gl-font-weight-bold" :href="policiesPath">
{{ attributeTitle }}
</gl-link>
</template>
</sidebar-dropdown-widget>
<escalation-policies-empty-state v-else />
</template>
...@@ -6,12 +6,14 @@ import { ...@@ -6,12 +6,14 @@ import {
IssuableAttributeType, IssuableAttributeType,
IssuableAttributeState, IssuableAttributeState,
issuableAttributesQueries, issuableAttributesQueries,
SIDEBAR_ESCALATION_POLICY_TITLE,
} from '../constants'; } from '../constants';
const widgetTitleText = { const widgetTitleText = {
[IssuableAttributeType.Milestone]: __('Milestone'), [IssuableAttributeType.Milestone]: __('Milestone'),
[IssuableAttributeType.Iteration]: __('Iteration'), [IssuableAttributeType.Iteration]: __('Iteration'),
[IssuableAttributeType.Epic]: __('Epic'), [IssuableAttributeType.Epic]: __('Epic'),
[IssuableAttributeType.EscalationPolicy]: SIDEBAR_ESCALATION_POLICY_TITLE,
none: __('None'), none: __('None'),
expired: __('(expired)'), expired: __('(expired)'),
}; };
...@@ -33,6 +35,7 @@ export default { ...@@ -33,6 +35,7 @@ export default {
IssuableAttributeType.Milestone, IssuableAttributeType.Milestone,
IssuableAttributeType.Iteration, IssuableAttributeType.Iteration,
IssuableAttributeType.Epic, IssuableAttributeType.Epic,
IssuableAttributeType.EscalationPolicy,
].includes(value); ].includes(value);
}, },
}, },
......
...@@ -20,6 +20,9 @@ import projectIssueEpicQuery from './queries/project_issue_epic.query.graphql'; ...@@ -20,6 +20,9 @@ import projectIssueEpicQuery from './queries/project_issue_epic.query.graphql';
import projectIssueIterationMutation from './queries/project_issue_iteration.mutation.graphql'; import projectIssueIterationMutation from './queries/project_issue_iteration.mutation.graphql';
import projectIssueIterationQuery from './queries/project_issue_iteration.query.graphql'; import projectIssueIterationQuery from './queries/project_issue_iteration.query.graphql';
import updateIssueWeightMutation from './queries/update_issue_weight.mutation.graphql'; import updateIssueWeightMutation from './queries/update_issue_weight.mutation.graphql';
import issueEscalationPolicyQuery from './queries/issue_escalation_policy.query.graphql';
import issueEscalationPolicyMutation from './queries/issue_escalation_policy.mutation.graphql';
import projectEscalationPoliciesQuery from './queries/project_escalation_policies.query.graphql';
export { Tracking, defaultEpicSort, epicIidPattern }; export { Tracking, defaultEpicSort, epicIidPattern };
...@@ -60,6 +63,8 @@ export const healthStatusForRestApi = { ...@@ -60,6 +63,8 @@ export const healthStatusForRestApi = {
[healthStatus.AT_RISK]: 'at_risk', [healthStatus.AT_RISK]: 'at_risk',
}; };
export const SIDEBAR_ESCALATION_POLICY_TITLE = __('Escalation policy');
export const MAX_DISPLAY_WEIGHT = 99999; export const MAX_DISPLAY_WEIGHT = 99999;
export const I18N_DROPDOWN = { export const I18N_DROPDOWN = {
...@@ -109,10 +114,24 @@ const epicsQueries = { ...@@ -109,10 +114,24 @@ const epicsQueries = {
}, },
}; };
const issuableEscalationPolicyQueries = {
[IssuableType.Issue]: {
query: issueEscalationPolicyQuery,
mutation: issueEscalationPolicyMutation,
},
};
const escalationPoliciesQueries = {
[IssuableType.Issue]: {
query: projectEscalationPoliciesQuery,
},
};
export const IssuableAttributeType = { export const IssuableAttributeType = {
...IssuableAttributeTypeFoss, ...IssuableAttributeTypeFoss,
Iteration: 'iteration', Iteration: 'iteration',
Epic: 'epic', Epic: 'epic',
EscalationPolicy: 'escalation policy', // eslint-disable-line @gitlab/require-i18n-strings
}; };
export const IssuableAttributeState = { export const IssuableAttributeState = {
...@@ -131,6 +150,10 @@ export const issuableAttributesQueries = { ...@@ -131,6 +150,10 @@ export const issuableAttributesQueries = {
current: issuableEpicQueries, current: issuableEpicQueries,
list: epicsQueries, list: epicsQueries,
}, },
[IssuableAttributeType.EscalationPolicy]: {
current: issuableEscalationPolicyQueries,
list: escalationPoliciesQueries,
},
}; };
export const ancestorsQueries = { export const ancestorsQueries = {
......
...@@ -9,6 +9,7 @@ import IterationSidebarDropdownWidget from './components/iteration_sidebar_dropd ...@@ -9,6 +9,7 @@ import IterationSidebarDropdownWidget from './components/iteration_sidebar_dropd
import SidebarDropdownWidget from './components/sidebar_dropdown_widget.vue'; import SidebarDropdownWidget from './components/sidebar_dropdown_widget.vue';
import SidebarStatus from './components/status/sidebar_status.vue'; import SidebarStatus from './components/status/sidebar_status.vue';
import SidebarWeightWidget from './components/weight/sidebar_weight_widget.vue'; import SidebarWeightWidget from './components/weight/sidebar_weight_widget.vue';
import SidebarEscalationPolicy from './components/incidents/sidebar_escalation_policy.vue';
import { IssuableAttributeType } from './constants'; import { IssuableAttributeType } from './constants';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -148,6 +149,36 @@ function mountIterationSelect() { ...@@ -148,6 +149,36 @@ function mountIterationSelect() {
}); });
} }
function mountEscalationPoliciesSelect() {
const el = document.querySelector('#js-escalation-policy');
if (!el) {
return false;
}
const { canEdit, projectPath, issueIid, hasEscalationPolicies } = el.dataset;
return new Vue({
el,
apolloProvider,
components: {
SidebarEscalationPolicy,
},
provide: {
canUpdate: parseBoolean(canEdit),
isClassicSidebar: true,
},
render: (createElement) =>
createElement('sidebar-escalation-policy', {
props: {
projectPath,
iid: issueIid,
escalationsPossible: parseBoolean(hasEscalationPolicies),
},
}),
});
}
export const { getSidebarOptions } = CEMountSidebar; export const { getSidebarOptions } = CEMountSidebar;
export function mountSidebar(mediator, store) { export function mountSidebar(mediator, store) {
...@@ -156,5 +187,6 @@ export function mountSidebar(mediator, store) { ...@@ -156,5 +187,6 @@ export function mountSidebar(mediator, store) {
mountStatusComponent(store); mountStatusComponent(store);
mountEpicsSelect(); mountEpicsSelect();
mountIterationSelect(); mountIterationSelect();
mountEscalationPoliciesSelect();
mountCveIdRequestComponent(store); mountCveIdRequestComponent(store);
} }
fragment EscalationPolicyFragment on EscalationPolicyType {
__typename
id
title: name
}
#import "./escalation_policy.fragment.graphql"
mutation issueEscalationPolicyMutation(
$fullPath: ID!
$iid: String!
$attributeId: IncidentManagementEscalationPolicyID
) {
issuableSetAttribute: issueSetEscalationPolicy(
input: { projectPath: $fullPath, iid: $iid, escalationPolicyId: $attributeId }
) {
__typename
errors
issuable: issue {
__typename
id
attribute: escalationPolicy {
...EscalationPolicyFragment
}
escalationStatus
}
}
}
#import "./escalation_policy.fragment.graphql"
query issueEscalationPolicy($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
id
issuable: issue(iid: $iid) {
__typename
id
attribute: escalationPolicy {
...EscalationPolicyFragment
}
}
}
}
#import "./escalation_policy.fragment.graphql"
query projectEscalationPolicies($fullPath: ID!, $title: String) {
workspace: project(fullPath: $fullPath) {
__typename
id
attributes: incidentManagementEscalationPolicies(name: $title) {
nodes {
...EscalationPolicyFragment
__typename
}
}
}
}
- if issuable_sidebar[:supports_escalation_policies] - if issuable_sidebar[:supports_escalation_policies]
.block.escalation-policy{ data: { testid: 'escalation_policy_container' } } .block.escalation-policy{ data: { testid: 'escalation_policy_container' } }
#js-escalation-policy{ data: { can_edit: issuable_sidebar.dig(:current_user, :can_update_escalation_policy).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } } #js-escalation-policy{ data: { can_edit: issuable_sidebar.dig(:current_user, :can_update_escalation_policy).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], has_escalation_policies: @project.incident_management_escalation_policies.any?.to_s } }
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Incident details', :js do
let_it_be(:project) { create(:project) }
let_it_be(:reporter) { create(:user) }
let_it_be(:developer) { create(:user) }
let_it_be(:incident, reload: true) { create(:incident, :with_escalation_status, project: project) }
let(:current_user) { reporter }
let(:sidebar) { page.find('.right-sidebar') }
before_all do
project.add_reporter(reporter)
project.add_developer(developer)
end
before do
sign_in(current_user)
end
context 'escalation policy widget' do
let(:escalation_policy_container) { sidebar.find('[data-testid="escalation_policy_container"]') }
shared_examples 'hides the escalation policy widget' do
specify do
visit_incident_with_expanded_sidebar
expect(sidebar).not_to have_selector('[data-testid="escalation_policy_container"]')
end
end
shared_examples 'hides the edit button' do
specify do
visit_incident_with_expanded_sidebar
expect(escalation_policy_container).not_to have_selector('[data-testid="edit-button"]')
end
end
shared_examples 'shows empty state for escalation policy' do
specify do
visit_incident_with_expanded_sidebar
assert_expanded_policy_values('None')
collapse_sidebar
assert_collapsed_policy_values('None', 'None')
end
end
# Depends on escalation_policy being defined
shared_examples 'shows attributes of assigned escalation policy' do
specify do
visit_incident_with_expanded_sidebar
assert_expanded_policy_values(escalation_policy.name, href: true)
collapse_sidebar
assert_collapsed_policy_values('Paged', escalation_policy.name)
end
end
context 'escalation policies licensed feature available' do
before do
stub_licensed_features(oncall_schedules: true, escalation_policies: true)
end
context 'with incident_escalations feature flag enabled' do
context 'with escalation policies in the project' do
let_it_be(:escalation_policy) { create(:incident_management_escalation_policy, project: project) }
let(:edit_policy_widget) { escalation_policy_container.find('[data-testid="escalation-policy-edit"]') }
context 'without escalation policy linked to incident' do
context 'with only view permissions' do
it_behaves_like 'shows empty state for escalation policy'
it_behaves_like 'hides the edit button'
end
context 'with edit permissions' do
let(:current_user) { developer }
it_behaves_like 'shows empty state for escalation policy'
it 'can set the policy for the incident' do
visit_incident_with_expanded_sidebar
assert_edit_button_exists_and_click
assert_policy_in_list.click
assert_expanded_policy_values(escalation_policy.name, href: true)
end
it 'can search for policies' do
visit_incident_with_expanded_sidebar
assert_edit_button_exists_and_click
# List all
assert_policy_in_list
assert_null_policy_in_list
# Filter w/ match
search_bar.send_keys escalation_policy.name.first(3)
wait_for_requests
assert_policy_in_list
# Filter w/ no match
search_bar.send_keys 'Bar'
wait_for_requests
expect(edit_policy_widget).to have_content 'No escalation policy found'
end
end
end
context 'with escalation policy linked to incident' do
before do
incident.escalation_status.update!(policy: escalation_policy, escalations_started_at: Time.current)
end
context 'with only view permissions' do
it_behaves_like 'shows attributes of assigned escalation policy'
it_behaves_like 'hides the edit button'
end
context 'with edit permissions' do
let(:current_user) { developer }
it_behaves_like 'shows attributes of assigned escalation policy'
it 'can remove the policy from the incident' do
visit_incident_with_expanded_sidebar
assert_edit_button_exists_and_click
assert_null_policy_in_list.click
assert_expanded_policy_values('None')
end
context 'with alert associated with the incident' do
let_it_be(:alert) { create(:alert_management_alert, issue: incident) }
it_behaves_like 'shows attributes of assigned escalation policy'
it_behaves_like 'hides the edit button'
end
end
end
private
def assert_edit_button_exists_and_click
expect(edit_policy_widget).to have_button('Edit')
edit_button.click
wait_for_requests
end
def assert_policy_in_list
policy_item = edit_policy_widget.find('[data-testid="escalation-policy-items"]')
expect(policy_item).to have_content escalation_policy.name
policy_item
end
def assert_null_policy_in_list
null_policy_item = edit_policy_widget.find('[data-testid="no-escalation-policy-item"]')
expect(null_policy_item).to have_content 'No escalation policy'
null_policy_item
end
def edit_button
edit_policy_widget.find('[data-testid="edit-button"]')
end
def search_bar
edit_policy_widget.find('.gl-form-input')
end
end
context 'with no escalation policies in the project' do
it_behaves_like 'shows empty state for escalation policy'
it 'lets users open, view, and close the escalation policy help menu' do
visit_incident_with_expanded_sidebar
escalation_policy_container.find('[data-testid="help-button"]').click
expect(escalation_policy_container).to have_content('Page your team')
expect(escalation_policy_container).to have_content('Use escalation policies to automatically page your team')
escalation_policy_container.find('[data-testid="close-help-button"]').click
expect(escalation_policy_container).not_to have_content('Page your team')
end
end
end
context 'with incident_escalations feature flag disabled' do
before do
stub_feature_flags(incident_escalations: false)
end
it_behaves_like 'hides the escalation policy widget'
end
end
context 'escalation policies lisenced feature unavailable' do
it_behaves_like 'hides the escalation policy widget'
end
end
private
def visit_incident_with_collapsed_sidebar
visit project_issues_incident_path(project, incident)
wait_for_requests
collapse_sidebar
end
def visit_incident_with_expanded_sidebar
visit project_issues_incident_path(project, incident)
wait_for_requests
end
def expand_sidebar
sidebar.find('[data-testid="chevron-double-lg-left-icon"]').click
end
def collapse_sidebar
sidebar.find('[data-testid="chevron-double-lg-right-icon"]').click
end
def assert_collapsed_policy_values(collapsed_name, policy_name)
expect(escalation_policy_container).to have_selector('[data-testid="mobile-icon"]')
expect(escalation_policy_container).to have_content(collapsed_name)
escalation_policy_container.hover
expect(page).to have_content("Escalation policy: #{policy_name}")
end
def assert_expanded_policy_values(policy_name, href: false)
expect(escalation_policy_container).to have_content('Escalation policy')
if href
expect(escalation_policy_container).to have_link(
policy_name,
href: project_incident_management_escalation_policies_path(project)
)
else
expect(escalation_policy_container).to have_content(policy_name)
end
end
end
...@@ -275,6 +275,12 @@ RSpec.describe 'Issue Sidebar' do ...@@ -275,6 +275,12 @@ RSpec.describe 'Issue Sidebar' do
end end
end end
context 'escalation policy', :js do
it 'is not available for default issue type' do
expect(page).not_to have_selector('.block.escalation-policy')
end
end
def find_and_click_edit_iteration def find_and_click_edit_iteration
page.find('[data-testid="iteration-edit"] [data-testid="edit-button"]').click page.find('[data-testid="iteration-edit"] [data-testid="edit-button"]').click
......
export const mockEscalationPolicy1 = {
__typename: 'EscalationPolicyType',
id: 'gid://gitlab/IncidentManagement::EscalationPolicy/1',
title: 'First policy',
};
export const mockEscalationPolicy2 = {
__typename: 'EscalationPolicyType',
id: 'gid://gitlab/IncidentManagement::EscalationPolicy/2',
title: 'Second policy',
};
export const mockEscalationPoliciesResponse = {
data: {
workspace: {
__typename: 'Project',
id: 'gid://gitlab/Project/1',
attributes: {
nodes: [mockEscalationPolicy1, mockEscalationPolicy2],
__typename: 'EscalationPolicyTypeConnection',
},
},
},
};
export const mockCurrentEscalationPolicyResponse = {
data: {
workspace: {
__typename: 'Project',
id: 'gid://gitlab/Project/1',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
attribute: mockEscalationPolicy1,
escalationStatus: 'ACKNOWLEDGED',
},
},
},
};
export const mockNullEscalationPolicyResponse = {
data: {
workspace: {
__typename: 'Project',
id: 'gid://gitlab/Project/1',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
attribute: null,
escalationStatus: 'TRIGGERED',
},
},
},
};
import VueApollo from 'vue-apollo';
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import SidebarEscalationPolicy from 'ee/sidebar/components/incidents/sidebar_escalation_policy.vue';
import policiesQuery from 'ee/sidebar/queries/project_escalation_policies.query.graphql';
import currentPolicyQuery from 'ee/sidebar/queries/issue_escalation_policy.query.graphql';
import { clickEdit } from '../../helpers';
import {
mockEscalationPolicy1,
mockEscalationPolicy2,
mockEscalationPoliciesResponse,
mockCurrentEscalationPolicyResponse,
mockNullEscalationPolicyResponse,
} from './mock_data';
Vue.use(VueApollo);
describe('Sidebar Escalation Policy Widget', () => {
let wrapper;
let mockApollo;
let propsData;
let provide;
let escalationPolicyResponse;
const createComponent = async () => {
mockApollo = createMockApollo([
[currentPolicyQuery, jest.fn().mockResolvedValue(escalationPolicyResponse)],
[policiesQuery, jest.fn().mockResolvedValue(mockEscalationPoliciesResponse)],
]);
wrapper = extendedWrapper(
mount(SidebarEscalationPolicy, {
apolloProvider: mockApollo,
propsData,
provide,
}),
);
await waitForPromises();
};
beforeEach(() => {
propsData = {
projectPath: 'gitlab-test/test',
iid: '1',
escalationsPossible: true,
};
provide = {
canUpdate: true,
isClassicSidebar: true,
};
escalationPolicyResponse = mockCurrentEscalationPolicyResponse;
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
mockApollo = null;
propsData = null;
provide = null;
escalationPolicyResponse = null;
});
const findNarrowSidebarPolicy = () => wrapper.findByTestId('sidebar-collapsed-attribute-title');
const findExpandedSidebarPolicy = () => wrapper.findByTestId('select-escalation-policy');
const findMobileIcon = () => wrapper.findByTestId('mobile-icon');
const findPolicyLink = () => wrapper.find('[href="/gitlab-test/test/-/escalation_policies"]');
const findHelpLink = () =>
wrapper.find('[href="/help/operations/incident_management/escalation_policies.md"]');
const verifyMobileIcon = () => {
it('renders the mobile icon', async () => {
await createComponent();
expect(findMobileIcon().exists()).toBe(true);
expect(findMobileIcon().classes('hide-collapsed')).toBe(false);
});
};
const verifyNarrowSidebarPolicyText = (text) => {
it(`renders '${text}' to describe the policy`, async () => {
await createComponent();
expect(findNarrowSidebarPolicy().text()).toBe(text);
expect(findNarrowSidebarPolicy().classes('hide-collapsed')).toBe(false);
});
};
const verifyExpandedSidebarPolicyText = (text) => {
it(`renders '${text}' to describe the policy`, async () => {
await createComponent();
expect(findExpandedSidebarPolicy().text()).toBe(text);
expect(findExpandedSidebarPolicy().classes('hide-collapsed')).toBe(true);
expect(findExpandedSidebarPolicy().isVisible()).toBe(true);
});
};
const verifyEmptyPolicyContent = () => {
describe('when the policy is not set', () => {
beforeEach(() => {
escalationPolicyResponse = mockNullEscalationPolicyResponse;
});
verifyMobileIcon();
verifyExpandedSidebarPolicyText('None');
verifyNarrowSidebarPolicyText('None');
});
};
const verifyPopulatedPolicyContent = () => {
describe('when the policy is initially set', () => {
verifyMobileIcon();
verifyExpandedSidebarPolicyText(mockEscalationPolicy1.title);
verifyNarrowSidebarPolicyText('Paged');
it('links to the escalation policies for the project', async () => {
await createComponent();
expect(findPolicyLink().exists()).toBe(true);
});
});
};
describe('when user has permissions to update policy', () => {
verifyEmptyPolicyContent();
verifyPopulatedPolicyContent();
it('renders list of escalation policies in the dropdown', async () => {
await createComponent();
await clickEdit(wrapper);
const dropdownItems = wrapper.findAllByTestId('escalation-policy-items');
expect(dropdownItems.at(0).text()).toBe(mockEscalationPolicy1.title);
expect(dropdownItems.at(1).text()).toBe(mockEscalationPolicy2.title);
});
describe('when a policy is selected', () => {
beforeEach(async () => {
await createComponent();
await clickEdit(wrapper);
await wrapper.findByTestId('escalation-policy-items').trigger('click');
await waitForPromises();
});
verifyPopulatedPolicyContent();
});
});
describe('when user does not have permissions to update policy', () => {
beforeEach(() => {
provide.canUpdate = false;
});
verifyEmptyPolicyContent();
verifyPopulatedPolicyContent();
});
describe('when escalation policies are not available for the project', () => {
beforeEach(() => {
propsData.escalationsPossible = false;
});
verifyEmptyPolicyContent();
it('can be opened and closed', async () => {
await createComponent();
await wrapper.find('[data-testid="help-button"]').trigger('click');
expect(findHelpLink().exists()).toBe(true);
await wrapper.find('[data-testid="close-help-button"]').trigger('click');
expect(findHelpLink().exists()).toBe(false);
});
});
});
...@@ -49,7 +49,9 @@ RSpec.describe IncidentManagement::EscalationPolicy do ...@@ -49,7 +49,9 @@ RSpec.describe IncidentManagement::EscalationPolicy do
describe '.search_by_name' do describe '.search_by_name' do
subject { described_class.search_by_name('other') } subject { described_class.search_by_name('other') }
it { is_expected.to contain_exactly(other_policy) } it 'does a case-insenstive search' do
expect(subject).to contain_exactly(other_policy)
end
end end
end end
end end
...@@ -14377,6 +14377,9 @@ msgstr "" ...@@ -14377,6 +14377,9 @@ msgstr ""
msgid "Escalation policies must have at least one rule" msgid "Escalation policies must have at least one rule"
msgstr "" msgstr ""
msgid "Escalation policy"
msgstr ""
msgid "Escalation policy:" msgid "Escalation policy:"
msgstr "" msgstr ""
...@@ -19112,6 +19115,12 @@ msgstr "" ...@@ -19112,6 +19115,12 @@ msgstr ""
msgid "IncidentManagement|Open" msgid "IncidentManagement|Open"
msgstr "" msgstr ""
msgid "IncidentManagement|Page your team with escalation policies"
msgstr ""
msgid "IncidentManagement|Paged"
msgstr ""
msgid "IncidentManagement|Published" msgid "IncidentManagement|Published"
msgstr "" msgstr ""
...@@ -19139,6 +19148,9 @@ msgstr "" ...@@ -19139,6 +19148,9 @@ msgstr ""
msgid "IncidentManagement|Unpublished" msgid "IncidentManagement|Unpublished"
msgstr "" msgstr ""
msgid "IncidentManagement|Use escalation policies to automatically page your team when incidents are created."
msgstr ""
msgid "IncidentSettings|Activate \"time to SLA\" countdown timer" msgid "IncidentSettings|Activate \"time to SLA\" countdown timer"
msgstr "" msgstr ""
......
...@@ -40,7 +40,7 @@ module QA ...@@ -40,7 +40,7 @@ module QA
end end
base.view 'app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue' do base.view 'app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue' do
element :milestone_link, 'data-qa-selector="`${issuableAttribute}_link`"' # rubocop:disable QA/ElementWithPattern element :milestone_link, 'data-qa-selector="`${formatIssuableAttribute.snake}_link`"' # rubocop:disable QA/ElementWithPattern
end end
base.view 'app/assets/javascripts/sidebar/components/sidebar_editable_item.vue' do base.view 'app/assets/javascripts/sidebar/components/sidebar_editable_item.vue' do
......
...@@ -61,6 +61,15 @@ FactoryBot.define do ...@@ -61,6 +61,15 @@ FactoryBot.define do
factory :incident do factory :incident do
issue_type { :incident } issue_type { :incident }
association :work_item_type, :default, :incident association :work_item_type, :default, :incident
# An escalation status record is created for all incidents
# in app code. This is a trait to avoid creating escalation
# status records in specs which do not need them.
trait :with_escalation_status do
after(:create) do |incident|
create(:incident_management_issuable_escalation_status, issue: incident)
end
end
end end
end end
end end
...@@ -66,7 +66,7 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type| ...@@ -66,7 +66,7 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type|
it 'shows the help state when icon is clicked' do it 'shows the help state when icon is clicked' do
page.within '.time-tracking-component-wrap' do page.within '.time-tracking-component-wrap' do
find('.help-button').click find('[data-testid="helpButton"]').click
expect(page).to have_content 'Track time with quick actions' expect(page).to have_content 'Track time with quick actions'
expect(page).to have_content 'Learn more' expect(page).to have_content 'Learn more'
end end
...@@ -92,8 +92,8 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type| ...@@ -92,8 +92,8 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type|
it 'hides the help state when close icon is clicked' do it 'hides the help state when close icon is clicked' do
page.within '.time-tracking-component-wrap' do page.within '.time-tracking-component-wrap' do
find('.help-button').click find('[data-testid="helpButton"]').click
find('.close-help-button').click find('[data-testid="closeHelpButton"]').click
expect(page).not_to have_content 'Track time with quick actions' expect(page).not_to have_content 'Track time with quick actions'
expect(page).not_to have_content 'Learn more' expect(page).not_to have_content 'Learn more'
...@@ -102,7 +102,7 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type| ...@@ -102,7 +102,7 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type|
it 'displays the correct help url' do it 'displays the correct help url' do
page.within '.time-tracking-component-wrap' do page.within '.time-tracking-component-wrap' do
find('.help-button').click find('[data-testid="helpButton"]').click
expect(find_link('Learn more')[:href]).to have_content('/help/user/project/time_tracking.md') expect(find_link('Learn more')[:href]).to have_content('/help/user/project/time_tracking.md')
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment