Commit 0d0a6883 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 502309c2 deea032d
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
GlIcon, GlIcon,
GlEmptyState, GlEmptyState,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils';
import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
...@@ -287,6 +288,7 @@ export default { ...@@ -287,6 +288,7 @@ export default {
errorAlertDismissed() { errorAlertDismissed() {
this.isErrorAlertDismissed = true; this.isErrorAlertDismissed = true;
}, },
isValidSlaDueAt,
}, },
}; };
</script> </script>
...@@ -367,7 +369,13 @@ export default { ...@@ -367,7 +369,13 @@ export default {
</template> </template>
<template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }"> <template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }">
<service-level-agreement-cell :sla-due-at="item.slaDueAt" data-testid="incident-sla" /> <service-level-agreement-cell
v-if="isValidSlaDueAt(item.slaDueAt)"
:issue-iid="item.iid"
:project-path="projectPath"
:sla-due-at="item.slaDueAt"
data-testid="incident-sla"
/>
</template> </template>
<template #cell(assignees)="{ item }"> <template #cell(assignees)="{ item }">
......
...@@ -48,7 +48,7 @@ const createEventPayload = (el, { suffix = '' } = {}) => { ...@@ -48,7 +48,7 @@ const createEventPayload = (el, { suffix = '' } = {}) => {
const action = (trackAction || trackEvent) + (suffix || ''); const action = (trackAction || trackEvent) + (suffix || '');
let value = trackValue || el.value || undefined; let value = trackValue || el.value || undefined;
if (el.type === 'checkbox' && !el.checked) value = false; if (el.type === 'checkbox' && !el.checked) value = 0;
let extra = trackExtra; let extra = trackExtra;
......
import { noop } from 'lodash';
export const isValidSlaDueAt = noop;
...@@ -116,7 +116,7 @@ module Projects ...@@ -116,7 +116,7 @@ module Projects
log_destroy_event log_destroy_event
trash_relation_repositories! trash_relation_repositories!
trash_project_repositories! trash_project_repositories!
destroy_web_hooks! if Feature.enabled?(:destroy_webhooks_before_the_project, project, default_enabled: :yaml) destroy_web_hooks!
# Rails attempts to load all related records into memory before # Rails attempts to load all related records into memory before
# destroying: https://github.com/rails/rails/issues/22510 # destroying: https://github.com/rails/rails/issues/22510
......
---
name: destroy_webhooks_before_the_project
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59754
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/328393
milestone: '13.12'
type: development
group: group::source code
default_enabled: true
...@@ -64,7 +64,7 @@ GET /groups/:id/wikis/:slug ...@@ -64,7 +64,7 @@ GET /groups/:id/wikis/:slug
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ------- | -------- | --------------------- | | --------- | ------- | -------- | --------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) | | `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) |
| `slug` | string | yes | The slug (a unique string) of the wiki page | | `slug` | string | yes | URL-encoded slug (a unique string) of the wiki page, such as `dir%2Fpage_name` |
```shell ```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/1/wikis/home" curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/1/wikis/home"
...@@ -127,7 +127,7 @@ PUT /groups/:id/wikis/:slug ...@@ -127,7 +127,7 @@ PUT /groups/:id/wikis/:slug
| `content` | string | yes if `title` is not provided | The content of the wiki page | | `content` | string | yes if `title` is not provided | The content of the wiki page |
| `title` | string | yes if `content` is not provided | The title of the wiki page | | `title` | string | yes if `content` is not provided | The title of the wiki page |
| `format` | string | no | The format of the wiki page. Available formats are: `markdown` (default), `rdoc`, `asciidoc` and `org` | | `format` | string | no | The format of the wiki page. Available formats are: `markdown` (default), `rdoc`, `asciidoc` and `org` |
| `slug` | string | yes | The slug (a unique identifier) of the wiki page | | `slug` | string | yes | URL encoded slug (a unique string) of the wiki page. Ex. dir%2Fpage_name |
```shell ```shell
curl --request PUT --data "format=rdoc&content=documentation&title=Docs" \ curl --request PUT --data "format=rdoc&content=documentation&title=Docs" \
...@@ -157,7 +157,7 @@ DELETE /groups/:id/wikis/:slug ...@@ -157,7 +157,7 @@ DELETE /groups/:id/wikis/:slug
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ------- | -------- | --------------------- | | --------- | ------- | -------- | --------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) | | `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) |
| `slug` | string | yes | The slug (a unique identifier) of the wiki page | | `slug` | string | yes | URL-encoded slug (a unique string) of the wiki page, such as `dir%2Fpage_name` |
```shell ```shell
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/1/wikis/foo" curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/1/wikis/foo"
......
...@@ -64,7 +64,7 @@ GET /projects/:id/wikis/:slug ...@@ -64,7 +64,7 @@ GET /projects/:id/wikis/:slug
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ------- | -------- | --------------------- | | --------- | ------- | -------- | --------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `slug` | string | yes | The slug (a unique string) of the wiki page | | `slug` | string | yes | URLencoded slug (a unique string) of the wiki page, such as `dir%2Fpage_name` |
```shell ```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/wikis/home" curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/wikis/home"
...@@ -125,7 +125,7 @@ PUT /projects/:id/wikis/:slug ...@@ -125,7 +125,7 @@ PUT /projects/:id/wikis/:slug
| `content` | string | yes if `title` is not provided | The content of the wiki page | | `content` | string | yes if `title` is not provided | The content of the wiki page |
| `title` | string | yes if `content` is not provided | The title of the wiki page | | `title` | string | yes if `content` is not provided | The title of the wiki page |
| `format` | string | no | The format of the wiki page. Available formats are: `markdown` (default), `rdoc`, `asciidoc` and `org` | | `format` | string | no | The format of the wiki page. Available formats are: `markdown` (default), `rdoc`, `asciidoc` and `org` |
| `slug` | string | yes | The slug (a unique string) of the wiki page | | `slug` | string | yes | URL-encoded slug (a unique string) of the wiki page, such as `dir%2Fpage_name` |
```shell ```shell
curl --request PUT --data "format=rdoc&content=documentation&title=Docs" --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/wikis/foo" curl --request PUT --data "format=rdoc&content=documentation&title=Docs" --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/wikis/foo"
...@@ -153,7 +153,7 @@ DELETE /projects/:id/wikis/:slug ...@@ -153,7 +153,7 @@ DELETE /projects/:id/wikis/:slug
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ------- | -------- | --------------------- | | --------- | ------- | -------- | --------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `slug` | string | yes | The slug (a unique string) of the wiki page | | `slug` | string | yes | URL-encoded slug (a unique string) of the wiki page, such as `dir%2Fpage_name` |
```shell ```shell
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/wikis/foo" curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/wikis/foo"
......
query getIncidentState($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) {
issue(iid: $iid) {
id
state
}
}
}
query getSlaDueAt($iid: String!, $fullPath: ID!) { query getSlaDueAt($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
issue(iid: $iid) { issue(iid: $iid) {
id
slaDueAt slaDueAt
} }
} }
......
...@@ -3,27 +3,27 @@ import { GlIcon } from '@gitlab/ui'; ...@@ -3,27 +3,27 @@ import { GlIcon } from '@gitlab/ui';
import { isValidSlaDueAt } from 'ee/vue_shared/components/incidents/utils'; import { isValidSlaDueAt } from 'ee/vue_shared/components/incidents/utils';
import ServiceLevelAgreement from 'ee_component/vue_shared/components/incidents/service_level_agreement.vue'; import ServiceLevelAgreement from 'ee_component/vue_shared/components/incidents/service_level_agreement.vue';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { formatTime, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import getSlaDueAt from './graphql/queries/get_sla_due_at.graphql'; import getSlaIncidentDataQuery from './graphql/queries/get_sla_due_at.query.graphql';
export default { export default {
components: { GlIcon, ServiceLevelAgreement }, components: { GlIcon, ServiceLevelAgreement },
inject: ['fullPath', 'iid', 'slaFeatureAvailable'], inject: ['fullPath', 'iid', 'slaFeatureAvailable'],
apollo: { apollo: {
slaDueAt: { slaDueAt: {
query: getSlaDueAt, query: getSlaIncidentDataQuery,
variables() { variables() {
return { return {
fullPath: this.fullPath, fullPath: this.fullPath,
iid: this.iid, iid: this.iid,
}; };
}, },
update(data) { update({ project }) {
return data?.project?.issue?.slaDueAt; return project?.issue?.slaDueAt || null;
}, },
result({ data }) { result({ data }) {
const isValidSla = isValidSlaDueAt(data?.project?.issue?.slaDueAt); const issue = data?.project?.issue;
const isValidSla = isValidSlaDueAt(issue?.slaDueAt);
// Render component // Render component
this.hasData = isValidSla; this.hasData = isValidSla;
...@@ -40,18 +40,10 @@ export default { ...@@ -40,18 +40,10 @@ export default {
}, },
data() { data() {
return { return {
slaDueAt: null,
hasData: false, hasData: false,
slaDueAt: null,
}; };
}, },
computed: {
displayValue() {
const time = formatTime(calculateRemainingMilliseconds(this.slaDueAt));
// remove the seconds portion of the string
return time.substring(0, time.length - 3);
},
},
}; };
</script> </script>
...@@ -60,7 +52,7 @@ export default { ...@@ -60,7 +52,7 @@ export default {
<span class="gl-font-weight-bold">{{ s__('HighlightBar|Time to SLA:') }}</span> <span class="gl-font-weight-bold">{{ s__('HighlightBar|Time to SLA:') }}</span>
<span class="gl-white-space-nowrap"> <span class="gl-white-space-nowrap">
<gl-icon name="timer" /> <gl-icon name="timer" />
<service-level-agreement :sla-due-at="slaDueAt" /> <service-level-agreement :sla-due-at="slaDueAt" :issue-iid="iid" :project-path="fullPath" />
</span> </span>
</div> </div>
</template> </template>
<script> <script>
import { GlTooltipDirective } from '@gitlab/ui'; import { GlTooltipDirective } from '@gitlab/ui';
import getIncidentStateQuery from 'ee/graphql_shared/queries/get_incident_state.query.graphql';
import { formatTime, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; import { formatTime, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import { isValidSlaDueAt } from './utils'; import { isValidSlaDueAt } from './utils';
export default { export default {
i18n: { i18n: {
achievedSLAText: s__('IncidentManagement|Achieved SLA'),
missedSLAText: s__('IncidentManagement|Missed SLA'),
longTitle: s__('IncidentManagement|%{hours} hours, %{minutes} minutes remaining'), longTitle: s__('IncidentManagement|%{hours} hours, %{minutes} minutes remaining'),
shortTitle: s__('IncidentManagement|%{minutes} minutes remaining'), shortTitle: s__('IncidentManagement|%{minutes} minutes remaining'),
}, },
// Refresh the timer display every 15 minutes.
REFRESH_INTERVAL: 15 * 60 * 1000,
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
apollo: {
issueState: {
query: getIncidentStateQuery,
variables() {
return {
iid: this.issueIid,
fullPath: this.projectPath,
};
},
skip() {
return this.remainingTime > 0;
},
update(data) {
return data?.project?.issue?.state;
},
},
},
props: { props: {
slaDueAt: { slaDueAt: {
type: String, // ISODateString type: String, // ISODateString
required: false, required: false,
default: null, default: null,
}, },
issueIid: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
},
data() {
return {
issueState: null,
clientRemainingTime: null,
};
}, },
computed: { computed: {
shouldShow() { hasNoTimeRemaining() {
return isValidSlaDueAt(this.slaDueAt); return this.remainingTime === 0;
},
isMissedSLA() {
return this.hasNoTimeRemaining && !this.isClosed;
},
isAchievedSLA() {
return this.hasNoTimeRemaining && this.isClosed;
},
isClosed() {
return this.issueState === 'closed';
}, },
remainingTime() { remainingTime() {
return calculateRemainingMilliseconds(this.slaDueAt); return this.clientRemainingTime ?? calculateRemainingMilliseconds(this.slaDueAt);
},
shouldShow() {
return isValidSlaDueAt(this.slaDueAt);
}, },
slaText() { slaText() {
if (this.isMissedSLA) {
return this.$options.i18n.missedSLAText;
}
if (this.isAchievedSLA) {
return this.$options.i18n.achievedSLAText;
}
const remainingDuration = formatTime(this.remainingTime); const remainingDuration = formatTime(this.remainingTime);
// remove the seconds portion of the string // remove the seconds portion of the string
return remainingDuration.substring(0, remainingDuration.length - 3); return remainingDuration.substring(0, remainingDuration.length - 3);
}, },
slaTitle() { slaTitle() {
if (this.hasNoTimeRemaining) {
return '';
}
const minutes = Math.floor(this.remainingTime / 1000 / 60) % 60; const minutes = Math.floor(this.remainingTime / 1000 / 60) % 60;
const hours = Math.floor(this.remainingTime / 1000 / 60 / 60); const hours = Math.floor(this.remainingTime / 1000 / 60 / 60);
...@@ -42,6 +101,22 @@ export default { ...@@ -42,6 +101,22 @@ export default {
return sprintf(this.$options.i18n.shortTitle, { minutes }); return sprintf(this.$options.i18n.shortTitle, { minutes });
}, },
}, },
mounted() {
this.timer = setInterval(this.refreshTime, this.$options.REFRESH_INTERVAL);
},
beforeDestroy() {
clearTimeout(this.timer);
},
methods: {
refreshTime() {
if (this.remainingTime > this.$options.REFRESH_INTERVAL) {
this.clientRemainingTime = this.remainingTime - this.$options.REFRESH_INTERVAL;
} else {
clearTimeout(this.timer);
this.clientRemainingTime = 0;
}
},
},
}; };
</script> </script>
<template> <template>
......
[
{
"iid": "15",
"title": "New: Alert",
"createdAt": "2020-06-03T15:46:08Z",
"assignees": {},
"state": "opened",
"severity": "CRITICAL",
"slaDueAt": "2020-06-04T12:46:08Z"
},
{
"iid": "14",
"title": "Create issue4",
"createdAt": "2020-05-19T09:26:07Z",
"assignees": {
"nodes": [
{
"name": "Benjamin Braun",
"username": "kami.hegmann",
"avatarUrl": "https://invalid'",
"webUrl": "https://invalid"
}
]
},
"state": "opened",
"severity": "HIGH",
"slaDueAt": "2020-06-05T12:46:08Z"
},
{
"iid": "13",
"title": "Create issue3",
"createdAt": "2020-05-19T08:53:55Z",
"assignees": {},
"state": "closed",
"severity": "LOW"
},
{
"iid": "12",
"title": "Create issue2",
"createdAt": "2020-05-18T17:13:35Z",
"assignees": {},
"state": "closed",
"severity": "MEDIUM"
}
]
import ServiceLevelAgreement from 'ee_component/vue_shared/components/incidents/service_level_agreement.vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import IncidentsList from '~/incidents/components/incidents_list.vue';
import mockIncidents from './mocks/incidents.json';
const defaultProvide = {
projectPath: '/project/path',
newIssuePath: 'namespace/project/-/issues/new',
incidentTemplateName: 'incident',
incidentType: 'incident',
issuePath: '/project/issues',
publishedAvailable: true,
emptyListSvgPath: '/assets/empty.svg',
textQuery: '',
authorUsernameQuery: '',
assigneeUsernameQuery: '',
slaFeatureAvailable: true,
};
describe('Incidents Service Level Agreement', () => {
let wrapper;
const findIncidentSlaHeader = () => wrapper.findByTestId('incident-management-sla');
const findIncidentSLAs = () => wrapper.findAllComponents(ServiceLevelAgreement);
function mountComponent(provide = {}) {
wrapper = mountExtended(IncidentsList, {
data() {
return {
incidents: { list: mockIncidents },
incidentsCount: {},
};
},
mocks: {
$apollo: {
queries: {
incidents: {
loading: false,
},
},
},
},
provide: {
...defaultProvide,
...provide,
},
});
}
afterEach(() => {
wrapper.destroy();
});
describe('Incident SLA field', () => {
it('displays the column when the feature is available', () => {
mountComponent({ slaFeatureAvailable: true });
expect(findIncidentSlaHeader().text()).toContain('Time to SLA');
});
it('does not display the column when the feature is not available', () => {
mountComponent({ slaFeatureAvailable: false });
expect(findIncidentSlaHeader().exists()).toBe(false);
});
it('renders an SLA for each incident with an SLA', () => {
mountComponent({ slaFeatureAvailable: true });
expect(findIncidentSLAs()).toHaveLength(2);
});
});
});
...@@ -5,7 +5,7 @@ import ServiceLevelAgreement from 'ee_component/vue_shared/components/incidents/ ...@@ -5,7 +5,7 @@ import ServiceLevelAgreement from 'ee_component/vue_shared/components/incidents/
jest.mock('~/lib/utils/datetime_utility'); jest.mock('~/lib/utils/datetime_utility');
const defaultProvide = { fullPath: 'test', iid: 1, slaFeatureAvailable: true }; const defaultProvide = { fullPath: 'test', iid: '1', slaFeatureAvailable: true };
const mockSlaDueAt = '2020-01-01T00:00:00.000Z'; const mockSlaDueAt = '2020-01-01T00:00:00.000Z';
describe('Incident SLA', () => { describe('Incident SLA', () => {
......
import { shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import getIncidentStateQuery from 'ee/graphql_shared/queries/get_incident_state.query.graphql';
import ServiceLevelAgreementCell from 'ee/vue_shared/components/incidents/service_level_agreement.vue'; import ServiceLevelAgreementCell from 'ee/vue_shared/components/incidents/service_level_agreement.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility'; import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility';
const localVue = createLocalVue();
const formatTimeActual = jest.requireActual('~/lib/utils/datetime_utility').formatTime;
jest.mock('~/lib/utils/datetime_utility', () => ({ jest.mock('~/lib/utils/datetime_utility', () => ({
calculateRemainingMilliseconds: jest.fn(() => 1000), calculateRemainingMilliseconds: jest.fn(() => 1000),
formatTime: jest.fn(() => '00:00:00'), formatTime: jest.fn(),
})); }));
const mockDateString = '2020-10-15T02:42:27Z'; const mockDateString = '2020-10-15T02:42:27Z';
describe('Incidents Published Cell', () => { const ONE_MINUTE = 60 * 1000; // ms
const MINUTES = {
FIVE: 5 * ONE_MINUTE,
FIFTEEN: 15 * ONE_MINUTE,
TWENTY: 20 * ONE_MINUTE,
THIRTY_FIVE: 35 * ONE_MINUTE,
};
const issueStateResponse = (state = 'opened') => ({
data: { project: { issue: { state, id: '1' } } },
});
describe('Service Level Agreement', () => {
let wrapper; let wrapper;
function mountComponent(props) { const advanceFifteenMinutes = async () => {
jest.advanceTimersByTime(MINUTES.FIFTEEN);
await nextTick();
};
function createMockApolloProvider(issueState) {
localVue.use(VueApollo);
const requestHandlers = [
[getIncidentStateQuery, jest.fn().mockResolvedValue(issueStateResponse(issueState))],
];
return createMockApollo(requestHandlers);
}
function mountComponent({ mockApollo, props } = {}) {
wrapper = shallowMount(ServiceLevelAgreementCell, { wrapper = shallowMount(ServiceLevelAgreementCell, {
localVue,
apolloProvider: mockApollo,
propsData: { propsData: {
...props, ...props,
issueIid: '5',
projectPath: 'test-project',
}, },
}); });
} }
...@@ -27,46 +67,120 @@ describe('Incidents Published Cell', () => { ...@@ -27,46 +67,120 @@ describe('Incidents Published Cell', () => {
} }
}); });
describe('Service Level Agreement Cell', () => { beforeEach(() => {
formatTime.mockImplementation(formatTimeActual);
});
describe('initial states', () => {
it('renders an empty cell by default', () => { it('renders an empty cell by default', () => {
mountComponent(); mountComponent();
expect(wrapper.html()).toBe(''); expect(wrapper.html()).toBe('');
}); });
it('renders a empty cell for an invalid date', () => { it('renders a empty cell for an invalid date', () => {
mountComponent({ slaDueAt: 'dfsgsdfg' }); mountComponent({ props: { slaDueAt: 'dfsgsdfg' } });
expect(wrapper.html()).toBe(''); expect(wrapper.html()).toBe('');
}); });
});
describe('tooltips', () => {
const hoursInMilliseconds = 60 * 60 * 1000;
const minutesInMilliseconds = 60 * 1000;
it.each`
hours | minutes | expectedMessage
${5} | ${7} | ${'5 hours, 7 minutes remaining'}
${5} | ${0} | ${'5 hours, 0 minutes remaining'}
${0} | ${7} | ${'7 minutes remaining'}
${0} | ${0} | ${''}
`(
'returns the correct message for: hours: "$hours", minutes: "$minutes"',
({ hours, minutes, expectedMessage }) => {
const testTime = hours * hoursInMilliseconds + minutes * minutesInMilliseconds;
calculateRemainingMilliseconds.mockImplementationOnce(() => testTime);
mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.attributes('title')).toBe(expectedMessage);
},
);
});
describe('countdown timer', () => {
it('advances a countdown timer', async () => {
calculateRemainingMilliseconds.mockImplementationOnce(() => MINUTES.THIRTY_FIVE);
mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.text()).toBe('00:35');
await advanceFifteenMinutes();
expect(wrapper.text()).toBe('00:20');
await advanceFifteenMinutes();
expect(wrapper.text()).toBe('00:05');
});
it('counts down to zero', async () => {
calculateRemainingMilliseconds.mockImplementationOnce(() => MINUTES.FIFTEEN);
mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.text()).toBe('00:15');
await advanceFifteenMinutes();
expect(wrapper.text()).toBe('Missed SLA');
});
it('cleans up a countdown timer when countdown is complete', async () => {
calculateRemainingMilliseconds.mockImplementationOnce(() => MINUTES.FIVE);
mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.text()).toBe('00:05');
await advanceFifteenMinutes();
expect(wrapper.text()).toBe('Missed SLA');
await advanceFifteenMinutes();
expect(wrapper.text()).toBe('Missed SLA');
// If the countdown timer was still running we would expect it to be called a second time
expect(formatTime).toHaveBeenCalledTimes(1);
expect(formatTime).toHaveBeenCalledWith(MINUTES.FIVE);
});
});
describe('SLA text', () => {
it('displays the correct time when displaying an SLA', () => { it('displays the correct time when displaying an SLA', () => {
formatTime.mockImplementation(() => '12:34:56'); formatTime.mockImplementationOnce(() => '12:34:56');
mountComponent({ slaDueAt: mockDateString }); mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.text()).toBe('12:34'); expect(wrapper.text()).toBe('12:34');
}); });
describe('tooltips', () => { describe('text when remaining time is 0', () => {
const hoursInMilliseconds = 60 * 60 * 1000; beforeEach(() => {
const minutesInMilliseconds = 60 * 1000; calculateRemainingMilliseconds.mockImplementationOnce(() => 0);
});
it.each`
hours | minutes | expectedMessage it('shows the correct text when the SLA has been missed', async () => {
${5} | ${7} | ${'5 hours, 7 minutes remaining'} const issueState = 'open';
${5} | ${0} | ${'5 hours, 0 minutes remaining'} const mockApollo = createMockApolloProvider(issueState);
${0} | ${7} | ${'7 minutes remaining'} mountComponent({ props: { slaDueAt: mockDateString }, mockApollo });
${0} | ${0} | ${'0 minutes remaining'}
`( await nextTick();
'returns the correct message for: hours: "$hours", hinutes: "$minutes"',
({ hours, minutes, expectedMessage }) => { expect(wrapper.text()).toBe('Missed SLA');
const testTime = hours * hoursInMilliseconds + minutes * minutesInMilliseconds; });
calculateRemainingMilliseconds.mockImplementation(() => testTime);
it('shows the correct text when the SLA has been achieved', async () => {
mountComponent({ slaDueAt: mockDateString }); const issueState = 'closed';
const mockApollo = createMockApolloProvider(issueState);
expect(wrapper.attributes('title')).toBe(expectedMessage); mountComponent({ props: { slaDueAt: mockDateString }, mockApollo });
},
); await nextTick();
expect(wrapper.text()).toBe('Achieved SLA');
});
}); });
}); });
}); });
...@@ -17363,6 +17363,9 @@ msgstr "" ...@@ -17363,6 +17363,9 @@ msgstr ""
msgid "IncidentManagement|%{minutes} minutes remaining" msgid "IncidentManagement|%{minutes} minutes remaining"
msgstr "" msgstr ""
msgid "IncidentManagement|Achieved SLA"
msgstr ""
msgid "IncidentManagement|All" msgid "IncidentManagement|All"
msgstr "" msgstr ""
...@@ -17402,6 +17405,9 @@ msgstr "" ...@@ -17402,6 +17405,9 @@ msgstr ""
msgid "IncidentManagement|Medium - S3" msgid "IncidentManagement|Medium - S3"
msgstr "" msgstr ""
msgid "IncidentManagement|Missed SLA"
msgstr ""
msgid "IncidentManagement|No incidents to display." msgid "IncidentManagement|No incidents to display."
msgstr "" msgstr ""
......
...@@ -43,12 +43,10 @@ describe('Incidents List', () => { ...@@ -43,12 +43,10 @@ describe('Incidents List', () => {
const findLoader = () => wrapper.find(GlLoadingIcon); const findLoader = () => wrapper.find(GlLoadingIcon);
const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip); const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip);
const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]'); const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]');
const findIncidentSlaHeader = () => wrapper.find('[data-testid="incident-management-sla"]');
const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]'); const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']"); const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
const findEmptyState = () => wrapper.find(GlEmptyState); const findEmptyState = () => wrapper.find(GlEmptyState);
const findSeverity = () => wrapper.findAll(SeverityToken); const findSeverity = () => wrapper.findAll(SeverityToken);
const findIncidentSla = () => wrapper.findAll("[data-testid='incident-sla']");
function mountComponent({ data = {}, loading = false, provide = {} } = {}) { function mountComponent({ data = {}, loading = false, provide = {} } = {}) {
wrapper = mount(IncidentsList, { wrapper = mount(IncidentsList, {
...@@ -188,35 +186,6 @@ describe('Incidents List', () => { ...@@ -188,35 +186,6 @@ describe('Incidents List', () => {
joinPaths(`/project/issues/incident`, mockIncidents[0].iid), joinPaths(`/project/issues/incident`, mockIncidents[0].iid),
); );
}); });
describe('Incident SLA field', () => {
it('displays the column when the feature is available', () => {
mountComponent({
data: { incidents: { list: mockIncidents } },
provide: { slaFeatureAvailable: true },
});
expect(findIncidentSlaHeader().text()).toContain('Time to SLA');
});
it('does not display the column when the feature is not available', () => {
mountComponent({
data: { incidents: { list: mockIncidents } },
provide: { slaFeatureAvailable: false },
});
expect(findIncidentSlaHeader().exists()).toBe(false);
});
it('renders an SLA for each incident', () => {
mountComponent({
data: { incidents: { list: mockIncidents } },
provide: { slaFeatureAvailable: true },
});
expect(findIncidentSla().length).toBe(mockIncidents.length);
});
});
}); });
describe('Create Incident', () => { describe('Create Incident', () => {
......
...@@ -232,7 +232,7 @@ describe('Tracking', () => { ...@@ -232,7 +232,7 @@ describe('Tracking', () => {
Tracking.bindDocument('_category_'); // only happens once Tracking.bindDocument('_category_'); // only happens once
setHTMLFixture(` setHTMLFixture(`
<input data-track-${term}="click_input1" data-track-label="_label_" value="_value_"/> <input data-track-${term}="click_input1" data-track-label="_label_" value="_value_"/>
<input data-track-${term}="click_input2" data-track-value="_value_override_" value="_value_"/> <input data-track-${term}="click_input2" data-track-value="_value_override_" value=0/>
<input type="checkbox" data-track-${term}="toggle_checkbox" value="_value_" checked/> <input type="checkbox" data-track-${term}="toggle_checkbox" value="_value_" checked/>
<input class="dropdown" data-track-${term}="toggle_dropdown"/> <input class="dropdown" data-track-${term}="toggle_dropdown"/>
<div data-track-${term}="nested_event"><span class="nested"></span></div> <div data-track-${term}="nested_event"><span class="nested"></span></div>
...@@ -272,7 +272,7 @@ describe('Tracking', () => { ...@@ -272,7 +272,7 @@ describe('Tracking', () => {
checkbox.click(); // unchecking checkbox.click(); // unchecking
expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', { expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', {
value: false, value: 0,
}); });
checkbox.click(); // checking checkbox.click(); // checking
......
...@@ -447,23 +447,6 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do ...@@ -447,23 +447,6 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do
it_behaves_like 'handles errors thrown during async destroy', "Failed to remove webhooks" it_behaves_like 'handles errors thrown during async destroy', "Failed to remove webhooks"
end end
context 'when "destroy_webhooks_before_the_project" flag is disabled' do
before do
stub_feature_flags(destroy_webhooks_before_the_project: false)
end
it 'does not call WebHooks::DestroyService' do
expect(WebHooks::DestroyService).not_to receive(:new)
expect do
destroy_project(project, user)
end.to change(WebHook, :count).by(-2)
.and change(WebHookLog, :count).by(-1)
expect(another_project_web_hook.reload).to be
end
end
end end
context 'error while destroying', :sidekiq_inline do context 'error while destroying', :sidekiq_inline do
......
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