Commit c27ac3ae authored by Miguel Rincon's avatar Miguel Rincon

Merge branch 'tr-incident-escalation-status' into 'master'

Add escalation status to incidents

See merge request gitlab-org/gitlab!66165
parents 8ea4b646 23b56bff
import { s__ } from '~/locale';
export const STATUS_TRIGGERED = 'TRIGGERED';
export const STATUS_ACKNOWLEDGED = 'ACKNOWLEDGED';
export const STATUS_RESOLVED = 'RESOLVED';
export const STATUS_TRIGGERED_LABEL = s__('IncidentManagement|Triggered');
export const STATUS_ACKNOWLEDGED_LABEL = s__('IncidentManagement|Acknowledged');
export const STATUS_RESOLVED_LABEL = s__('IncidentManagement|Resolved');
export const STATUS_LABELS = {
[STATUS_TRIGGERED]: STATUS_TRIGGERED_LABEL,
[STATUS_ACKNOWLEDGED]: STATUS_ACKNOWLEDGED_LABEL,
[STATUS_RESOLVED]: STATUS_RESOLVED_LABEL,
};
export const i18n = {
fetchError: s__(
'IncidentManagement|An error occurred while fetching the incident status. Please reload the page.',
),
title: s__('IncidentManagement|Status'),
updateError: s__(
'IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again.',
),
};
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { i18n, STATUS_ACKNOWLEDGED, STATUS_TRIGGERED, STATUS_RESOLVED } from './constants';
import { getStatusLabel } from './utils';
const STATUS_LIST = [STATUS_TRIGGERED, STATUS_ACKNOWLEDGED, STATUS_RESOLVED];
export default {
i18n,
STATUS_LIST,
components: {
GlDropdown,
GlDropdownItem,
},
props: {
value: {
type: String,
required: false,
default: null,
validator(value) {
return [...STATUS_LIST, null].includes(value);
},
},
},
computed: {
currentStatusLabel() {
return this.getStatusLabel(this.value);
},
},
methods: {
show() {
this.$refs.dropdown.show();
},
hide() {
this.$refs.dropdown.hide();
},
getStatusLabel,
},
};
</script>
<template>
<gl-dropdown
ref="dropdown"
block
:text="currentStatusLabel"
toggle-class="dropdown-menu-toggle gl-mb-2"
>
<slot name="header"> </slot>
<gl-dropdown-item
v-for="status in $options.STATUS_LIST"
:key="status"
data-testid="status-dropdown-item"
:is-check-item="true"
:is-checked="status === value"
@click="$emit('input', status)"
>
{{ getStatusLabel(status) }}
</gl-dropdown-item>
</gl-dropdown>
</template>
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants';
import { createAlert } from '~/flash';
import { logError } from '~/lib/logger';
import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue';
import SidebarEditableItem from '../sidebar_editable_item.vue';
import { i18n } from './constants';
import { getStatusLabel } from './utils';
export default {
i18n,
components: {
EscalationStatus,
SidebarEditableItem,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
iid: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
issuableType: {
required: true,
type: String,
},
},
data() {
return {
status: null,
isUpdating: false,
};
},
apollo: {
status: {
query() {
return escalationStatusQuery;
},
variables() {
return {
fullPath: this.projectPath,
iid: this.iid,
};
},
update(data) {
return data.workspace?.issuable?.escalationStatus;
},
error(error) {
const message = this.$options.i18n.fetchError;
createAlert({ message });
logError(message, error);
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.status.loading;
},
currentStatusLabel() {
return getStatusLabel(this.status);
},
tooltipText() {
return `${this.$options.i18n.title}: ${this.currentStatusLabel}`;
},
},
methods: {
updateStatus(status) {
this.isUpdating = true;
this.closeSidebar();
return this.$apollo
.mutate({
mutation: escalationStatusMutation,
variables: {
status,
iid: this.iid,
projectPath: this.projectPath,
},
})
.then(({ data: { issueSetEscalationStatus } }) => {
this.status = issueSetEscalationStatus.issue.escalationStatus;
})
.catch((error) => {
const message = this.$options.i18n.updateError;
createAlert({ message });
logError(message, error);
})
.finally(() => {
this.isUpdating = false;
});
},
closeSidebar() {
this.close();
this.$refs.editable.collapse();
},
open() {
this.$refs.escalationStatus.show();
},
close() {
this.$refs.escalationStatus.hide();
},
},
};
</script>
<template>
<sidebar-editable-item
ref="editable"
:title="$options.i18n.title"
:initial-loading="isLoading"
:loading="isUpdating"
@open="open"
@close="close"
>
<template #default>
<escalation-status ref="escalationStatus" :value="status" @input="updateStatus" />
</template>
<template #collapsed>
<div
v-gl-tooltip.viewport.left="tooltipText"
class="sidebar-collapsed-icon"
data-testid="status-icon"
>
<gl-icon name="status" :size="16" />
</div>
<span class="hide-collapsed text-secondary">{{ currentStatusLabel }}</span>
</template>
</sidebar-editable-item>
</template>
import { s__ } from '~/locale';
import { STATUS_LABELS } from './constants';
export const getStatusLabel = (status) => STATUS_LABELS[status] ?? s__('IncidentManagement|None');
...@@ -49,6 +49,8 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries ...@@ -49,6 +49,8 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries
import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql';
import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
import getEscalationStatusQuery from '~/sidebar/queries/escalation_status.query.graphql';
import updateEscalationStatusMutation from '~/sidebar/queries/update_escalation_status.mutation.graphql';
import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql'; import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql';
import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql'; import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql';
import projectMilestonesQuery from './queries/project_milestones.query.graphql'; import projectMilestonesQuery from './queries/project_milestones.query.graphql';
...@@ -305,3 +307,6 @@ export function dropdowni18nText(issuableAttribute, issuableType) { ...@@ -305,3 +307,6 @@ export function dropdowni18nText(issuableAttribute, issuableType) {
), ),
}; };
} }
export const escalationStatusQuery = getEscalationStatusQuery;
export const escalationStatusMutation = updateEscalationStatusMutation;
...@@ -31,6 +31,7 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests ...@@ -31,6 +31,7 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue';
...@@ -568,6 +569,36 @@ function mountSeverityComponent() { ...@@ -568,6 +569,36 @@ function mountSeverityComponent() {
}); });
} }
function mountEscalationStatusComponent() {
const statusContainerEl = document.querySelector('#js-escalation-status');
if (!statusContainerEl) {
return false;
}
const { issuableType } = getSidebarOptions();
const { canUpdate, issueIid, projectPath } = statusContainerEl.dataset;
return new Vue({
el: statusContainerEl,
apolloProvider,
components: {
SidebarEscalationStatus,
},
provide: {
canUpdate: parseBoolean(canUpdate),
},
render: (createElement) =>
createElement('sidebar-escalation-status', {
props: {
iid: issueIid,
issuableType,
projectPath,
},
}),
});
}
function mountCopyEmailComponent() { function mountCopyEmailComponent() {
const el = document.getElementById('issuable-copy-email'); const el = document.getElementById('issuable-copy-email');
...@@ -619,6 +650,8 @@ export function mountSidebar(mediator, store) { ...@@ -619,6 +650,8 @@ export function mountSidebar(mediator, store) {
mountSeverityComponent(); mountSeverityComponent();
mountEscalationStatusComponent();
if (window.gon?.features?.mrAttentionRequests) { if (window.gon?.features?.mrAttentionRequests) {
eventHub.$on('removeCurrentUserAttentionRequested', () => { eventHub.$on('removeCurrentUserAttentionRequested', () => {
mediator.removeCurrentUserAttentionRequested(); mediator.removeCurrentUserAttentionRequested();
......
query escalationStatusQuery($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
id
issuable: issue(iid: $iid) {
id
escalationStatus
}
}
}
mutation updateEscalationStatus($projectPath: ID!, $status: IssueEscalationStatus!, $iid: String!) {
issueSetEscalationStatus(input: { projectPath: $projectPath, status: $status, iid: $iid }) {
errors
clientMutationId
issue {
id
escalationStatus
}
}
}
...@@ -48,3 +48,5 @@ class Projects::IncidentsController < Projects::ApplicationController ...@@ -48,3 +48,5 @@ class Projects::IncidentsController < Projects::ApplicationController
IssueSerializer.new(current_user: current_user, project: incident.project) IssueSerializer.new(current_user: current_user, project: incident.project)
end end
end end
Projects::IncidentsController.prepend_mod
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
- if issuable_sidebar[:supports_escalation] - if issuable_sidebar[:supports_escalation]
.block.escalation-status{ data: { testid: 'escalation_status_container' } } .block.escalation-status{ data: { testid: 'escalation_status_container' } }
#js-escalation-status{ data: { can_edit: issuable_sidebar.dig(:current_user, :can_update_escalation_status).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } } #js-escalation-status{ data: { can_update: issuable_sidebar.dig(:current_user, :can_update_escalation_status).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
= render_if_exists 'shared/issuable/sidebar_escalation_policy', issuable_sidebar: issuable_sidebar = render_if_exists 'shared/issuable/sidebar_escalation_policy', issuable_sidebar: issuable_sidebar
- if @project.group.present? - if @project.group.present?
......
...@@ -14,3 +14,10 @@ export const i18nPolicyText = { ...@@ -14,3 +14,10 @@ export const i18nPolicyText = {
title: SIDEBAR_ESCALATION_POLICY_TITLE, title: SIDEBAR_ESCALATION_POLICY_TITLE,
none, none,
}; };
export const i18nStatusText = {
dropdownHeader: s__('IncidentManagement|Assign paging status'),
dropdownInfo: s__(
'IncidentManagement|Setting the status to Acknowledged or Resolved stops paging when escalation policies are selected for the incident.',
),
};
<script>
import { GlDropdownDivider, GlDropdownSectionHeader, GlIcon, GlPopover } from '@gitlab/ui';
import EscalationStatus from '~/sidebar/components/incidents/escalation_status.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { i18nStatusText } from './constants';
export default {
i18n: i18nStatusText,
components: {
EscalationStatus,
GlDropdownDivider,
GlDropdownSectionHeader,
GlIcon,
GlPopover,
},
mixins: [glFeatureFlagMixin()],
computed: {
showHeader() {
return this.glFeatures.escalationPolicies;
},
},
methods: {
// Pass through to wrapped component
show() {
this.$refs.escalationStatus.show();
},
// Pass through to wrapped component
hide() {
this.$refs.escalationStatus.hide();
},
},
};
</script>
<template>
<escalation-status ref="escalationStatus" v-bind="$attrs" v-on="$listeners">
<template v-if="showHeader" #header>
<gl-dropdown-section-header class="gl-mt-n2">
<div class="gl-text-center">
{{ $options.i18n.dropdownHeader }}
<gl-icon id="escalation-status-help" class="gl-ml-2 gl-text-blue-600" name="question-o" />
<gl-popover
:content="$options.i18n.dropdownInfo"
:title="$options.i18n.dropdownHeader"
boundary="viewport"
placement="left"
target="escalation-status-help"
/>
</div>
</gl-dropdown-section-header>
<gl-dropdown-divider />
</template>
</escalation-status>
</template>
# frozen_string_literal: true
module EE
module Projects::IncidentsController
extend ActiveSupport::Concern
prepended do
before_action do
push_licensed_feature(:escalation_policies, project)
end
end
end
end
...@@ -15,6 +15,10 @@ module EE ...@@ -15,6 +15,10 @@ module EE
populate_vulnerability_id populate_vulnerability_id
end end
before_action only: :show do
push_licensed_feature(:escalation_policies, project)
end
before_action :redirect_if_test_case, only: [:show] before_action :redirect_if_test_case, only: [:show]
feature_category :team_planning, [:delete_description_version, :description_diff] feature_category :team_planning, [:delete_description_version, :description_diff]
......
...@@ -206,6 +206,22 @@ RSpec.describe 'Incident details', :js do ...@@ -206,6 +206,22 @@ RSpec.describe 'Incident details', :js do
end end
end end
context 'escalation status dropdown' do
let(:escalation_status_container) { page.find('[data-testid="escalation_status_container"]') }
let(:current_user) { developer }
before do
stub_licensed_features(oncall_schedules: true, escalation_policies: true)
end
it 'includes help info for escalations' do
visit_incident_with_expanded_sidebar
escalation_status_container.find('[data-testid="edit-button"]').click
expect(escalation_status_container).to have_selector('#escalation-status-help')
end
end
private private
def visit_incident_with_collapsed_sidebar def visit_incident_with_collapsed_sidebar
......
import { GlDropdownSectionHeader, GlPopover } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import EscalationStatus from 'ee/sidebar/components/incidents/escalation_status.vue';
import { STATUS_TRIGGERED } from '~/sidebar/components/incidents/constants';
describe('EscalationStatus', () => {
let wrapper;
const showSpy = jest.fn();
const hideSpy = jest.fn();
function createComponent(glFeatures = {}) {
wrapper = mountExtended(EscalationStatus, {
propsData: {
status: STATUS_TRIGGERED,
},
provide: {
glFeatures: {
escalationPolicies: true,
...glFeatures,
},
},
});
}
afterEach(() => {
wrapper.destroy();
});
const findDropdownHeaderComponent = () => wrapper.findComponent(GlDropdownSectionHeader);
const findPopover = () => wrapper.findComponent(GlPopover);
const findInnerStatusComponent = () => wrapper.findComponent({ ref: 'escalationStatus' });
describe('popover', () => {
it('renders a popover', () => {
createComponent();
expect(findPopover().props('title')).toBe('Assign paging status');
expect(findPopover().attributes('content')).toContain('Setting the status');
});
it('forwards `show` calls to the child', () => {
createComponent();
findInnerStatusComponent().vm.show = showSpy;
wrapper.vm.show();
expect(showSpy).toHaveBeenCalled();
});
it('forwards `hide` calls to the child', () => {
createComponent();
findInnerStatusComponent().vm.hide = hideSpy;
wrapper.vm.hide();
expect(hideSpy).toHaveBeenCalled();
});
});
describe('licensed features', () => {
it('when licensed, renders the dropdown header', () => {
createComponent();
expect(findDropdownHeaderComponent().exists()).toBe(true);
});
it('when unlicensed, does not render the dropdown header', () => {
createComponent({ escalationPolicies: false });
expect(findDropdownHeaderComponent().exists()).toBe(false);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::IncidentsController do
let_it_be(:issue) { create(:incident) }
let_it_be(:project) { issue.project }
let_it_be(:user) { issue.author }
before do
login_as(user)
end
describe 'GET #show' do
it 'exposes the escalation_policies licensed feature setting' do
stub_licensed_features(escalation_policies: true)
get project_issue_path(project, issue)
expect(response.body).to have_pushed_frontend_feature_flags(escalationPolicies: true)
end
end
end
...@@ -50,6 +50,14 @@ RSpec.describe Projects::IssuesController do ...@@ -50,6 +50,14 @@ RSpec.describe Projects::IssuesController do
expect(response).to redirect_to(project_quality_test_case_path(project, test_case)) expect(response).to redirect_to(project_quality_test_case_path(project, test_case))
end end
end end
it 'exposes the escalation_policies licensed feature setting' do
stub_licensed_features(escalation_policies: true)
get_show
expect(response.body).to have_pushed_frontend_feature_flags(escalationPolicies: true)
end
end end
describe 'GET #index' do describe 'GET #index' do
......
...@@ -19344,6 +19344,9 @@ msgstr "" ...@@ -19344,6 +19344,9 @@ msgstr ""
msgid "IncidentManagement|Achieved SLA" msgid "IncidentManagement|Achieved SLA"
msgstr "" msgstr ""
msgid "IncidentManagement|Acknowledged"
msgstr ""
msgid "IncidentManagement|All" msgid "IncidentManagement|All"
msgstr "" msgstr ""
...@@ -19353,6 +19356,15 @@ msgstr "" ...@@ -19353,6 +19356,15 @@ msgstr ""
msgid "IncidentManagement|All alerts promoted to incidents are automatically displayed within the list. You can also create a new incident using the button below." msgid "IncidentManagement|All alerts promoted to incidents are automatically displayed within the list. You can also create a new incident using the button below."
msgstr "" msgstr ""
msgid "IncidentManagement|An error occurred while fetching the incident status. Please reload the page."
msgstr ""
msgid "IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again."
msgstr ""
msgid "IncidentManagement|Assign paging status"
msgstr ""
msgid "IncidentManagement|Assignees" msgid "IncidentManagement|Assignees"
msgstr "" msgstr ""
...@@ -19410,6 +19422,12 @@ msgstr "" ...@@ -19410,6 +19422,12 @@ msgstr ""
msgid "IncidentManagement|Published to status page" msgid "IncidentManagement|Published to status page"
msgstr "" msgstr ""
msgid "IncidentManagement|Resolved"
msgstr ""
msgid "IncidentManagement|Setting the status to Acknowledged or Resolved stops paging when escalation policies are selected for the incident."
msgstr ""
msgid "IncidentManagement|Severity" msgid "IncidentManagement|Severity"
msgstr "" msgstr ""
...@@ -19425,6 +19443,9 @@ msgstr "" ...@@ -19425,6 +19443,9 @@ msgstr ""
msgid "IncidentManagement|Time to SLA" msgid "IncidentManagement|Time to SLA"
msgstr "" msgstr ""
msgid "IncidentManagement|Triggered"
msgstr ""
msgid "IncidentManagement|Unassigned" msgid "IncidentManagement|Unassigned"
msgstr "" msgstr ""
......
...@@ -6,6 +6,7 @@ RSpec.describe 'Incident details', :js do ...@@ -6,6 +6,7 @@ RSpec.describe 'Incident details', :js do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user) } let_it_be(:developer) { create(:user) }
let_it_be(:incident) { create(:incident, project: project, author: developer, description: 'description') } let_it_be(:incident) { create(:incident, project: project, author: developer, description: 'description') }
let_it_be(:escalation_status) { create(:incident_management_issuable_escalation_status, issue: incident) }
before_all do before_all do
project.add_developer(developer) project.add_developer(developer)
...@@ -46,6 +47,42 @@ RSpec.describe 'Incident details', :js do ...@@ -46,6 +47,42 @@ RSpec.describe 'Incident details', :js do
expect(page).to have_selector('.right-sidebar[data-issuable-type="issue"]') expect(page).to have_selector('.right-sidebar[data-issuable-type="issue"]')
expect(sidebar).to have_selector('.incident-severity') expect(sidebar).to have_selector('.incident-severity')
expect(sidebar).to have_selector('.milestone') expect(sidebar).to have_selector('.milestone')
expect(sidebar).to have_selector('[data-testid="escalation_status_container"]')
end
end
context 'escalation status' do
let(:sidebar) { page.find('.right-sidebar') }
let(:widget) { sidebar.find('[data-testid="escalation_status_container"]') }
let(:expected_dropdown_options) { escalation_status.class::STATUSES.keys.take(3).map { |key| key.to_s.titleize } }
it 'has an interactable escalation status widget' do
expect(current_status).to have_text(escalation_status.status_name.to_s.titleize)
# list the available statuses
widget.find('[data-testid="edit-button"]').click
expect(dropdown_options.map(&:text)).to eq(expected_dropdown_options)
expect(widget).not_to have_selector('#escalation-status-help')
# update the status
select_resolved(dropdown_options)
expect(current_status).to have_text('Resolved')
expect(escalation_status.reload).to be_resolved
end
private
def dropdown_options
widget.all('[data-testid="status-dropdown-item"]', count: 3)
end
def select_resolved(options)
options.last.click
wait_for_requests
end
def current_status
widget.find('[data-testid="collapsed-content"]')
end end
end end
end end
......
...@@ -236,6 +236,12 @@ RSpec.describe 'Issue Sidebar' do ...@@ -236,6 +236,12 @@ RSpec.describe 'Issue Sidebar' do
it_behaves_like 'labels sidebar widget' it_behaves_like 'labels sidebar widget'
end end
context 'escalation status', :js do
it 'is not available for default issue type' do
expect(page).not_to have_selector('.block.escalation-status')
end
end
context 'interacting with collapsed sidebar', :js do context 'interacting with collapsed sidebar', :js do
collapsed_sidebar_selector = 'aside.right-sidebar.right-sidebar-collapsed' collapsed_sidebar_selector = 'aside.right-sidebar.right-sidebar-collapsed'
expanded_sidebar_selector = 'aside.right-sidebar.right-sidebar-expanded' expanded_sidebar_selector = 'aside.right-sidebar.right-sidebar-expanded'
......
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import EscalationStatus from '~/sidebar/components/incidents/escalation_status.vue';
import {
STATUS_LABELS,
STATUS_TRIGGERED,
STATUS_ACKNOWLEDGED,
} from '~/sidebar/components/incidents/constants';
describe('EscalationStatus', () => {
let wrapper;
function createComponent(props) {
wrapper = mountExtended(EscalationStatus, {
propsData: {
value: STATUS_TRIGGERED,
...props,
},
});
}
afterEach(() => {
wrapper.destroy();
});
const findDropdownComponent = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
describe('status', () => {
it('shows the current status', () => {
createComponent({ value: STATUS_ACKNOWLEDGED });
expect(findDropdownComponent().props('text')).toBe(STATUS_LABELS[STATUS_ACKNOWLEDGED]);
});
it('shows the None option when status is null', () => {
createComponent({ value: null });
expect(findDropdownComponent().props('text')).toBe('None');
});
});
describe('events', () => {
it('selects an item', async () => {
createComponent();
await findDropdownItems().at(1).vm.$emit('click');
expect(wrapper.emitted().input[0][0]).toBe(STATUS_ACKNOWLEDGED);
});
});
});
import { STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants';
import { getStatusLabel } from '~/sidebar/components/incidents/utils';
describe('EscalationUtils', () => {
describe('getStatusLabel', () => {
it('returns a label when provided with a valid status', () => {
const label = getStatusLabel(STATUS_ACKNOWLEDGED);
expect(label).toEqual('Acknowledged');
});
it("returns 'None' when status is null", () => {
const label = getStatusLabel(null);
expect(label).toEqual('None');
});
});
});
import { STATUS_TRIGGERED, STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants';
export const fetchData = {
workspace: {
__typename: 'Project',
id: 'gid://gitlab/Project/2',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/4',
escalationStatus: STATUS_TRIGGERED,
},
},
};
export const mutationData = {
issueSetEscalationStatus: {
__typename: 'IssueSetEscalationStatusPayload',
errors: [],
clientMutationId: null,
issue: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/4',
escalationStatus: STATUS_ACKNOWLEDGED,
},
},
};
export const fetchError = {
workspace: {
__typename: 'Project',
},
};
export const mutationError = {
issueSetEscalationStatus: {
__typename: 'IssueSetEscalationStatusPayload',
errors: ['hello'],
},
};
import { createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import SidebarEscalationStatus from '~/sidebar/components/incidents/sidebar_escalation_status.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants';
import waitForPromises from 'helpers/wait_for_promises';
import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue';
import { STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants';
import { createAlert } from '~/flash';
import { logError } from '~/lib/logger';
import { fetchData, fetchError, mutationData, mutationError } from './mock_data';
jest.mock('~/lib/logger');
jest.mock('~/flash');
const localVue = createLocalVue();
describe('SidebarEscalationStatus', () => {
let wrapper;
const queryResolverMock = jest.fn();
const mutationResolverMock = jest.fn();
function createMockApolloProvider({ hasFetchError = false, hasMutationError = false } = {}) {
localVue.use(VueApollo);
queryResolverMock.mockResolvedValue({ data: hasFetchError ? fetchError : fetchData });
mutationResolverMock.mockResolvedValue({
data: hasMutationError ? mutationError : mutationData,
});
const requestHandlers = [
[escalationStatusQuery, queryResolverMock],
[escalationStatusMutation, mutationResolverMock],
];
return createMockApollo(requestHandlers);
}
function createComponent({ mockApollo } = {}) {
let config;
if (mockApollo) {
config = { apolloProvider: mockApollo };
} else {
config = { mocks: { $apollo: { queries: { status: { loading: false } } } } };
}
wrapper = mountExtended(SidebarEscalationStatus, {
propsData: {
iid: '1',
projectPath: 'gitlab-org/gitlab',
issuableType: 'issue',
},
provide: {
canUpdate: true,
},
directives: {
GlTooltip: createMockDirective(),
},
localVue,
...config,
});
}
afterEach(() => {
wrapper.destroy();
});
const findSidebarComponent = () => wrapper.findComponent(SidebarEditableItem);
const findStatusComponent = () => wrapper.findComponent(EscalationStatus);
const findEditButton = () => wrapper.findByTestId('edit-button');
const findIcon = () => wrapper.findByTestId('status-icon');
const clickEditButton = async () => {
findEditButton().vm.$emit('click');
await nextTick();
};
const selectAcknowledgedStatus = async () => {
findStatusComponent().vm.$emit('input', STATUS_ACKNOWLEDGED);
// wait for apollo requests
await waitForPromises();
};
describe('sidebar', () => {
it('renders the sidebar component', () => {
createComponent();
expect(findSidebarComponent().exists()).toBe(true);
});
describe('status icon', () => {
it('is visible', () => {
createComponent();
expect(findIcon().exists()).toBe(true);
expect(findIcon().isVisible()).toBe(true);
});
it('has correct tooltip', async () => {
const mockApollo = createMockApolloProvider();
createComponent({ mockApollo });
// wait for apollo requests
await waitForPromises();
const tooltip = getBinding(findIcon().element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe('Status: Triggered');
});
});
describe('status dropdown', () => {
beforeEach(async () => {
const mockApollo = createMockApolloProvider();
createComponent({ mockApollo });
// wait for apollo requests
await waitForPromises();
});
it('is closed by default', () => {
expect(findStatusComponent().exists()).toBe(true);
expect(findStatusComponent().isVisible()).toBe(false);
});
it('is shown after clicking the edit button', async () => {
await clickEditButton();
expect(findStatusComponent().isVisible()).toBe(true);
});
it('is hidden after clicking the edit button, when open already', async () => {
await clickEditButton();
await clickEditButton();
expect(findStatusComponent().isVisible()).toBe(false);
});
});
describe('update Status event', () => {
beforeEach(async () => {
const mockApollo = createMockApolloProvider();
createComponent({ mockApollo });
// wait for apollo requests
await waitForPromises();
await clickEditButton();
await selectAcknowledgedStatus();
});
it('calls the mutation', async () => {
const mutationVariables = {
iid: '1',
projectPath: 'gitlab-org/gitlab',
status: STATUS_ACKNOWLEDGED,
};
expect(mutationResolverMock).toHaveBeenCalledWith(mutationVariables);
});
it('closes the dropdown', async () => {
expect(findStatusComponent().isVisible()).toBe(false);
});
it('updates the status', async () => {
expect(findStatusComponent().attributes('value')).toBe(STATUS_ACKNOWLEDGED);
});
});
describe('mutation errors', () => {
it('should error upon fetch', async () => {
const mockApollo = createMockApolloProvider({ hasFetchError: true });
createComponent({ mockApollo });
// wait for apollo requests
await waitForPromises();
expect(createAlert).toHaveBeenCalled();
expect(logError).toHaveBeenCalled();
});
it('should error upon mutation', async () => {
const mockApollo = createMockApolloProvider({ hasMutationError: true });
createComponent({ mockApollo });
// wait for apollo requests
await waitForPromises();
await clickEditButton();
await selectAcknowledgedStatus();
expect(createAlert).toHaveBeenCalled();
expect(logError).toHaveBeenCalled();
});
});
});
});
...@@ -5,15 +5,19 @@ RSpec::Matchers.define :have_pushed_frontend_feature_flags do |expected| ...@@ -5,15 +5,19 @@ RSpec::Matchers.define :have_pushed_frontend_feature_flags do |expected|
"\"#{key}\":#{value}" "\"#{key}\":#{value}"
end end
def html(actual)
actual.try(:html) || actual
end
match do |actual| match do |actual|
expected.all? do |feature_flag_name, enabled| expected.all? do |feature_flag_name, enabled|
page.html.include?(to_js(feature_flag_name, enabled)) html(actual).include?(to_js(feature_flag_name, enabled))
end end
end end
failure_message do |actual| failure_message do |actual|
missing = expected.select do |feature_flag_name, enabled| missing = expected.select do |feature_flag_name, enabled|
!page.html.include?(to_js(feature_flag_name, enabled)) !html(actual).include?(to_js(feature_flag_name, enabled))
end end
formatted_missing_flags = missing.map { |feature_flag_name, enabled| to_js(feature_flag_name, enabled) }.join("\n") formatted_missing_flags = missing.map { |feature_flag_name, enabled| to_js(feature_flag_name, enabled) }.join("\n")
......
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