Commit f10a3654 authored by Mark Florian's avatar Mark Florian

Merge branch '262857-validation' into 'master'

Feat(oncallschedules): add form validation - schedule / rotations

See merge request gitlab-org/gitlab!50819
parents 173bc9f9 18d0b537
......@@ -52,12 +52,8 @@ export default {
type: Object,
required: true,
},
isNameInvalid: {
type: Boolean,
required: true,
},
isTimezoneInvalid: {
type: Boolean,
validationState: {
type: Object,
required: true,
},
schedule: {
......@@ -69,6 +65,7 @@ export default {
data() {
return {
tzSearchTerm: '',
selectedDropdownTimezone: null,
};
},
computed: {
......@@ -94,6 +91,9 @@ export default {
isTimezoneSelected(tz) {
return isEqual(tz, this.form.timezone);
},
setTimezone(timezone) {
this.selectedDropdownTimezone = timezone;
},
},
};
</script>
......@@ -105,12 +105,13 @@ export default {
:invalid-feedback="$options.i18n.fields.name.validation.empty"
label-size="sm"
label-for="schedule-name"
:state="validationState.name"
requried
>
<gl-form-input
id="schedule-name"
:value="form.name"
:state="!isNameInvalid"
@input="$emit('update-schedule-form', { type: 'name', value: $event })"
@blur="$emit('update-schedule-form', { type: 'name', value: $event.target.value })"
/>
</gl-form-group>
......@@ -122,7 +123,7 @@ export default {
<gl-form-input
id="schedule-description"
:value="form.description"
@input="$emit('update-schedule-form', { type: 'description', value: $event })"
@blur="$emit('update-schedule-form', { type: 'description', value: $event.target.value })"
/>
</gl-form-group>
......@@ -131,15 +132,17 @@ export default {
label-size="sm"
label-for="schedule-timezone"
:description="$options.i18n.fields.timezone.description"
:state="!isTimezoneInvalid"
:state="validationState.timezone"
:invalid-feedback="$options.i18n.fields.timezone.validation.empty"
requried
>
<gl-dropdown
id="schedule-timezone"
:text="selectedTimezone"
class="timezone-dropdown gl-w-full"
:header-text="$options.i18n.selectTimezone"
:class="{ 'invalid-dropdown': isTimezoneInvalid }"
:class="{ 'invalid-dropdown': !validationState.timezone }"
@hide="$emit('update-schedule-form', { type: 'timezone', value: selectedDropdownTimezone })"
>
<gl-search-box-by-type v-model.trim="tzSearchTerm" />
<gl-dropdown-item
......@@ -147,7 +150,7 @@ export default {
:key="getFormattedTimezone(tz)"
:is-checked="isTimezoneSelected(tz)"
is-check-item
@click="$emit('update-schedule-form', { type: 'timezone', value: tz })"
@click="setTimezone(tz)"
>
<span class="gl-white-space-nowrap"> {{ getFormattedTimezone(tz) }}</span>
</gl-dropdown-item>
......
......@@ -7,6 +7,7 @@ import createOncallScheduleMutation from '../graphql/mutations/create_oncall_sch
import updateOncallScheduleMutation from '../graphql/mutations/update_oncall_schedule.mutation.graphql';
import AddEditScheduleForm from './add_edit_schedule_form.vue';
import { updateStoreOnScheduleCreate, updateStoreAfterScheduleEdit } from '../utils/cache_updates';
import { isNameFieldValid } from '../utils/common_utils';
export const i18n = {
cancel: __('Cancel'),
......@@ -48,7 +49,11 @@ export default {
description: this.schedule?.description,
timezone: this.timezones.find(({ identifier }) => this.schedule?.timezone === identifier),
},
error: null,
error: '',
validationState: {
name: true,
timezone: true,
},
};
},
computed: {
......@@ -59,7 +64,7 @@ export default {
attributes: [
{ variant: 'info' },
{ loading: this.loading },
{ disabled: this.isFormInvalid },
{ disabled: !this.isFormValid },
],
},
cancel: {
......@@ -67,14 +72,8 @@ export default {
},
};
},
isNameInvalid() {
return !this.form.name?.length;
},
isTimezoneInvalid() {
return isEmpty(this.form.timezone);
},
isFormInvalid() {
return this.isNameInvalid || this.isTimezoneInvalid;
isFormValid() {
return Object.values(this.validationState).every(Boolean);
},
editScheduleVariables() {
return {
......@@ -169,10 +168,18 @@ export default {
});
},
hideErrorAlert() {
this.error = null;
this.error = '';
},
updateScheduleForm({ type, value }) {
this.form[type] = value;
this.validateForm(type);
},
validateForm(key) {
if (key === 'name') {
this.validationState.name = isNameFieldValid(this.form.name);
} else if (key === 'timezone') {
this.validationState.timezone = !isEmpty(this.form.timezone);
}
},
},
};
......@@ -192,8 +199,7 @@ export default {
{{ errorMsg }}
</gl-alert>
<add-edit-schedule-form
:is-name-invalid="isNameInvalid"
:is-timezone-invalid="isTimezoneInvalid"
:validation-state="validationState"
:form="form"
:schedule="schedule"
@update-schedule-form="updateScheduleForm"
......
......@@ -143,7 +143,6 @@ export default {
</gl-card>
</gl-card>
<delete-schedule-modal :schedule="schedule" :modal-id="$options.deleteScheduleModalId" />
<edit-schedule-modal :schedule="schedule" :modal-id="$options.editScheduleModalId" />
<edit-schedule-modal
:schedule="schedule"
:modal-id="$options.editScheduleModalId"
......
......@@ -10,13 +10,13 @@ import {
GlAvatar,
GlAvatarLabeled,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import {
LENGTH_ENUM,
HOURS_IN_DAY,
CHEVRON_SKIPPING_SHADE_ENUM,
CHEVRON_SKIPPING_PALETTE_ENUM,
} from '../../../constants';
} from 'ee/oncall_schedules/constants';
import { s__, __ } from '~/locale';
import { format24HourTimeStringFromInt } from '~/lib/utils/datetime_utility';
export const i18n = {
......@@ -65,16 +65,8 @@ export default {
type: Boolean,
required: true,
},
rotationNameIsValid: {
type: Boolean,
required: true,
},
rotationParticipantsAreValid: {
type: Boolean,
required: true,
},
rotationStartsAtIsValid: {
type: Boolean,
validationState: {
type: Object,
required: true,
},
participants: {
......@@ -105,11 +97,11 @@ export default {
label-size="sm"
label-for="rotation-name"
:invalid-feedback="$options.i18n.fields.name.error"
:state="rotationNameIsValid"
:state="validationState.name"
>
<gl-form-input
id="rotation-name"
@input="$emit('update-rotation-form', { type: 'name', value: $event })"
@blur="$emit('update-rotation-form', { type: 'name', value: $event.target.value })"
/>
</gl-form-group>
......@@ -118,7 +110,7 @@ export default {
label-size="sm"
label-for="rotation-participants"
:invalid-feedback="$options.i18n.fields.participants.error"
:state="rotationParticipantsAreValid"
:state="validationState.participants"
>
<gl-token-selector
v-model="participantsArr"
......@@ -126,6 +118,7 @@ export default {
:loading="isLoading"
container-class="gl-h-13! gl-overflow-y-auto"
@text-input="$emit('filter-participants', $event)"
@blur="$emit('update-rotation-form', { type: 'participants', value: participantsArr })"
@input="$emit('update-rotation-form', { type: 'participants', value: participantsArr })"
>
<template #token-content="{ token }">
......@@ -176,13 +169,24 @@ export default {
label-size="sm"
label-for="rotation-time"
:invalid-feedback="$options.i18n.fields.startsAt.error"
:state="rotationStartsAtIsValid"
:state="validationState.startsAt"
>
<div class="gl-display-flex gl-align-items-center">
<gl-datepicker
class="gl-mr-3"
@input="$emit('update-rotation-form', { type: 'startsAt.date', value: $event })"
/>
>
<template #default="{ formattedDate }">
<gl-form-input
class="gl-w-full"
:value="formattedDate"
:placeholder="__(`YYYY-MM-DD`)"
@blur="
$emit('update-rotation-form', { type: 'startsAt.date', value: $event.target.value })
"
/>
</template>
</gl-datepicker>
<span> {{ __('at') }} </span>
<gl-dropdown
id="rotation-time"
......
<script>
import { GlModal, GlAlert } from '@gitlab/ui';
import { set } from 'lodash';
import getOncallSchedulesQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql';
import createOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/mutations/create_oncall_schedule_rotation.mutation.graphql';
import updateOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql';
import { LENGTH_ENUM } from 'ee/oncall_schedules/constants';
import {
updateStoreAfterRotationAdd,
updateStoreAfterRotationEdit,
} from 'ee/oncall_schedules/utils/cache_updates';
import { isNameFieldValid } from 'ee/oncall_schedules/utils/common_utils';
import { s__, __ } from '~/locale';
import createFlash, { FLASH_TYPES } from '~/flash';
import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import getOncallSchedulesQuery from '../../../graphql/queries/get_oncall_schedules.query.graphql';
import createOncallScheduleRotationMutation from '../../../graphql/mutations/create_oncall_schedule_rotation.mutation.graphql';
import updateOncallScheduleRotationMutation from '../../../graphql/mutations/update_oncall_schedule_rotation.mutation.graphql';
import { LENGTH_ENUM } from '../../../constants';
import AddEditRotationForm from './add_edit_rotation_form.vue';
import {
updateStoreAfterRotationAdd,
updateStoreAfterRotationEdit,
} from '../../../utils/cache_updates';
import { format24HourTimeStringFromInt } from '~/lib/utils/datetime_utility';
export const i18n = {
......@@ -81,6 +82,11 @@ export default {
},
},
error: '',
validationState: {
name: true,
participants: true,
startsAt: true,
},
};
},
computed: {
......@@ -99,15 +105,6 @@ export default {
},
};
},
rotationNameIsValid() {
return this.form.name !== '';
},
rotationParticipantsAreValid() {
return this.form.participants.length > 0;
},
rotationStartsAtIsValid() {
return Boolean(this.form.startsAt.date);
},
rotationVariables() {
return {
projectPath: this.projectPath,
......@@ -130,11 +127,7 @@ export default {
};
},
isFormValid() {
return (
this.rotationNameIsValid &&
this.rotationParticipantsAreValid &&
this.rotationStartsAtIsValid
);
return Object.values(this.validationState).every(Boolean);
},
isLoading() {
return this.loading || this.$apollo.queries.participants.loading;
......@@ -226,10 +219,20 @@ export default {
},
updateRotationForm({ type, value }) {
set(this.form, type, value);
this.validateForm(type);
},
filterParticipants(query) {
this.ptSearchTerm = query;
},
validateForm(key) {
if (key === 'name') {
this.validationState.name = isNameFieldValid(this.form.name);
} else if (key === 'participants') {
this.validationState.participants = this.form.participants.length > 0;
} else if (key === 'startsAt.date') {
this.validationState.startsAt = Boolean(this.form.startsAt.date);
}
},
},
};
</script>
......@@ -248,9 +251,7 @@ export default {
{{ error || $options.i18n.errorMsg }}
</gl-alert>
<add-edit-rotation-form
:rotation-name-is-valid="rotationNameIsValid"
:rotation-participants-are-valid="rotationParticipantsAreValid"
:rotation-starts-at-is-valid="rotationStartsAtIsValid"
:validation-state="validationState"
:form="form"
:schedule="schedule"
:participants="participants"
......
<script>
import { GlToken, GlAvatarLabeled, GlPopover } from '@gitlab/ui';
import { assigneeScheduleDateStart } from 'ee/oncall_schedules/utils/common_utils';
import { __, sprintf } from '~/locale';
import { assigneeScheduleDateStart } from '../../../utils/common_utils';
export default {
components: {
......
......@@ -30,3 +30,14 @@ export const getFormattedTimezone = (tz) => {
export const assigneeScheduleDateStart = (startDate, daysToAdd) => {
return getDateInFuture(startDate, daysToAdd);
};
/**
* Returns `true` for non-empty string, otherwise returns `false`
*
* @param {String} startDate
*
* @returns {Boolean}
*/
export const isNameFieldValid = (nameField) => {
return Boolean(nameField?.length);
};
......@@ -9,10 +9,11 @@ exports[`AddEditScheduleForm renders modal layout 1`] = `
label="Name"
label-for="schedule-name"
label-size="sm"
requried=""
state="true"
>
<gl-form-input-stub
id="schedule-name"
state="true"
value="Test schedule"
/>
</gl-form-group-stub>
......@@ -34,6 +35,7 @@ exports[`AddEditScheduleForm renders modal layout 1`] = `
label="Timezone"
label-for="schedule-timezone"
label-size="sm"
requried=""
state="true"
>
<gl-dropdown-stub
......
import { shallowMount } from '@vue/test-utils';
import { GlSearchBoxByType, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { GlSearchBoxByType, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
import AddEditScheduleForm, {
i18n,
} from 'ee/oncall_schedules/components/add_edit_schedule_form.vue';
......@@ -22,8 +22,10 @@ describe('AddEditScheduleForm', () => {
description: mockSchedule.description,
timezone: mockTimezones[0],
},
isNameInvalid: false,
isTimezoneInvalid: false,
validationState: {
name: true,
timezone: true,
},
schedule: mockSchedule,
...props,
},
......@@ -36,9 +38,6 @@ describe('AddEditScheduleForm', () => {
mutate,
},
},
stubs: {
GlFormGroup: false,
},
});
};
......@@ -52,13 +51,25 @@ describe('AddEditScheduleForm', () => {
});
const findTimezoneDropdown = () => wrapper.find(GlDropdown);
const findDropdownOptions = () => wrapper.findAll(GlDropdownItem);
const findDropdownOptions = () => wrapper.findAllComponents(GlDropdownItem);
const findTimezoneSearchBox = () => wrapper.find(GlSearchBoxByType);
const findScheduleName = () => wrapper.find(GlFormGroup);
it('renders modal layout', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('Schedule form validation', () => {
it('should show feedback for an invalid name input validation state', async () => {
createComponent({
props: {
validationState: { name: false },
},
});
expect(findScheduleName().attributes('state')).toBeFalsy();
});
});
describe('Timezone select', () => {
it('has options based on provided BE data', () => {
expect(findDropdownOptions()).toHaveLength(mockTimezones.length);
......@@ -102,20 +113,23 @@ describe('AddEditScheduleForm', () => {
describe('Form validation', () => {
describe('Timezone select', () => {
it('has red border when nothing selected', () => {
it('has a validation red border when timezone field is invalid', () => {
createComponent({
props: {
schedule: null,
form: { name: '', description: '', timezone: '' },
isTimezoneInvalid: true,
validationState: { timezone: false },
},
});
expect(findTimezoneDropdown().classes()).toContain('invalid-dropdown');
});
it("doesn't have a red border when there is selected option", async () => {
findDropdownOptions().at(1).vm.$emit('click');
await wrapper.vm.$nextTick();
it('does not have a validation red border when timezone field is valid', async () => {
createComponent({
props: {
validationState: { timezone: true },
},
});
expect(findTimezoneDropdown().classes()).not.toContain('invalid-dropdown');
});
});
......
......@@ -16,6 +16,7 @@ exports[`AddEditRotationModal renders rotation modal layout 1`] = `
form="[object Object]"
participants=""
schedule="[object Object]"
validationstate="[object Object]"
/>
</gl-modal-stub>
`;
import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import { GlDropdownItem, GlTokenSelector } from '@gitlab/ui';
import { GlDropdownItem, GlTokenSelector, GlFormGroup } from '@gitlab/ui';
import AddEditRotationForm from 'ee/oncall_schedules/components/rotations/components/add_edit_rotation_form.vue';
import { LENGTH_ENUM } from 'ee/oncall_schedules/constants';
import { participants, getOncallSchedulesQueryResponse } from '../../mocks/apollo_mock';
......@@ -23,9 +23,11 @@ describe('AddEditRotationForm', () => {
...props,
schedule,
isLoading: false,
rotationNameIsValid: true,
rotationParticipantsAreValid: true,
rotationStartsAtIsValid: true,
validationState: {
name: true,
participants: false,
startsAt: false,
},
participants,
form: {
name: '',
......@@ -57,7 +59,23 @@ describe('AddEditRotationForm', () => {
const findRotationLength = () => wrapper.find('[id = "rotation-length"]');
const findRotationStartsOn = () => wrapper.find('[id = "rotation-time"]');
const findUserSelector = () => wrapper.find(GlTokenSelector);
const findDropdownOptions = () => wrapper.findAll(GlDropdownItem);
const findDropdownOptions = () => wrapper.findAllComponents(GlDropdownItem);
const findRotationFormGroups = () => wrapper.findAllComponents(GlFormGroup);
describe('Rotation form validation', () => {
it.each`
index | type | validationState | value
${0} | ${'name'} | ${true} | ${'true'}
${1} | ${'participants'} | ${false} | ${undefined}
${3} | ${'startsAt'} | ${false} | ${undefined}
`(
'form validation for $type returns $value when passed validate state of $validationState',
({ index, value }) => {
const formGroup = findRotationFormGroups();
expect(formGroup.at(index).attributes('state')).toBe(value);
},
);
});
describe('Rotation length and start time', () => {
it('renders the rotation length value', async () => {
......
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