Commit 27fe6f83 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch...

Merge branch '217803-follow-up-from-resolve-distribute-daily-cron-schedules-out-over-the-hour' into 'master'

Follow-up from: Distribute daily cron schedules out over the hour"

See merge request gitlab-org/gitlab!36471
parents 59b0dcc4 6d52ba35
<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 ""
...@@ -16902,6 +16899,9 @@ msgstr "" ...@@ -16902,6 +16899,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 ""
...@@ -16932,9 +16932,6 @@ msgstr "" ...@@ -16932,9 +16932,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