Commit 51fd9ff0 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'sy-add-escalation-dropdown-menu' into 'master'

Add escalation policy dropdown to incidents sidebar

See merge request gitlab-org/gitlab!79803
parents 7040ba06 24a3a72d
......@@ -10,6 +10,7 @@ import {
GlIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import { kebabCase, snakeCase } from 'lodash';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issues/constants';
......@@ -221,6 +222,12 @@ export default {
// MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311
return this.issuableAttribute === IssuableType.Epic;
},
formatIssuableAttribute() {
return {
kebab: kebabCase(this.issuableAttribute),
snake: snakeCase(this.issuableAttribute),
};
},
},
methods: {
updateAttribute(attributeId) {
......@@ -300,26 +307,28 @@ export default {
<sidebar-editable-item
ref="editable"
:title="attributeTypeTitle"
:data-testid="`${issuableAttribute}-edit`"
:data-testid="`${formatIssuableAttribute.kebab}-edit`"
:tracking="tracking"
:loading="updating || loading"
@open="handleOpen"
@close="handleClose"
>
<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 :size="16" :aria-label="attributeTypeTitle" :name="attributeTypeIcon" />
<gl-icon :aria-label="attributeTypeTitle" :name="attributeTypeIcon" />
<span class="collapse-truncated-title">
{{ attributeTitle }}
</span>
</div>
</slot>
<div
:data-testid="`select-${issuableAttribute}`"
:data-testid="`select-${formatIssuableAttribute.kebab}`"
:class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'"
>
<span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span>
......@@ -337,7 +346,7 @@ export default {
v-gl-tooltip="tooltipText"
class="gl-text-gray-900! gl-font-weight-bold"
:href="attributeUrl"
:data-qa-selector="`${issuableAttribute}_link`"
:data-qa-selector="`${formatIssuableAttribute.snake}_link`"
>
{{ attributeTitle }}
<span v-if="isAttributeOverdue(currentAttribute)">{{ $options.i18n.expired }}</span>
......@@ -359,7 +368,7 @@ export default {
>
<gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item
:data-testid="`no-${issuableAttribute}-item`"
:data-testid="`no-${formatIssuableAttribute.kebab}-item`"
:is-check-item="true"
:is-checked="isAttributeChecked($options.noAttributeId)"
@click="updateAttribute($options.noAttributeId)"
......@@ -389,7 +398,7 @@ export default {
:key="attrItem.id"
:is-check-item="true"
:is-checked="isAttributeChecked(attrItem.id)"
:data-testid="`${issuableAttribute}-items`"
:data-testid="`${formatIssuableAttribute.kebab}-items`"
@click="updateAttribute(attrItem.id)"
>
{{ attrItem.title }}
......
......@@ -38,7 +38,10 @@ export default {
</script>
<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">
<h4>{{ __('Track time with quick actions') }}</h4>
<p>{{ __('Quick actions can be used in description and comment boxes.') }}</p>
......
<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 { s__, __ } from '~/locale';
import { timeTrackingQueries } from '~/sidebar/constants';
......@@ -21,6 +21,7 @@ export default {
GlIcon,
GlLink,
GlModal,
GlButton,
GlLoadingIcon,
TimeTrackingCollapsedState,
TimeTrackingSpentOnlyPane,
......@@ -187,7 +188,11 @@ export default {
</script>
<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
v-if="showCollapsed"
:show-comparison-state="showComparisonState"
......@@ -198,25 +203,21 @@ export default {
:time-spent-human-readable="humanTotalTimeSpent"
:time-estimate-human-readable="humanTimeEstimate"
/>
<div class="hide-collapsed gl-line-height-20 gl-text-gray-900">
{{ __('Time tracking') }}
<gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" inline />
<div
v-if="!showHelpState"
data-testid="helpButton"
class="help-button float-right"
@click="toggleHelpState(true)"
class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center"
>
<gl-icon name="question-o" />
</div>
<div
v-else
data-testid="closeHelpButton"
class="close-help-button float-right"
@click="toggleHelpState(false)"
{{ __('Time tracking') }}
<gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" inline />
<gl-button
:data-testid="showHelpState ? 'closeHelpButton' : 'helpButton'"
category="tertiary"
size="small"
variant="link"
class="gl-ml-auto"
@click="toggleHelpState(!showHelpState)"
>
<gl-icon name="close" />
</div>
<gl-icon :name="showHelpState ? 'close' : 'question-o'" class="gl-text-gray-900!" />
</gl-button>
</div>
<div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed">
<div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane">
......
......@@ -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 {
.sidebar-collapsed-icon {
> .stopwatch-svg {
......@@ -759,11 +779,6 @@
}
}
.help-button,
.close-help-button {
cursor: pointer;
}
.compare-meter {
&.over_estimate {
.time-remaining,
......@@ -776,31 +791,6 @@
.compare-display-container {
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 {
......
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 {
IssuableAttributeType,
IssuableAttributeState,
issuableAttributesQueries,
SIDEBAR_ESCALATION_POLICY_TITLE,
} from '../constants';
const widgetTitleText = {
[IssuableAttributeType.Milestone]: __('Milestone'),
[IssuableAttributeType.Iteration]: __('Iteration'),
[IssuableAttributeType.Epic]: __('Epic'),
[IssuableAttributeType.EscalationPolicy]: SIDEBAR_ESCALATION_POLICY_TITLE,
none: __('None'),
expired: __('(expired)'),
};
......@@ -33,6 +35,7 @@ export default {
IssuableAttributeType.Milestone,
IssuableAttributeType.Iteration,
IssuableAttributeType.Epic,
IssuableAttributeType.EscalationPolicy,
].includes(value);
},
},
......
......@@ -20,6 +20,9 @@ import projectIssueEpicQuery from './queries/project_issue_epic.query.graphql';
import projectIssueIterationMutation from './queries/project_issue_iteration.mutation.graphql';
import projectIssueIterationQuery from './queries/project_issue_iteration.query.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 };
......@@ -60,6 +63,8 @@ export const healthStatusForRestApi = {
[healthStatus.AT_RISK]: 'at_risk',
};
export const SIDEBAR_ESCALATION_POLICY_TITLE = __('Escalation policy');
export const MAX_DISPLAY_WEIGHT = 99999;
export const I18N_DROPDOWN = {
......@@ -109,10 +114,24 @@ const epicsQueries = {
},
};
const issuableEscalationPolicyQueries = {
[IssuableType.Issue]: {
query: issueEscalationPolicyQuery,
mutation: issueEscalationPolicyMutation,
},
};
const escalationPoliciesQueries = {
[IssuableType.Issue]: {
query: projectEscalationPoliciesQuery,
},
};
export const IssuableAttributeType = {
...IssuableAttributeTypeFoss,
Iteration: 'iteration',
Epic: 'epic',
EscalationPolicy: 'escalation policy', // eslint-disable-line @gitlab/require-i18n-strings
};
export const IssuableAttributeState = {
......@@ -131,6 +150,10 @@ export const issuableAttributesQueries = {
current: issuableEpicQueries,
list: epicsQueries,
},
[IssuableAttributeType.EscalationPolicy]: {
current: issuableEscalationPolicyQueries,
list: escalationPoliciesQueries,
},
};
export const ancestorsQueries = {
......
......@@ -9,6 +9,7 @@ import IterationSidebarDropdownWidget from './components/iteration_sidebar_dropd
import SidebarDropdownWidget from './components/sidebar_dropdown_widget.vue';
import SidebarStatus from './components/status/sidebar_status.vue';
import SidebarWeightWidget from './components/weight/sidebar_weight_widget.vue';
import SidebarEscalationPolicy from './components/incidents/sidebar_escalation_policy.vue';
import { IssuableAttributeType } from './constants';
Vue.use(VueApollo);
......@@ -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 function mountSidebar(mediator, store) {
......@@ -156,5 +187,6 @@ export function mountSidebar(mediator, store) {
mountStatusComponent(store);
mountEpicsSelect();
mountIterationSelect();
mountEscalationPoliciesSelect();
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]
.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
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
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
describe '.search_by_name' do
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
......@@ -14377,6 +14377,9 @@ msgstr ""
msgid "Escalation policies must have at least one rule"
msgstr ""
msgid "Escalation policy"
msgstr ""
msgid "Escalation policy:"
msgstr ""
......@@ -19112,6 +19115,12 @@ msgstr ""
msgid "IncidentManagement|Open"
msgstr ""
msgid "IncidentManagement|Page your team with escalation policies"
msgstr ""
msgid "IncidentManagement|Paged"
msgstr ""
msgid "IncidentManagement|Published"
msgstr ""
......@@ -19139,6 +19148,9 @@ msgstr ""
msgid "IncidentManagement|Unpublished"
msgstr ""
msgid "IncidentManagement|Use escalation policies to automatically page your team when incidents are created."
msgstr ""
msgid "IncidentSettings|Activate \"time to SLA\" countdown timer"
msgstr ""
......
......@@ -40,7 +40,7 @@ module QA
end
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
base.view 'app/assets/javascripts/sidebar/components/sidebar_editable_item.vue' do
......
......@@ -61,6 +61,15 @@ FactoryBot.define do
factory :incident do
issue_type { :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
......@@ -66,7 +66,7 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type|
it 'shows the help state when icon is clicked' 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 'Learn more'
end
......@@ -92,8 +92,8 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type|
it 'hides the help state when close icon is clicked' do
page.within '.time-tracking-component-wrap' do
find('.help-button').click
find('.close-help-button').click
find('[data-testid="helpButton"]').click
find('[data-testid="closeHelpButton"]').click
expect(page).not_to have_content 'Track time with quick actions'
expect(page).not_to have_content 'Learn more'
......@@ -102,7 +102,7 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type|
it 'displays the correct help url' 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')
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