Commit 6d52ba35 authored by Frédéric Caplette's avatar Frédéric Caplette Committed by Paul Slaughter

Refactor cron schedule

Replace old inputs with the new
component glFormRadio and refactor
the part of the code that used some hack
to trigger the v-model to work.
parent 361945b1
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { GlFormRadio, GlFormRadioGroup, GlLink, GlSprintf } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { getWeekdayNames } from '~/lib/utils/datetime_utility';
const KEY_EVERY_DAY = 'everyDay';
const KEY_EVERY_WEEK = 'everyWeek';
const KEY_EVERY_MONTH = 'everyMonth';
const KEY_CUSTOM = 'custom';
export default {
components: {
GlSprintf,
GlFormRadio,
GlFormRadioGroup,
GlLink,
GlSprintf,
},
props: {
initialCronInterval: {
......@@ -22,6 +29,7 @@ export default {
randomWeekDayIndex: this.generateRandomWeekDayIndex(),
randomDay: this.generateRandomDay(),
inputNameAttribute: 'schedule[cron]',
radioValue: this.initialCronInterval ? KEY_CUSTOM : KEY_EVERY_DAY,
cronInterval: this.initialCronInterval,
cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
};
......@@ -29,14 +37,11 @@ export default {
computed: {
cronIntervalPresets() {
return {
everyDay: `0 ${this.randomHour} * * *`,
everyWeek: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`,
everyMonth: `0 ${this.randomHour} ${this.randomDay} * *`,
[KEY_EVERY_DAY]: `0 ${this.randomHour} * * *`,
[KEY_EVERY_WEEK]: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`,
[KEY_EVERY_MONTH]: `0 ${this.randomHour} ${this.randomDay} * *`,
};
},
intervalIsPreset() {
return Object.values(this.cronIntervalPresets).includes(this.cronInterval);
},
formattedTime() {
if (this.randomHour > 12) {
return `${this.randomHour - 12}:00pm`;
......@@ -45,24 +50,36 @@ export default {
}
return `${this.randomHour}:00am`;
},
radioOptions() {
return [
{
value: KEY_EVERY_DAY,
text: sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }),
},
{
value: KEY_EVERY_WEEK,
text: sprintf(s__('Every week (%{weekday} at %{time})'), {
weekday: this.weekday,
time: this.formattedTime,
}),
},
{
value: KEY_EVERY_MONTH,
text: sprintf(s__('Every month (Day %{day} at %{time})'), {
day: this.randomDay,
time: this.formattedTime,
}),
},
{
value: KEY_CUSTOM,
text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})'),
link: this.cronSyntaxUrl,
},
];
},
weekday() {
return getWeekdayNames()[this.randomWeekDayIndex];
},
everyDayText() {
return sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime });
},
everyWeekText() {
return sprintf(s__('Every week (%{weekday} at %{time})'), {
weekday: this.weekday,
time: this.formattedTime,
});
},
everyMonthText() {
return sprintf(s__('Every month (Day %{day} at %{time})'), {
day: this.randomDay,
time: this.formattedTime,
});
},
},
watch: {
cronInterval() {
......@@ -72,38 +89,18 @@ export default {
gl.pipelineScheduleFieldErrors.updateFormValidityState();
});
},
},
// If at the mounting stage the default is still an empty string, we
// know we are not editing an existing field so we update it so
// that the default is the first radio option
mounted() {
if (this.cronInterval === '') {
this.cronInterval = this.cronIntervalPresets.everyDay;
}
radioValue: {
immediate: true,
handler(val) {
if (val !== KEY_CUSTOM) {
this.cronInterval = this.cronIntervalPresets[val];
}
},
},
},
methods: {
setCustomInput(e) {
if (!this.isEditingCustom) {
this.isEditingCustom = true;
this.$refs.customInput.click();
// Because we need to manually trigger the click on the radio btn,
// it will add a space to update the v-model. If the user is typing
// and the space is added, it will feel very unituitive so we reset
// the value to the original
this.cronInterval = e.target.value;
}
if (this.intervalIsPreset) {
this.isEditingCustom = false;
}
},
toggleCustomInput(shouldEnable) {
this.isEditingCustom = shouldEnable;
if (shouldEnable) {
// We need to change the value so other radios don't remain selected
// because the model (cronInterval) hasn't changed. The server trims it.
this.cronInterval = `${this.cronInterval} `;
}
onCustomInput() {
this.radioValue = KEY_CUSTOM;
},
generateRandomHour() {
return Math.floor(Math.random() * 23);
......@@ -119,89 +116,33 @@ export default {
</script>
<template>
<div class="interval-pattern-form-group">
<div class="cron-preset-radio-input">
<input
id="every-day"
v-model="cronInterval"
:name="inputNameAttribute"
:value="cronIntervalPresets.everyDay"
class="label-bold"
type="radio"
@click="toggleCustomInput(false)"
/>
<label class="label-bold" for="every-day">
{{ everyDayText }}
</label>
</div>
<div class="cron-preset-radio-input">
<input
id="every-week"
v-model="cronInterval"
:name="inputNameAttribute"
:value="cronIntervalPresets.everyWeek"
class="label-bold"
type="radio"
@click="toggleCustomInput(false)"
/>
<label class="label-bold" for="every-week">
{{ everyWeekText }}
</label>
</div>
<div class="cron-preset-radio-input">
<input
id="every-month"
v-model="cronInterval"
:name="inputNameAttribute"
:value="cronIntervalPresets.everyMonth"
class="label-bold"
type="radio"
@click="toggleCustomInput(false)"
/>
<label class="label-bold" for="every-month">
{{ everyMonthText }}
</label>
</div>
<div class="cron-preset-radio-input">
<input
id="custom"
ref="customInput"
v-model="cronInterval"
:name="inputNameAttribute"
:value="cronInterval"
class="label-bold"
type="radio"
@click="toggleCustomInput(true)"
/>
<label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label>
<gl-sprintf :message="__('(%{linkStart}Cron syntax%{linkEnd})')">
<template #link="{content}">
<gl-link :href="cronSyntaxUrl" target="_blank" class="gl-font-sm">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</div>
<div class="cron-interval-input-wrapper">
<input
id="schedule_cron"
v-model="cronInterval"
:placeholder="__('Define a custom pattern with cron syntax')"
:name="inputNameAttribute"
class="form-control inline cron-interval-input"
type="text"
required="true"
@input="setCustomInput"
/>
</div>
<div>
<gl-form-radio-group v-model="radioValue" :name="inputNameAttribute">
<gl-form-radio
v-for="option in radioOptions"
:key="option.value"
:value="option.value"
:data-testid="option.value"
>
<gl-sprintf v-if="option.link" :message="option.text">
<template #link="{content}">
<gl-link :href="option.link" target="_blank" class="gl-font-sm">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
<template v-else>{{ option.text }}</template>
</gl-form-radio>
</gl-form-radio-group>
<input
id="schedule_cron"
v-model="cronInterval"
:placeholder="__('Define a custom pattern with cron syntax')"
:name="inputNameAttribute"
class="form-control inline cron-interval-input"
type="text"
required="true"
@input="onCustomInput"
/>
</div>
</template>
---
title: Fix UI quirks with pipeline schedule cron options
merge_request: 36471
author:
type: changed
......@@ -757,9 +757,6 @@ msgid_plural "(%d closed)"
msgstr[0] ""
msgstr[1] ""
msgid "(%{linkStart}Cron syntax%{linkEnd})"
msgstr ""
msgid "(%{mrCount} merged)"
msgstr ""
......@@ -16857,6 +16854,9 @@ msgstr ""
msgid "PipelineCharts|Total:"
msgstr ""
msgid "PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})"
msgstr ""
msgid "PipelineSchedules|Activated"
msgstr ""
......@@ -16887,9 +16887,6 @@ msgstr ""
msgid "PipelineSchedules|Variables"
msgstr ""
msgid "PipelineSheduleIntervalPattern|Custom"
msgstr ""
msgid "PipelineStatusTooltip|Pipeline: %{ciStatus}"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
describe('Interval Pattern Input Component', () => {
......@@ -14,15 +15,22 @@ describe('Interval Pattern Input Component', () => {
everyWeek: `0 ${mockHour} * * ${mockWeekDayIndex}`,
everyMonth: `0 ${mockHour} ${mockDay} * *`,
};
const findEveryDayRadio = () => wrapper.find('#every-day');
const findEveryWeekRadio = () => wrapper.find('#every-week');
const findEveryMonthRadio = () => wrapper.find('#every-month');
const findCustomRadio = () => wrapper.find('#custom');
const customKey = 'custom';
const everyDayKey = 'everyDay';
const cronIntervalNotInPreset = `0 12 * * *`;
const findEveryDayRadio = () => wrapper.find(`[data-testid=${everyDayKey}]`);
const findEveryWeekRadio = () => wrapper.find('[data-testid="everyWeek"]');
const findEveryMonthRadio = () => wrapper.find('[data-testid="everyMonth"]');
const findCustomRadio = () => wrapper.find(`[data-testid="${customKey}"]`);
const findCustomInput = () => wrapper.find('#schedule_cron');
const selectEveryDayRadio = () => findEveryDayRadio().setChecked();
const selectEveryWeekRadio = () => findEveryWeekRadio().setChecked();
const selectEveryMonthRadio = () => findEveryMonthRadio().setChecked();
const findAllLabels = () => wrapper.findAll('label');
const findSelectedRadio = () =>
wrapper.findAll('input[type="radio"]').wrappers.find(x => x.element.checked);
const findSelectedRadioKey = () => findSelectedRadio()?.attributes('data-testid');
const selectEveryDayRadio = () => findEveryDayRadio().trigger('click');
const selectEveryWeekRadio = () => findEveryWeekRadio().trigger('click');
const selectEveryMonthRadio = () => findEveryMonthRadio().trigger('click');
const selectCustomRadio = () => findCustomRadio().trigger('click');
const createWrapper = (props = {}, data = {}) => {
......@@ -30,7 +38,7 @@ describe('Interval Pattern Input Component', () => {
throw new Error('A wrapper already exists');
}
wrapper = shallowMount(IntervalPatternInput, {
wrapper = mount(IntervalPatternInput, {
propsData: { ...props },
data() {
return {
......@@ -63,8 +71,8 @@ describe('Interval Pattern Input Component', () => {
createWrapper();
});
it('to a non empty string when no initial value is not passed', () => {
expect(findCustomInput()).not.toBe('');
it('defaults to every day value when no `initialCronInterval` is passed', () => {
expect(findCustomInput().element.value).toBe(cronIntervalPresets.everyDay);
});
});
......@@ -85,20 +93,20 @@ describe('Interval Pattern Input Component', () => {
createWrapper();
});
it('when a default option is selected', () => {
it('when a default option is selected', async () => {
selectEveryDayRadio();
return wrapper.vm.$nextTick().then(() => {
expect(findCustomInput().attributes('disabled')).toBeUndefined();
});
await wrapper.vm.$nextTick();
expect(findCustomInput().attributes('disabled')).toBeUndefined();
});
it('when the custom option is selected', () => {
it('when the custom option is selected', async () => {
selectCustomRadio();
return wrapper.vm.$nextTick().then(() => {
expect(findCustomInput().attributes('disabled')).toBeUndefined();
});
await wrapper.vm.$nextTick();
expect(findCustomInput().attributes('disabled')).toBeUndefined();
});
});
......@@ -115,40 +123,83 @@ describe('Interval Pattern Input Component', () => {
});
});
describe('Time strings', () => {
beforeEach(() => {
createWrapper();
});
it('renders each label for radio options properly', () => {
const labels = findAllLabels().wrappers.map(el => trimText(el.text()));
expect(labels).toEqual([
'Every day (at 4:00am)',
'Every week (Monday at 4:00am)',
'Every month (Day 1 at 4:00am)',
'Custom ( Cron syntax )',
]);
});
});
describe('User Actions with radio buttons', () => {
it.each`
desc | initialCronInterval | act | expectedValue
${'when everyday is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryDayRadio} | ${cronIntervalPresets.everyDay}
${'when everyweek is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryWeekRadio} | ${cronIntervalPresets.everyWeek}
${'when everymonth is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryMonthRadio} | ${cronIntervalPresets.everyMonth}
${'when custom is selected, add space to value'} | ${cronIntervalPresets.everyMonth} | ${selectCustomRadio} | ${`${cronIntervalPresets.everyMonth} `}
`('$desc', ({ initialCronInterval, act, expectedValue }) => {
createWrapper({ initialCronInterval });
describe('Default option', () => {
beforeEach(() => {
createWrapper();
});
it('when everyday is selected, update value', async () => {
selectEveryWeekRadio();
await wrapper.vm.$nextTick();
expect(findCustomInput().element.value).toBe(cronIntervalPresets.everyWeek);
selectEveryDayRadio();
await wrapper.vm.$nextTick();
expect(findCustomInput().element.value).toBe(cronIntervalPresets.everyDay);
});
});
describe('Other options', () => {
it.each`
desc | initialCronInterval | act | expectedValue
${'when everyweek is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryWeekRadio} | ${cronIntervalPresets.everyWeek}
${'when everymonth is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryMonthRadio} | ${cronIntervalPresets.everyMonth}
${'when custom is selected, value remains the same'} | ${cronIntervalPresets.everyMonth} | ${selectCustomRadio} | ${cronIntervalPresets.everyMonth}
`('$desc', async ({ initialCronInterval, act, expectedValue }) => {
createWrapper({ initialCronInterval });
act();
act();
await wrapper.vm.$nextTick();
return wrapper.vm.$nextTick().then(() => {
expect(findCustomInput().element.value).toBe(expectedValue);
});
});
});
describe('User actions with input field for Cron syntax', () => {
beforeEach(() => {
createWrapper();
});
it('when editing the cron input it selects the custom radio button', () => {
it('when editing the cron input it selects the custom radio button', async () => {
const newValue = '0 * * * *';
expect(findSelectedRadioKey()).toBe(everyDayKey);
findCustomInput().setValue(newValue);
expect(wrapper.vm.cronInterval).toBe(newValue);
await wrapper.vm.$nextTick;
expect(findSelectedRadioKey()).toBe(customKey);
});
});
it('when value of input is one of the defaults, it selects the corresponding radio button', () => {
findCustomInput().setValue(cronIntervalPresets.everyWeek);
describe('Edit form field', () => {
beforeEach(() => {
createWrapper({ initialCronInterval: cronIntervalNotInPreset });
});
expect(wrapper.vm.cronInterval).toBe(cronIntervalPresets.everyWeek);
it('loads with the custom option being selected', () => {
expect(findSelectedRadioKey()).toBe(customKey);
});
});
});
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