Commit 4e08f02d authored by Olena Horal-Koretska's avatar Olena Horal-Koretska Committed by Enrique Alcántara

Incident severity widget

parent 4e24daa6
import { __, s__ } from '~/locale';
export const INCIDENT_SEVERITY = {
CRITICAL: {
value: 'CRITICAL',
icon: 'critical',
label: s__('IncidentManagement|Critical - S1'),
},
HIGH: {
value: 'HIGH',
icon: 'high',
label: s__('IncidentManagement|High - S2'),
},
MEDIUM: {
value: 'MEDIUM',
icon: 'medium',
label: s__('IncidentManagement|Medium - S3'),
},
LOW: {
value: 'LOW',
icon: 'low',
label: s__('IncidentManagement|Low - S4'),
},
UNKNOWN: {
value: 'UNKNOWN',
icon: 'unknown',
label: s__('IncidentManagement|Unknown'),
},
};
export const ISSUABLE_TYPES = {
INCIDENT: 'incident',
};
export const SIDEBAR_ANIMATION_DURATION = 300;
export const I18N = {
UPDATE_SEVERITY_ERROR: s__('SeverityWidget|There was an error while updating severity.'),
TRY_AGAIN: __('Please try again'),
EDIT: __('Edit'),
SEVERITY: s__('SeverityWidget|Severity'),
SEVERITY_VALUE: s__('SeverityWidget|Severity: %{severity}'),
};
mutation updateIssuableSeverity($projectPath: ID!, $severity: IssuableSeverity!, $iid: String!) {
issueSetSeverity(input: { iid: $iid, severity: $severity, projectPath: $projectPath }) {
errors
issue {
iid
severity
}
}
}
<script>
import { GlIcon } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
props: {
severity: {
type: Object,
required: true,
validator(severity) {
const { value, label, icon } = severity;
return value && label && icon;
},
},
iconSize: {
type: Number,
required: false,
default: 12,
},
iconOnly: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<div
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between"
>
<gl-icon
:size="iconSize"
:name="`severity-${severity.icon}`"
:class="[`icon-${severity.icon}`, { 'gl-mr-3': !iconOnly }]"
/>
<span v-if="!iconOnly">{{ severity.label }}</span>
</div>
</template>
<script>
import {
GlDropdown,
GlDropdownItem,
GlLoadingIcon,
GlTooltip,
GlSprintf,
GlLink,
} from '@gitlab/ui';
import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SIDEBAR_ANIMATION_DURATION, I18N } from './constants';
import updateIssuableSeverity from './graphql/mutations/update_issuable_severity.mutation.graphql';
import SeverityToken from './severity.vue';
import createFlash from '~/flash';
export default {
i18n: I18N,
components: {
GlLoadingIcon,
GlTooltip,
GlSprintf,
GlDropdown,
GlDropdownItem,
GlLink,
SeverityToken,
},
props: {
projectPath: {
type: String,
required: true,
},
iid: {
type: String,
required: true,
},
initialSeverity: {
type: String,
required: false,
default: INCIDENT_SEVERITY.UNKNOWN.value,
},
issuableType: {
type: String,
required: false,
default: ISSUABLE_TYPES.INCIDENT,
validator: value => {
// currently severity is supported only for incidents, but this list might be extended
return [ISSUABLE_TYPES.INCIDENT].includes(value);
},
},
},
data() {
return {
isDropdownShowing: false,
isUpdating: false,
severity: this.initialSeverity,
};
},
computed: {
severitiesList() {
switch (this.issuableType) {
case ISSUABLE_TYPES.INCIDENT:
return Object.values(INCIDENT_SEVERITY);
default:
return [];
}
},
dropdownClass() {
return this.isDropdownShowing ? 'show' : 'gl-display-none';
},
selectedItem() {
return this.severitiesList.find(severity => severity.value === this.severity);
},
},
mounted() {
document.addEventListener('click', this.handleOffClick);
},
beforeDestroy() {
document.removeEventListener('click', this.handleOffClick);
},
methods: {
handleOffClick(event) {
if (!this.isDropdownShowing) {
return;
}
if (!this.$refs.sidebarSeverity.contains(event.target)) {
this.hideDropdown();
}
},
hideDropdown() {
this.isDropdownShowing = false;
const event = new Event('hidden.gl.dropdown');
this.$el.dispatchEvent(event);
},
toggleFormDropdown(collapsedSidebar) {
this.isDropdownShowing = !this.isDropdownShowing;
const timeout = collapsedSidebar ? SIDEBAR_ANIMATION_DURATION : 0;
setTimeout(() => {
const { dropdown } = this.$refs;
if (dropdown && this.isDropdownShowing) {
dropdown.show();
}
}, timeout);
},
updateSeverity(value) {
this.hideDropdown();
this.isUpdating = true;
this.$apollo
.mutate({
mutation: updateIssuableSeverity,
variables: {
iid: this.iid,
severity: value,
projectPath: this.projectPath,
},
})
.then(resp => {
const {
data: {
issueSetSeverity: {
errors = [],
issue: { severity },
},
},
} = resp;
if (errors[0]) {
throw errors[0];
}
this.severity = severity;
})
.catch(() =>
createFlash({
message: `${this.$options.i18n.UPDATE_SEVERITY_ERROR} ${this.$options.i18n.TRY_AGAIN}`,
}),
)
.finally(() => {
this.isUpdating = false;
});
},
},
};
</script>
<template>
<div ref="sidebarSeverity" class="block">
<div ref="severity" class="sidebar-collapsed-icon" @click="toggleFormDropdown(true)">
<severity-token :severity="selectedItem" :icon-size="14" :icon-only="true" />
<gl-tooltip :target="() => $refs.severity" boundary="viewport" placement="left">
<gl-sprintf :message="$options.i18n.SEVERITY_VALUE">
<template #severity>
{{ selectedItem.label }}
</template>
</gl-sprintf>
</gl-tooltip>
</div>
<div class="hide-collapsed">
<p class="title gl-display-flex gl-justify-content-space-between">
{{ $options.i18n.SEVERITY }}
<gl-link
data-testid="editButton"
href="#"
@click="toggleFormDropdown"
@keydown.esc="hideDropdown"
>
{{ $options.i18n.EDIT }}
</gl-link>
</p>
<gl-dropdown
ref="dropdown"
:class="dropdownClass"
block
:text="selectedItem.label"
toggle-class="dropdown-menu-toggle gl-mb-2"
@keydown.esc.native="hideDropdown"
>
<gl-dropdown-item
v-for="option in severitiesList"
:key="option.value"
data-testid="severityDropdownItem"
:is-check-item="true"
:is-checked="option.value === severity"
@click="updateSeverity(option.value)"
>
<severity-token :severity="option" />
</gl-dropdown-item>
</gl-dropdown>
<gl-loading-icon v-if="isUpdating" :inline="true" />
<severity-token v-else-if="!isDropdownShowing" :severity="selectedItem" />
</div>
</div>
</template>
...@@ -8,6 +8,7 @@ import SidebarMoveIssue from './lib/sidebar_move_issue'; ...@@ -8,6 +8,7 @@ import SidebarMoveIssue from './lib/sidebar_move_issue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import sidebarParticipants from './components/participants/sidebar_participants.vue'; import sidebarParticipants from './components/participants/sidebar_participants.vue';
import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { store } from '~/notes/stores'; import { store } from '~/notes/stores';
...@@ -159,6 +160,35 @@ function mountTimeTrackingComponent() { ...@@ -159,6 +160,35 @@ function mountTimeTrackingComponent() {
}); });
} }
function mountSeverityComponent() {
const severityContainerEl = document.querySelector('#js-severity');
if (!severityContainerEl) {
return false;
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const { fullPath, iid, severity } = getSidebarOptions();
return new Vue({
el: severityContainerEl,
apolloProvider,
components: {
SidebarSeverity,
},
render: createElement =>
createElement('sidebar-severity', {
props: {
projectPath: fullPath,
iid: String(iid),
initialSeverity: severity.toUpperCase(),
},
}),
});
}
export function mountSidebar(mediator) { export function mountSidebar(mediator) {
mountAssigneesComponent(mediator); mountAssigneesComponent(mediator);
mountConfidentialComponent(mediator); mountConfidentialComponent(mediator);
...@@ -173,6 +203,8 @@ export function mountSidebar(mediator) { ...@@ -173,6 +203,8 @@ export function mountSidebar(mediator) {
).init(); ).init();
mountTimeTrackingComponent(); mountTimeTrackingComponent();
mountSeverityComponent();
} }
export { getSidebarOptions }; export { getSidebarOptions };
.incident-severity,
.incident-management-list, .incident-management-list,
.alert-management-details { .alert-management-details {
.icon-critical { .icon-critical {
......
...@@ -465,6 +465,7 @@ module IssuablesHelper ...@@ -465,6 +465,7 @@ module IssuablesHelper
rootPath: root_path, rootPath: root_path,
fullPath: issuable[:project_full_path], fullPath: issuable[:project_full_path],
iid: issuable[:iid], iid: issuable[:iid],
severity: issuable[:severity],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours
} }
end end
......
...@@ -185,6 +185,10 @@ module Issuable ...@@ -185,6 +185,10 @@ module Issuable
is_a?(TimeTrackable) && !incident? is_a?(TimeTrackable) && !incident?
end end
def supports_severity?
incident?
end
def incident? def incident?
is_a?(Issue) && super is_a?(Issue) && super
end end
......
...@@ -105,6 +105,7 @@ class IssuableSidebarBasicEntity < Grape::Entity ...@@ -105,6 +105,7 @@ class IssuableSidebarBasicEntity < Grape::Entity
expose :supports_time_tracking?, as: :supports_time_tracking expose :supports_time_tracking?, as: :supports_time_tracking
expose :supports_milestone?, as: :supports_milestone expose :supports_milestone?, as: :supports_milestone
expose :supports_severity?, as: :supports_severity
private private
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
class IssueSidebarBasicEntity < IssuableSidebarBasicEntity class IssueSidebarBasicEntity < IssuableSidebarBasicEntity
expose :due_date expose :due_date
expose :confidential expose :confidential
expose :severity
end end
IssueSidebarBasicEntity.prepend_if_ee('EE::IssueSidebarBasicEntity') IssueSidebarBasicEntity.prepend_if_ee('EE::IssueSidebarBasicEntity')
...@@ -133,6 +133,9 @@ ...@@ -133,6 +133,9 @@
= render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar
- if issuable_sidebar[:supports_severity]
#js-severity
- if issuable_sidebar.dig(:features_available, :health_status) - if issuable_sidebar.dig(:features_available, :health_status)
.js-sidebar-status-entry-point .js-sidebar-status-entry-point
......
---
title: Incident severity widget
merge_request: 39859
author:
type: added
...@@ -13252,18 +13252,30 @@ msgstr "" ...@@ -13252,18 +13252,30 @@ msgstr ""
msgid "IncidentManagement|Create incident" msgid "IncidentManagement|Create incident"
msgstr "" msgstr ""
msgid "IncidentManagement|Critical - S1"
msgstr ""
msgid "IncidentManagement|Date created" msgid "IncidentManagement|Date created"
msgstr "" msgstr ""
msgid "IncidentManagement|Display your incidents in a dedicated view" msgid "IncidentManagement|Display your incidents in a dedicated view"
msgstr "" msgstr ""
msgid "IncidentManagement|High - S2"
msgstr ""
msgid "IncidentManagement|Incident" msgid "IncidentManagement|Incident"
msgstr "" msgstr ""
msgid "IncidentManagement|Incidents" msgid "IncidentManagement|Incidents"
msgstr "" msgstr ""
msgid "IncidentManagement|Low - S4"
msgstr ""
msgid "IncidentManagement|Medium - S3"
msgstr ""
msgid "IncidentManagement|No incidents to display." msgid "IncidentManagement|No incidents to display."
msgstr "" msgstr ""
...@@ -13285,6 +13297,9 @@ msgstr "" ...@@ -13285,6 +13297,9 @@ msgstr ""
msgid "IncidentManagement|Unassigned" msgid "IncidentManagement|Unassigned"
msgstr "" msgstr ""
msgid "IncidentManagement|Unknown"
msgstr ""
msgid "IncidentManagement|Unpublished" msgid "IncidentManagement|Unpublished"
msgstr "" msgstr ""
...@@ -22934,6 +22949,15 @@ msgstr "" ...@@ -22934,6 +22949,15 @@ msgstr ""
msgid "Severity" msgid "Severity"
msgstr "" msgstr ""
msgid "SeverityWidget|Severity"
msgstr ""
msgid "SeverityWidget|Severity: %{severity}"
msgstr ""
msgid "SeverityWidget|There was an error while updating severity."
msgstr ""
msgid "Shards (%{shards})" msgid "Shards (%{shards})"
msgstr "" msgstr ""
......
...@@ -44,6 +44,7 @@ ...@@ -44,6 +44,7 @@
"move_issue_path": { "type": "string" }, "move_issue_path": { "type": "string" },
"projects_autocomplete_path": { "type": "string" }, "projects_autocomplete_path": { "type": "string" },
"supports_time_tracking": { "type": "boolean" }, "supports_time_tracking": { "type": "boolean" },
"supports_milestone": { "type": "boolean" } "supports_milestone": { "type": "boolean" },
"supports_severity": { "type": "boolean" }
} }
} }
import { shallowMount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
describe('SeverityToken', () => {
let wrapper;
function createComponent(props) {
wrapper = shallowMount(SeverityToken, {
propsData: {
...props,
},
});
}
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const findIcon = () => wrapper.find(GlIcon);
it('renders severity token for each severity type', () => {
Object.values(INCIDENT_SEVERITY).forEach(severity => {
createComponent({ severity });
expect(findIcon().classes()).toContain(`icon-${severity.icon}`);
expect(findIcon().attributes('name')).toBe(`severity-${severity.icon}`);
expect(wrapper.text()).toBe(severity.label);
});
});
it('renders only icon when `iconOnly` prop is set to `true`', () => {
const severity = INCIDENT_SEVERITY.CRITICAL;
createComponent({ severity, iconOnly: true });
expect(findIcon().classes()).toContain(`icon-${severity.icon}`);
expect(findIcon().attributes('name')).toBe(`severity-${severity.icon}`);
expect(wrapper.text()).toBe('');
});
describe('icon size', () => {
it('renders the icon in default size when other is not specified', () => {
const severity = INCIDENT_SEVERITY.HIGH;
createComponent({ severity });
expect(findIcon().attributes('size')).toBe('12');
});
it('renders the icon in provided size', () => {
const severity = INCIDENT_SEVERITY.HIGH;
const iconSize = 14;
createComponent({ severity, iconSize });
expect(findIcon().attributes('size')).toBe(`${iconSize}`);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import updateIssuableSeverity from '~/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql';
import { INCIDENT_SEVERITY, ISSUABLE_TYPES } from '~/sidebar/components/severity/constants';
jest.mock('~/flash');
describe('SidebarSeverity', () => {
let wrapper;
let mutate;
const projectPath = 'gitlab-org/gitlab-test';
const iid = '1';
const severity = 'CRITICAL';
function createComponent(props = {}) {
const propsData = {
projectPath,
iid,
issuableType: ISSUABLE_TYPES.INCIDENT,
initialSeverity: severity,
...props,
};
mutate = jest.fn();
wrapper = shallowMount(SidebarSeverity, {
propsData,
mocks: {
$apollo: {
mutate,
},
},
stubs: {
GlSprintf,
},
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const findSeverityToken = () => wrapper.findAll(SeverityToken);
const findEditBtn = () => wrapper.find('[data-testid="editButton"]');
const findDropdown = () => wrapper.find(GlDropdown);
const findCriticalSeverityDropdownItem = () => wrapper.find(GlDropdownItem);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findTooltip = () => wrapper.find(GlTooltip);
const findCollapsedSeverity = () => wrapper.find({ ref: 'severity' });
it('renders severity widget', () => {
expect(findEditBtn().exists()).toBe(true);
expect(findSeverityToken().exists()).toBe(true);
expect(findDropdown().exists()).toBe(true);
});
describe('Update severity', () => {
it('calls `$apollo.mutate` with `updateIssuableSeverity`', () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValueOnce({ data: { issueSetSeverity: { issue: { severity } } } });
findCriticalSeverityDropdownItem().vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateIssuableSeverity,
variables: {
iid,
projectPath,
severity,
},
});
});
it('shows error alert when severity update fails ', () => {
const errorMsg = 'Something went wrong';
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValueOnce(errorMsg);
findCriticalSeverityDropdownItem().vm.$emit('click');
setImmediate(() => {
expect(createFlash).toHaveBeenCalled();
});
});
it('shows loading icon while updating', async () => {
let resolvePromise;
wrapper.vm.$apollo.mutate = jest.fn(
() =>
new Promise(resolve => {
resolvePromise = resolve;
}),
);
findCriticalSeverityDropdownItem().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findLoadingIcon().exists()).toBe(true);
resolvePromise();
await waitForPromises();
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('Switch between collapsed/expanded view of the sidebar', () => {
const HIDDDEN_CLASS = 'gl-display-none';
const SHOWN_CLASS = 'show';
describe('collapsed', () => {
it('should have collapsed icon class', () => {
expect(findCollapsedSeverity().classes('sidebar-collapsed-icon')).toBe(true);
});
it('should display only icon with a tooltip', () => {
expect(
findSeverityToken()
.at(0)
.attributes('icononly'),
).toBe('true');
expect(
findSeverityToken()
.at(0)
.attributes('iconsize'),
).toBe('14');
expect(
findTooltip()
.text()
.replace(/\s+/g, ' '),
).toContain(`Severity: ${INCIDENT_SEVERITY[severity].label}`);
});
it('should expand the dropdown on collapsed icon click', async () => {
wrapper.vm.isDropdownShowing = false;
await wrapper.vm.$nextTick();
expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true);
findCollapsedSeverity().trigger('click');
await wrapper.vm.$nextTick();
expect(findDropdown().classes(SHOWN_CLASS)).toBe(true);
});
});
describe('expanded', () => {
it('toggles dropdown with edit button', async () => {
wrapper.vm.isDropdownShowing = false;
await wrapper.vm.$nextTick();
expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true);
findEditBtn().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findDropdown().classes(SHOWN_CLASS)).toBe(true);
findEditBtn().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true);
});
});
});
});
...@@ -837,6 +837,24 @@ RSpec.describe Issuable do ...@@ -837,6 +837,24 @@ RSpec.describe Issuable do
end end
end end
describe '#supports_severity?' do
using RSpec::Parameterized::TableSyntax
where(:issuable_type, :supports_severity) do
:issue | false
:incident | true
:merge_request | false
end
with_them do
let(:issuable) { build_stubbed(issuable_type) }
subject { issuable.supports_severity? }
it { is_expected.to eq(supports_severity) }
end
end
describe '#incident?' do describe '#incident?' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
......
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