Commit 68e7f44b authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 20dc9f87 99a8861f
export const GRAPHQL_PAGE_SIZE = 30;
export const initialPaginationState = {
currentPage: 1,
prevPageCursor: '',
nextPageCursor: '',
first: GRAPHQL_PAGE_SIZE,
last: null,
};
query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) {
query getJobs(
$fullPath: ID!
$first: Int
$last: Int
$after: String
$before: String
$statuses: [CiJobStatus!]
) {
project(fullPath: $fullPath) {
jobs(first: 20, statuses: $statuses) {
jobs(after: $after, before: $before, first: $first, last: $last, statuses: $statuses) {
pageInfo {
endCursor
hasNextPage
......
<script>
import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
import { GlAlert, GlPagination, GlSkeletonLoader } from '@gitlab/ui';
import { __ } from '~/locale';
import { GRAPHQL_PAGE_SIZE, initialPaginationState } from './constants';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue';
import JobsTableEmptyState from './jobs_table_empty_state.vue';
......@@ -12,6 +13,7 @@ export default {
},
components: {
GlAlert,
GlPagination,
GlSkeletonLoader,
JobsTable,
JobsTableEmptyState,
......@@ -28,10 +30,18 @@ export default {
variables() {
return {
fullPath: this.fullPath,
first: this.pagination.first,
last: this.pagination.last,
after: this.pagination.nextPageCursor,
before: this.pagination.prevPageCursor,
};
},
update({ project }) {
return project?.jobs?.nodes || [];
update(data) {
const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data.project || {};
return {
list,
pageInfo,
};
},
error() {
this.hasError = true;
......@@ -40,10 +50,11 @@ export default {
},
data() {
return {
jobs: null,
jobs: {},
hasError: false,
isAlertDismissed: false,
scope: null,
pagination: initialPaginationState,
};
},
computed: {
......@@ -51,7 +62,16 @@ export default {
return this.hasError && !this.isAlertDismissed;
},
showEmptyState() {
return this.jobs.length === 0 && !this.scope;
return this.jobs.list.length === 0 && !this.scope;
},
prevPage() {
return Math.max(this.pagination.currentPage - 1, 0);
},
nextPage() {
return this.jobs.pageInfo?.hasNextPage ? this.pagination.currentPage + 1 : null;
},
showPaginationControls() {
return Boolean(this.prevPage || this.nextPage) && !this.$apollo.loading;
},
},
methods: {
......@@ -60,6 +80,24 @@ export default {
this.$apollo.queries.jobs.refetch({ statuses: scope });
},
handlePageChange(page) {
const { startCursor, endCursor } = this.jobs.pageInfo;
if (page > this.pagination.currentPage) {
this.pagination = {
...initialPaginationState,
nextPageCursor: endCursor,
currentPage: page,
};
} else {
this.pagination = {
last: GRAPHQL_PAGE_SIZE,
first: null,
prevPageCursor: startCursor,
currentPage: page,
};
}
},
},
};
</script>
......@@ -97,6 +135,16 @@ export default {
<jobs-table-empty-state v-else-if="showEmptyState" />
<jobs-table v-else :jobs="jobs" />
<jobs-table v-else :jobs="jobs.list" />
<gl-pagination
v-if="showPaginationControls"
:value="pagination.currentPage"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-mt-3"
@input="handlePageChange"
/>
</div>
</template>
......@@ -22,7 +22,7 @@ import { __ } from '~/locale';
import SmartInterval from '~/smart_interval';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MergeRequest from '../../../merge_request';
import { AUTO_MERGE_STRATEGIES, DANGER, INFO, WARNING } from '../../constants';
import { AUTO_MERGE_STRATEGIES, DANGER, CONFIRM, WARNING } from '../../constants';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import MergeRequestStore from '../../stores/mr_widget_store';
......@@ -227,11 +227,7 @@ export default {
return DANGER;
}
if (this.status === PIPELINE_PENDING_STATE) {
return INFO;
}
return PIPELINE_SUCCESS_STATE;
return CONFIRM;
},
iconClass() {
if (this.shouldRenderMergeTrainHelperText && !this.mr.preventMerge) {
......
......@@ -4,6 +4,7 @@ export const SUCCESS = 'success';
export const WARNING = 'warning';
export const DANGER = 'danger';
export const INFO = 'info';
export const CONFIRM = 'confirm';
export const MWPS_MERGE_STRATEGY = 'merge_when_pipeline_succeeds';
export const MTWPS_MERGE_STRATEGY = 'add_to_merge_train_when_pipeline_succeeds';
......
......@@ -2,13 +2,14 @@
width: 640px;
}
.escalation-policy-rules {
.rule-control {
width: 240px;
}
.rule-control {
width: 240px;
}
.rule-elapsed-minutes {
width: 56px;
}
.rule-elapsed-minutes {
width: 56px;
}
.rule-close-icon {
right: 1rem;
}
- display_issuable_type = issuable_display_type(@merge_request)
- button_action_class = @merge_request.closed? ? 'btn-default' : 'btn-warning btn-warning-secondary'
- button_class = "btn gl-button #{!@merge_request.closed? && 'js-draft-toggle-button'}"
- toggle_class = "btn gl-button dropdown-toggle"
.float-left.btn-group.gl-ml-3.gl-display-none.gl-md-display-flex
= link_to @merge_request.closed? ? reopen_issuable_path(@merge_request) : toggle_draft_merge_request_path(@merge_request), method: :put, class: "#{button_class} #{button_action_class}" do
= link_to @merge_request.closed? ? reopen_issuable_path(@merge_request) : toggle_draft_merge_request_path(@merge_request), method: :put, class: "#{button_class} btn-confirm-secondary" do
- if @merge_request.closed?
= _('Reopen')
= display_issuable_type
......@@ -12,9 +11,9 @@
= @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft')
- if !@merge_request.closed? || !issuable_author_is_current_user(@merge_request)
= button_tag type: 'button', class: "#{toggle_class} #{button_action_class}", data: { 'toggle' => 'dropdown' } do
= button_tag type: 'button', class: "#{toggle_class} btn-confirm-secondary btn-icon", data: { 'toggle' => 'dropdown' } do
%span.gl-sr-only= _('Toggle dropdown')
= sprite_icon "angle-down", size: 12
= sprite_icon "chevron-down", size: 12, css_class: "gl-button-icon"
%ul.dropdown-menu.dropdown-menu-right
- if @merge_request.open?
......
<script>
import { GlLink, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import createFlash from '~/flash';
import { s__, __ } from '~/locale';
import { defaultEscalationRule } from '../constants';
import getOncallSchedulesQuery from '../graphql/queries/get_oncall_schedules.query.graphql';
import EscalationRule from './escalation_rule.vue';
export const i18n = {
......@@ -19,6 +21,7 @@ export const i18n = {
},
},
addRule: s__('EscalationPolicies|+ Add an additional rule'),
failedLoadingSchedules: s__('EscalationPolicies|Failed to load oncall-schedules'),
};
export default {
......@@ -30,6 +33,7 @@ export default {
GlFormInput,
EscalationRule,
},
inject: ['projectPath'],
props: {
form: {
type: Object,
......@@ -42,12 +46,50 @@ export default {
},
data() {
return {
rules: [cloneDeep(defaultEscalationRule)],
schedules: [],
rules: [],
uid: 0,
};
},
apollo: {
schedules: {
query: getOncallSchedulesQuery,
variables() {
return {
projectPath: this.projectPath,
};
},
update(data) {
const nodes = data.project?.incidentManagementOncallSchedules?.nodes ?? [];
return nodes;
},
error(error) {
createFlash({ message: i18n.failedLoadingSchedules, captureError: true, error });
},
},
},
mounted() {
this.rules.push({ ...cloneDeep(defaultEscalationRule), key: this.getUid() });
},
methods: {
addRule() {
this.rules.push(cloneDeep(defaultEscalationRule));
this.rules.push({ ...cloneDeep(defaultEscalationRule), key: this.getUid() });
this.emitUpdate();
},
updateEscalationRules(index, rule) {
this.rules[index] = rule;
this.emitUpdate();
},
removeEscalationRule(index) {
this.rules.splice(index, 1);
this.emitUpdate();
},
emitUpdate() {
this.$emit('update-escalation-policy-form', { field: 'rules', value: this.rules });
},
getUid() {
this.uid += 1;
return this.uid;
},
},
};
......@@ -92,13 +134,17 @@ export default {
</gl-form-group>
</div>
<gl-form-group
class="escalation-policy-rules"
:label="$options.i18n.fields.rules.title"
label-size="sm"
:state="validationState.rules"
>
<escalation-rule v-for="(rule, index) in rules" :key="index" :rule="rule" />
<gl-form-group class="gl-mb-3" :label="$options.i18n.fields.rules.title" label-size="sm">
<escalation-rule
v-for="(rule, index) in rules"
:key="rule.key"
:rule="rule"
:index="index"
:schedules="schedules"
:is-valid="validationState.rules[index]"
@update-escalation-rule="updateEscalationRules"
@remove-escalation-rule="removeEscalationRule"
/>
</gl-form-group>
<gl-link @click="addRule">
<span>{{ $options.i18n.addRule }}</span>
......
<script>
import { GlModal } from '@gitlab/ui';
import { GlModal, GlAlert } from '@gitlab/ui';
import { set } from 'lodash';
import { s__, __ } from '~/locale';
import { addEscalationPolicyModalId } from '../constants';
import { isNameFieldValid } from '../utils';
import createEscalationPolicyMutation from '../graphql/mutations/create_escalation_policy.mutation.graphql';
import { isNameFieldValid, getRulesValidationState } from '../utils';
import AddEditEscalationPolicyForm from './add_edit_escalation_policy_form.vue';
export const i18n = {
......@@ -17,8 +18,10 @@ export default {
addEscalationPolicyModalId,
components: {
GlModal,
GlAlert,
AddEditEscalationPolicyForm,
},
inject: ['projectPath'],
props: {
escalationPolicy: {
type: Object,
......@@ -32,11 +35,13 @@ export default {
form: {
name: this.escalationPolicy.name,
description: this.escalationPolicy.description,
rules: [],
},
validationState: {
name: true,
rules: true,
rules: [],
},
error: null,
};
},
computed: {
......@@ -56,7 +61,15 @@ export default {
};
},
isFormValid() {
return Object.values(this.validationState).every(Boolean);
return this.validationState.name && this.validationState.rules.every(Boolean);
},
serializedData() {
const rules = this.form.rules.map(({ status, elapsedTimeSeconds, oncallScheduleIid }) => ({
status,
elapsedTimeSeconds,
oncallScheduleIid,
}));
return { ...this.form, rules };
},
},
methods: {
......@@ -64,10 +77,59 @@ export default {
set(this.form, field, value);
this.validateForm(field);
},
createEscalationPolicy() {
this.loading = true;
const { projectPath } = this;
this.$apollo
.mutate({
mutation: createEscalationPolicyMutation,
variables: {
input: {
projectPath,
...this.serializedData,
},
},
})
.then(
({
data: {
escalationPolicyCreate: {
errors: [error],
},
},
}) => {
if (error) {
throw error;
}
this.$refs.addUpdateEscalationPolicyModal.hide();
this.$emit('policyCreated');
this.clearForm();
},
)
.catch((error) => {
this.error = error;
})
.finally(() => {
this.loading = false;
});
},
validateForm(field) {
if (field === 'name') {
this.validationState.name = isNameFieldValid(this.form.name);
}
if (field === 'rules') {
this.validationState.rules = getRulesValidationState(this.form.rules);
}
},
hideErrorAlert() {
this.error = null;
},
clearForm() {
this.form = {
name: '',
description: '',
rules: [],
};
},
},
};
......@@ -75,12 +137,18 @@ export default {
<template>
<gl-modal
ref="addUpdateEscalationPolicyModal"
class="escalation-policy-modal"
:modal-id="$options.addEscalationPolicyModalId"
:title="$options.i18n.addEscalationPolicy"
:action-primary="actionsProps.primary"
:action-cancel="actionsProps.cancel"
@primary.prevent="createEscalationPolicy"
@cancel="clearForm"
>
<gl-alert v-if="error" variant="danger" class="gl-mt-n3 gl-mb-3" @dismiss="hideErrorAlert">
{{ error }}
</gl-alert>
<add-edit-escalation-policy-form
:validation-state="validationState"
:form="form"
......
<script>
import { GlFormInput, GlDropdown, GlDropdownItem, GlCard, GlSprintf } from '@gitlab/ui';
import {
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlCard,
GlIcon,
GlSprintf,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { ACTIONS, ALERT_STATUSES } from '../constants';
......@@ -9,6 +17,9 @@ export const i18n = {
condition: s__('EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} minutes'),
action: s__('EscalationPolicies|THEN %{doAction} %{schedule}'),
selectSchedule: s__('EscalationPolicies|Select schedule'),
validationMsg: s__(
'EscalationPolicies|A schedule is required for adding an escalation policy.',
),
},
},
};
......@@ -18,10 +29,12 @@ export default {
ALERT_STATUSES,
ACTIONS,
components: {
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlCard,
GlIcon,
GlSprintf,
},
props: {
......@@ -34,65 +47,138 @@ export default {
required: false,
default: () => [],
},
index: {
type: Number,
required: true,
},
isValid: {
type: Boolean,
required: false,
default: true,
},
},
data() {
const { status, elapsedTimeSeconds, action, oncallScheduleIid } = this.rule;
return {
status,
elapsedTimeSeconds,
action,
oncallScheduleIid,
};
},
computed: {
scheduleDropdownTitle() {
return this.oncallScheduleIid
? this.schedules.find(({ iid }) => iid === this.oncallScheduleIid)?.name
: i18n.fields.rules.selectSchedule;
},
},
methods: {
setOncallSchedule({ iid }) {
this.oncallScheduleIid = this.oncallScheduleIid === iid ? null : iid;
this.emitUpdate();
},
setStatus(status) {
this.status = status;
this.emitUpdate();
},
emitUpdate() {
this.$emit('update-escalation-rule', this.index, {
oncallScheduleIid: parseInt(this.oncallScheduleIid, 10),
action: this.action,
status: this.status,
elapsedTimeSeconds: parseInt(this.elapsedTimeSeconds, 10),
});
},
},
};
</script>
<template>
<gl-card class="gl-border-gray-400 gl-bg-gray-10 gl-mb-3">
<div class="gl-display-flex gl-align-items-center">
<gl-sprintf :message="$options.i18n.fields.rules.condition">
<template #alertStatus>
<gl-dropdown
class="rule-control gl-mx-3"
:text="$options.ALERT_STATUSES[rule.status]"
data-testid="alert-status-dropdown"
>
<gl-dropdown-item
v-for="(label, status) in $options.ALERT_STATUSES"
:key="status"
:is-checked="rule.status === status"
is-check-item
<gl-card class="gl-border-gray-400 gl-bg-gray-10 gl-mb-3 gl-relative">
<gl-icon
v-if="index !== 0"
name="close"
class="gl-absolute rule-close-icon"
@click="$emit('remove-escalation-rule', index)"
/>
<gl-form-group
:invalid-feedback="$options.i18n.fields.rules.validationMsg"
:state="isValid"
class="gl-mb-0"
>
<div class="gl-display-flex gl-align-items-center">
<gl-sprintf :message="$options.i18n.fields.rules.condition">
<template #alertStatus>
<gl-dropdown
class="rule-control gl-mx-3"
:text="$options.ALERT_STATUSES[status]"
data-testid="alert-status-dropdown"
>
<gl-dropdown-item
v-for="(label, alertStatus) in $options.ALERT_STATUSES"
:key="alertStatus"
:is-checked="status === alertStatus"
is-check-item
@click="setStatus(alertStatus)"
>
{{ label }}
</gl-dropdown-item>
</gl-dropdown>
</template>
<template #minutes>
<gl-form-input
v-model="elapsedTimeSeconds"
class="gl-mx-3 gl-inset-border-1-gray-200! rule-elapsed-minutes"
type="number"
min="0"
@change="emitUpdate"
/>
</template>
</gl-sprintf>
</div>
<div class="gl-display-flex gl-align-items-center gl-mt-3">
<gl-sprintf :message="$options.i18n.fields.rules.action">
<template #doAction>
<gl-dropdown
class="rule-control gl-mx-3"
:text="$options.ACTIONS[rule.action]"
data-testid="action-dropdown"
>
{{ label }}
</gl-dropdown-item>
</gl-dropdown>
</template>
<template #minutes>
<gl-form-input class="gl-mx-3 rule-elapsed-minutes" :value="0" />
</template>
</gl-sprintf>
</div>
<div class="gl-display-flex gl-align-items-center gl-mt-3">
<gl-sprintf :message="$options.i18n.fields.rules.action">
<template #doAction>
<gl-dropdown
class="rule-control gl-mx-3"
:text="$options.ACTIONS[rule.action]"
data-testid="action-dropdown"
>
<gl-dropdown-item
v-for="(label, action) in $options.ACTIONS"
:key="action"
:is-checked="rule.action === action"
is-check-item
<gl-dropdown-item
v-for="(label, ruleAction) in $options.ACTIONS"
:key="ruleAction"
:is-checked="rule.action === ruleAction"
is-check-item
>
{{ label }}
</gl-dropdown-item>
</gl-dropdown>
</template>
<template #schedule>
<gl-dropdown
class="rule-control"
:text="scheduleDropdownTitle"
data-testid="schedules-dropdown"
>
{{ label }}
</gl-dropdown-item>
</gl-dropdown>
</template>
<template #schedule>
<gl-dropdown
class="rule-control gl-mx-3"
:text="$options.i18n.fields.rules.selectSchedule"
data-testid="schedules-dropdown"
>
<gl-dropdown-item v-for="schedule in schedules" :key="schedule.id" is-check-item>
{{ schedule.name }}
</gl-dropdown-item>
</gl-dropdown>
</template>
</gl-sprintf>
</div>
<template #button-text>
<span :class="{ 'gl-text-gray-400': !oncallScheduleIid }">
{{ scheduleDropdownTitle }}
</span>
</template>
<gl-dropdown-item
v-for="schedule in schedules"
:key="schedule.iid"
:is-checked="schedule.iid === oncallScheduleIid"
is-check-item
@click="setOncallSchedule(schedule)"
>
{{ schedule.name }}
</gl-dropdown-item>
</gl-dropdown>
</template>
</gl-sprintf>
</div>
</gl-form-group>
</gl-card>
</template>
......@@ -13,10 +13,7 @@ export const defaultEscalationRule = {
status: 'ACKNOWLEDGED',
elapsedTimeSeconds: 0,
action: 'EMAIL_ONCALL_SCHEDULE_USER',
oncallSchedule: {
iid: null,
name: null,
},
oncallScheduleIid: null,
};
export const addEscalationPolicyModalId = 'addEscalationPolicyModal';
mutation escalationPolicyCreate($input: EscalationPolicyCreateInput!) {
escalationPolicyCreate(input: $input) {
escalationPolicy {
id
name
description
rules {
status
elapsedTimeSeconds
oncallSchedule {
iid
name
}
}
}
errors
}
}
query getOncallSchedules($projectPath: ID!) {
project(fullPath: $projectPath) {
incidentManagementOncallSchedules {
nodes {
iid
name
}
}
}
}
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import EscalationPoliciesWrapper from './components/escalation_policies_wrapper.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
cacheConfig: {
dataIdFromObject: (object) => {
// eslint-disable-next-line no-underscore-dangle
if (object.__typename === 'IncidentManagementOncallSchedule') {
return object.iid;
}
return defaultDataIdFromObject(object);
},
},
},
),
});
export default () => {
const el = document.querySelector('.js-escalation-policies');
......@@ -10,6 +32,7 @@ export default () => {
return new Vue({
el,
apolloProvider,
provide: {
projectPath,
emptyEscalationPoliciesSvgPath,
......
......@@ -7,3 +7,13 @@
export const isNameFieldValid = (name) => {
return Boolean(name?.length);
};
/**
* Returns an array of booleans - validation state for each rule
* @param {Array} rules
*
* @returns {Array}
*/
export const getRulesValidationState = (rules) => {
return rules.map((rule) => Boolean(rule.oncallScheduleIid));
};
......@@ -2,9 +2,10 @@
module IncidentManagement
module EscalationPolicyHelper
def escalation_policy_data
def escalation_policy_data(project)
{
'empty_escalation_policies_svg_path' => image_path('illustrations/empty-state/empty-escalation.svg')
'project-path' => project.full_path,
'empty_escalation_policies_svg_path' => image_path('illustrations/empty-state/empty-escalation.svg')
}
end
end
......
- page_title _('Escalation policies')
- add_page_specific_style 'page_bundles/escalation_policies'
.js-escalation-policies{ data: escalation_policy_data }
.js-escalation-policies{ data: escalation_policy_data(@project) }
......@@ -38,7 +38,7 @@ module EE
end
::Sidebars::MenuItem.new(
title: _('Escalation policies'),
title: _('Escalation Policies'),
link: project_incident_management_escalation_policies_path(context.project),
active_routes: { controller: :escalation_policies },
item_id: :escalation_policies
......
......@@ -35,7 +35,7 @@ RSpec.describe 'Merge requests > User merges immediately', :js do
context 'when the merge request is on the merge train' do
def merge_button
find('.mr-widget-body .accept-merge-request.btn-info')
find('.mr-widget-body .accept-merge-request.btn-confirm')
end
def open_warning_dialog
......
......@@ -6,7 +6,6 @@ import AddEscalationPolicyForm, {
import EscalationRule from 'ee/escalation_policies/components/escalation_rule.vue';
import { defaultEscalationRule } from 'ee/escalation_policies/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import mockPolicy from './mocks/mockPolicy.json';
describe('AddEscalationPolicyForm', () => {
......@@ -23,6 +22,7 @@ describe('AddEscalationPolicyForm', () => {
},
validationState: {
name: true,
rules: [],
},
...props,
},
......@@ -46,13 +46,15 @@ describe('AddEscalationPolicyForm', () => {
const findAddRuleLink = () => wrapper.findComponent(GlLink);
describe('Escalation policy form validation', () => {
it('should show feedback for an invalid name input validation state', async () => {
it('should set correct validation state for validated controls', async () => {
createComponent({
props: {
validationState: { name: false },
validationState: { name: false, rules: [false] },
},
});
expect(findPolicyName().attributes('state')).toBeFalsy();
await wrapper.vm.$nextTick();
expect(findPolicyName().attributes('state')).toBeUndefined();
expect(findRules().at(0).attributes('is-valid')).toBeUndefined();
});
});
......@@ -72,7 +74,40 @@ describe('AddEscalationPolicyForm', () => {
await wrapper.vm.$nextTick();
const rules = findRules();
expect(rules.length).toBe(2);
expect(rules.at(1).props('rule')).toEqual(defaultEscalationRule);
expect(rules.at(1).props('rule')).toMatchObject(defaultEscalationRule);
});
it('should emit updates when rule is added', async () => {
findAddRuleLink().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(wrapper.emitted('update-escalation-policy-form')[0]).toMatchObject([
{
field: 'rules',
value: [
expect.objectContaining(defaultEscalationRule),
expect.objectContaining(defaultEscalationRule),
],
},
]);
});
it('on rule update emitted should update rules array and emit updates up', () => {
const updatedRule = {
status: 'TRIGGERED',
elapsedTimeSeconds: 30,
oncallScheduleIid: 2,
};
findRules().at(0).vm.$emit('update-escalation-rule', 0, updatedRule);
expect(wrapper.emitted('update-escalation-policy-form')[0]).toEqual([
{ field: 'rules', value: [updatedRule] },
]);
});
it('on rule removal emitted should update rules array and emit updates up', () => {
findRules().at(0).vm.$emit('remove-escalation-rule', 0);
expect(wrapper.emitted('update-escalation-policy-form')[0]).toEqual([
{ field: 'rules', value: [] },
]);
});
});
});
import { GlModal } from '@gitlab/ui';
import { GlModal, GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import AddEscalationPolicyForm from 'ee/escalation_policies/components/add_edit_escalation_policy_form.vue';
import AddEscalationPolicyModal, {
i18n,
} from 'ee/escalation_policies/components/add_edit_escalation_policy_modal.vue';
import waitForPromises from 'helpers/wait_for_promises';
import mockPolicy from './mocks/mockPolicy.json';
describe('AddEscalationPolicyModal', () => {
let wrapper;
const projectPath = 'group/project';
const mockHideModal = jest.fn();
const mutate = jest.fn();
const createComponent = ({ escalationPolicy, data } = {}) => {
wrapper = shallowMount(AddEscalationPolicyModal, {
data() {
return {
form: mockPolicy,
...data,
};
},
......@@ -22,7 +27,14 @@ describe('AddEscalationPolicyModal', () => {
provide: {
projectPath,
},
mocks: {
$apollo: {
mutate,
},
},
});
wrapper.vm.$refs.addUpdateEscalationPolicyModal.hide = mockHideModal;
};
beforeEach(() => {
createComponent();
......@@ -34,6 +46,7 @@ describe('AddEscalationPolicyModal', () => {
const findModal = () => wrapper.findComponent(GlModal);
const findEscalationPolicyForm = () => wrapper.findComponent(AddEscalationPolicyForm);
const findAlert = () => wrapper.findComponent(GlAlert);
describe('renders create modal with the correct information', () => {
it('renders modal title', () => {
......@@ -43,6 +56,49 @@ describe('AddEscalationPolicyModal', () => {
it('renders the form inside the modal', () => {
expect(findEscalationPolicyForm().exists()).toBe(true);
});
it('makes a request with form data to create an escalation policy', () => {
mutate.mockResolvedValueOnce({});
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
input: {
projectPath,
...mockPolicy,
},
},
}),
);
});
it('hides the modal on successful policy creation', async () => {
mutate.mockResolvedValueOnce({ data: { escalationPolicyCreate: { errors: [] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
expect(mockHideModal).toHaveBeenCalled();
});
it("doesn't hide a modal and shows error alert on creation failure", async () => {
const error = 'some error';
mutate.mockResolvedValueOnce({ data: { escalationPolicyCreate: { errors: [error] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
const alert = findAlert();
expect(mockHideModal).not.toHaveBeenCalled();
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain(error);
});
it('clears the form on modal close', () => {
expect(wrapper.vm.form).toEqual(mockPolicy);
findModal().vm.$emit('cancel', { preventDefault: jest.fn() });
expect(wrapper.vm.form).toEqual({
name: '',
description: '',
rules: [],
});
});
});
describe('modal buttons', () => {
......
import { GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { GlDropdownItem, GlFormGroup, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import EscalationRule from 'ee/escalation_policies/components/escalation_rule.vue';
......@@ -19,6 +19,8 @@ describe('EscalationRule', () => {
propsData: {
rule: cloneDeep(defaultEscalationRule),
schedules: mockSchedules,
index: 0,
isValid: false,
...props,
},
stubs: {
......@@ -45,6 +47,8 @@ describe('EscalationRule', () => {
const findSchedulesDropdown = () => wrapper.findByTestId('schedules-dropdown');
const findSchedulesDropdownOptions = () => findSchedulesDropdown().findAll(GlDropdownItem);
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
describe('Status dropdown', () => {
it('should have correct alert status options', () => {
expect(findStatusDropdownOptions().wrappers.map((w) => w.text())).toStrictEqual(
......@@ -76,4 +80,19 @@ describe('EscalationRule', () => {
);
});
});
describe('Validation', () => {
it.each`
isValid | state
${true} | ${'true'}
${false} | ${undefined}
`('when $isValid sets from group state to $state', ({ isValid, state }) => {
createComponent({
props: {
isValid,
},
});
expect(findFormGroup().attributes('state')).toBe(state);
});
});
});
......@@ -3,11 +3,14 @@
require 'spec_helper'
RSpec.describe IncidentManagement::EscalationPolicyHelper do
let_it_be(:project) { create(:project) }
describe '#escalation_policy_data' do
subject(:data) { helper.escalation_policy_data }
subject(:data) { helper.escalation_policy_data(project) }
it 'returns scalation policies data' do
is_expected.to eq(
'project-path' => project.full_path,
'empty_escalation_policies_svg_path' => helper.image_path('illustrations/empty-state/empty-escalation.svg')
)
end
......
......@@ -26,7 +26,7 @@ RSpec.describe Sidebars::Projects::Menus::MonitorMenu do
end
end
describe 'Escalation policies' do
describe 'Escalation Policies' do
let(:item_id) { :escalation_policies }
before do
......
......@@ -275,7 +275,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
end
end
describe 'Escalation policies' do
describe 'Escalation Policies' do
before do
allow(view).to receive(:current_user).and_return(user)
stub_licensed_features(oncall_schedules: true, escalation_policies: true)
......@@ -284,7 +284,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
it 'has a link to the escalation policies page' do
render
expect(rendered).to have_link('Escalation policies', href: project_incident_management_escalation_policies_path(project))
expect(rendered).to have_link('Escalation Policies', href: project_incident_management_escalation_policies_path(project))
end
describe 'when the user does not have access' do
......@@ -293,7 +293,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
it 'does not have a link to the escalation policies page' do
render
expect(rendered).not_to have_link('Escalation policies')
expect(rendered).not_to have_link('Escalation Policies')
end
end
end
......
......@@ -13072,6 +13072,9 @@ msgstr ""
msgid "Errors:"
msgstr ""
msgid "Escalation Policies"
msgstr ""
msgid "Escalation policies"
msgstr ""
......@@ -13081,6 +13084,9 @@ msgstr ""
msgid "EscalationPolicies|+ Add an additional rule"
msgstr ""
msgid "EscalationPolicies|A schedule is required for adding an escalation policy."
msgstr ""
msgid "EscalationPolicies|Add an escalation policy"
msgstr ""
......@@ -13099,6 +13105,9 @@ msgstr ""
msgid "EscalationPolicies|Escalation rules"
msgstr ""
msgid "EscalationPolicies|Failed to load oncall-schedules"
msgstr ""
msgid "EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} minutes"
msgstr ""
......
......@@ -36,7 +36,7 @@ RSpec.describe 'Merge requests > User merges immediately', :js do
Sidekiq::Testing.fake! do
click_button 'Merge immediately'
expect(find('.accept-merge-request.btn-info')).to have_content('Merge in progress')
expect(find('.accept-merge-request.btn-confirm')).to have_content('Merge in progress')
wait_for_requests
end
......
......@@ -274,10 +274,10 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
visit project_merge_request_path(project, merge_request)
end
it 'has info button when MWBS button' do
it 'has confirm button when MWBS button' do
# Wait for the `ci_status` and `merge_check` requests
wait_for_requests
expect(page).to have_selector('.accept-merge-request.btn-info')
expect(page).to have_selector('.accept-merge-request.btn-confirm')
end
end
......
import { GlSkeletonLoader, GlAlert, GlEmptyState } from '@gitlab/ui';
import { GlSkeletonLoader, GlAlert, GlEmptyState, GlPagination } from '@gitlab/ui';
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
......@@ -25,6 +25,10 @@ describe('Job table app', () => {
const findTabs = () => wrapper.findComponent(JobsTableTabs);
const findAlert = () => wrapper.findComponent(GlAlert);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findPagination = () => wrapper.findComponent(GlPagination);
const findPrevious = () => findPagination().findAll('.page-item').at(0);
const findNext = () => findPagination().findAll('.page-item').at(1);
const createMockApolloProvider = (handler) => {
const requestHandlers = [[getJobsQuery, handler]];
......@@ -32,8 +36,17 @@ describe('Job table app', () => {
return createMockApollo(requestHandlers);
};
const createComponent = (handler = successHandler, mountFn = shallowMount) => {
const createComponent = ({
handler = successHandler,
mountFn = shallowMount,
data = {},
} = {}) => {
wrapper = mountFn(JobsTableApp, {
data() {
return {
...data,
};
},
provide: {
projectPath,
},
......@@ -52,6 +65,7 @@ describe('Job table app', () => {
expect(findSkeletonLoader().exists()).toBe(true);
expect(findTable().exists()).toBe(false);
expect(findPagination().exists()).toBe(false);
});
});
......@@ -65,9 +79,10 @@ describe('Job table app', () => {
it('should display the jobs table with data', () => {
expect(findTable().exists()).toBe(true);
expect(findSkeletonLoader().exists()).toBe(false);
expect(findPagination().exists()).toBe(true);
});
it('should retfech jobs query on fetchJobsByStatus event', async () => {
it('should refetch jobs query on fetchJobsByStatus event', async () => {
jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
......@@ -78,9 +93,72 @@ describe('Job table app', () => {
});
});
describe('pagination', () => {
it('should disable the next page button on the last page', async () => {
createComponent({
handler: successHandler,
mountFn: mount,
data: {
pagination: {
currentPage: 3,
},
jobs: {
pageInfo: {
hasPreviousPage: true,
startCursor: 'abc',
endCursor: 'bcd',
},
},
},
});
await wrapper.vm.$nextTick();
wrapper.setData({
jobs: {
pageInfo: {
hasNextPage: false,
},
},
});
await wrapper.vm.$nextTick();
expect(findPrevious().exists()).toBe(true);
expect(findNext().exists()).toBe(true);
expect(findNext().classes('disabled')).toBe(true);
});
it('should disable the previous page button on the first page', async () => {
createComponent({
handler: successHandler,
mountFn: mount,
data: {
pagination: {
currentPage: 1,
},
jobs: {
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'abc',
endCursor: 'bcd',
},
},
},
});
await wrapper.vm.$nextTick();
expect(findPrevious().exists()).toBe(true);
expect(findPrevious().classes('disabled')).toBe(true);
expect(findNext().exists()).toBe(true);
});
});
describe('error state', () => {
it('should show an alert if there is an error fetching the data', async () => {
createComponent(failedHandler);
createComponent({ handler: failedHandler });
await waitForPromises();
......@@ -90,7 +168,7 @@ describe('Job table app', () => {
describe('empty state', () => {
it('should display empty state if there are no jobs and tab scope is null', async () => {
createComponent(emptyHandler, mount);
createComponent({ handler: emptyHandler, mountFn: mount });
await waitForPromises();
......@@ -99,7 +177,7 @@ describe('Job table app', () => {
});
it('should not display empty state if there are jobs and tab scope is not null', async () => {
createComponent(successHandler, mount);
createComponent({ handler: successHandler, mountFn: mount });
await waitForPromises();
......
......@@ -123,26 +123,26 @@ describe('ReadyToMerge', () => {
});
describe('mergeButtonVariant', () => {
it('defaults to success class', () => {
it('defaults to confirm class', () => {
createComponent({
mr: { availableAutoMergeStrategies: [] },
});
expect(wrapper.vm.mergeButtonVariant).toEqual('success');
expect(wrapper.vm.mergeButtonVariant).toEqual('confirm');
});
it('returns success class for success status', () => {
it('returns confirm class for success status', () => {
createComponent({
mr: { availableAutoMergeStrategies: [], pipeline: true },
});
expect(wrapper.vm.mergeButtonVariant).toEqual('success');
expect(wrapper.vm.mergeButtonVariant).toEqual('confirm');
});
it('returns info class for pending status', () => {
it('returns confirm class for pending status', () => {
createComponent();
expect(wrapper.vm.mergeButtonVariant).toEqual('info');
expect(wrapper.vm.mergeButtonVariant).toEqual('confirm');
});
it('returns danger class for failed status', () => {
......
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