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