Commit 892da007 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '33098-distribute-daily-cron-schedules-out-over-the-hour' into 'master'

Resolve "Distribute daily cron schedules out over the hour"

See merge request gitlab-org/gitlab!30729
parents f524c3a5 f0354bb1
......@@ -56,6 +56,19 @@ export const getMonthNames = abbreviated => {
export const pad = (val, len = 2) => `0${val}`.slice(-len);
/**
* Returns i18n weekday names array.
*/
export const getWeekdayNames = () => [
__('Sunday'),
__('Monday'),
__('Tuesday'),
__('Wednesday'),
__('Thursday'),
__('Friday'),
__('Saturday'),
];
/**
* Given a date object returns the day of the week in English
* @param {date} date
......
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { getWeekdayNames } from '~/lib/utils/datetime_utility';
export default {
components: {
GlSprintf,
GlLink,
},
props: {
initialCronInterval: {
type: String,
......@@ -9,25 +17,51 @@ export default {
},
data() {
return {
isEditingCustom: false,
randomHour: this.generateRandomHour(),
randomWeekDayIndex: this.generateRandomWeekDayIndex(),
randomDay: this.generateRandomDay(),
inputNameAttribute: 'schedule[cron]',
cronInterval: this.initialCronInterval,
cronIntervalPresets: {
everyDay: '0 4 * * *',
everyWeek: '0 4 * * 0',
everyMonth: '0 4 1 * *',
},
cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
customInputEnabled: false,
};
},
computed: {
cronIntervalPresets() {
return {
everyDay: `0 ${this.randomHour} * * *`,
everyWeek: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`,
everyMonth: `0 ${this.randomHour} ${this.randomDay} * *`,
};
},
intervalIsPreset() {
return Object.values(this.cronIntervalPresets).includes(this.cronInterval);
},
// The text input is editable when there's a custom interval, or when it's
// a preset interval and the user clicks the 'custom' radio button
isEditable() {
return Boolean(this.customInputEnabled || !this.intervalIsPreset);
formattedTime() {
if (this.randomHour > 12) {
return `${this.randomHour - 12}:00pm`;
} else if (this.randomHour === 12) {
return `12:00pm`;
}
return `${this.randomHour}:00am`;
},
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: {
......@@ -39,14 +73,31 @@ export default {
});
},
},
created() {
if (this.intervalIsPreset) {
this.enableCustomInput = false;
// 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;
}
},
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.customInputEnabled = shouldEnable;
this.isEditingCustom = shouldEnable;
if (shouldEnable) {
// We need to change the value so other radios don't remain selected
......@@ -54,30 +105,21 @@ export default {
this.cronInterval = `${this.cronInterval} `;
}
},
generateRandomHour() {
return Math.floor(Math.random() * 23);
},
generateRandomWeekDayIndex() {
return Math.floor(Math.random() * 6);
},
generateRandomDay() {
return Math.floor(Math.random() * 28);
},
},
};
</script>
<template>
<div class="interval-pattern-form-group">
<div class="cron-preset-radio-input">
<input
id="custom"
:name="inputNameAttribute"
:value="cronInterval"
:checked="isEditable"
class="label-bold"
type="radio"
@click="toggleCustomInput(true)"
/>
<label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label>
<span class="cron-syntax-link-wrap">
(<a :href="cronSyntaxUrl" target="_blank"> {{ __('Cron syntax') }} </a>)
</span>
</div>
<div class="cron-preset-radio-input">
<input
id="every-day"
......@@ -89,7 +131,9 @@ export default {
@click="toggleCustomInput(false)"
/>
<label class="label-bold" for="every-day"> {{ __('Every day (at 4:00am)') }} </label>
<label class="label-bold" for="every-day">
{{ everyDayText }}
</label>
</div>
<div class="cron-preset-radio-input">
......@@ -104,7 +148,7 @@ export default {
/>
<label class="label-bold" for="every-week">
{{ __('Every week (Sundays at 4:00am)') }}
{{ everyWeekText }}
</label>
</div>
......@@ -120,20 +164,43 @@ export default {
/>
<label class="label-bold" for="every-month">
{{ __('Every month (on the 1st at 4:00am)') }}
{{ 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"
:disabled="!isEditable"
class="form-control inline cron-interval-input"
type="text"
required="true"
@input="setCustomInput"
/>
</div>
</div>
......
......@@ -11,9 +11,7 @@ Vue.use(Translate);
function initIntervalPatternInput() {
const intervalPatternMount = document.getElementById('interval-pattern-input');
const initialCronInterval = intervalPatternMount
? intervalPatternMount.dataset.initialInterval
: '';
const initialCronInterval = intervalPatternMount?.dataset?.initialInterval;
return new Vue({
el: intervalPatternMount,
......
......@@ -21,11 +21,6 @@
.cron-interval-input {
margin: 10px 10px 0 0;
}
.cron-syntax-link-wrap {
margin-right: 10px;
font-size: 12px;
}
}
.pipeline-schedule-table-row {
......
---
title: Update cron job schedule to have a random time generated on page load
merge_request: 30729
author:
type: changed
......@@ -586,6 +586,9 @@ msgid_plural "(%d closed)"
msgstr[0] ""
msgstr[1] ""
msgid "(%{linkStart}Cron syntax%{linkEnd})"
msgstr ""
msgid "(%{mrCount} merged)"
msgstr ""
......@@ -6417,9 +6420,6 @@ msgstr ""
msgid "Cron Timezone"
msgstr ""
msgid "Cron syntax"
msgstr ""
msgid "Crossplane"
msgstr ""
......@@ -8711,13 +8711,13 @@ msgstr ""
msgid "Every day"
msgstr ""
msgid "Every day (at 4:00am)"
msgid "Every day (at %{time})"
msgstr ""
msgid "Every month"
msgstr ""
msgid "Every month (on the 1st at 4:00am)"
msgid "Every month (Day %{day} at %{time})"
msgstr ""
msgid "Every three months"
......@@ -8729,7 +8729,7 @@ msgstr ""
msgid "Every week"
msgstr ""
msgid "Every week (Sundays at 4:00am)"
msgid "Every week (%{weekday} at %{time})"
msgstr ""
msgid "Everyone"
......
import { shallowMount } from '@vue/test-utils';
import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
const cronIntervalPresets = {
everyDay: '0 4 * * *',
everyWeek: '0 4 * * 0',
everyMonth: '0 4 1 * *',
};
describe('Interval Pattern Input Component', () => {
let oldWindowGl;
let wrapper;
const mockHour = 4;
const mockWeekDayIndex = 1;
const mockDay = 1;
const cronIntervalPresets = {
everyDay: `0 ${mockHour} * * *`,
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');
......@@ -21,13 +25,20 @@ describe('Interval Pattern Input Component', () => {
const selectEveryMonthRadio = () => findEveryMonthRadio().setChecked();
const selectCustomRadio = () => findCustomRadio().trigger('click');
const createWrapper = (props = {}) => {
const createWrapper = (props = {}, data = {}) => {
if (wrapper) {
throw new Error('A wrapper already exists');
}
wrapper = shallowMount(IntervalPatternInput, {
propsData: { ...props },
data() {
return {
randomHour: data?.hour || mockHour,
randomWeekDayIndex: mockWeekDayIndex,
randomDay: mockDay,
};
},
});
};
......@@ -47,39 +58,64 @@ describe('Interval Pattern Input Component', () => {
window.gl = oldWindowGl;
});
describe('when prop initialCronInterval is passed', () => {
describe('and prop initialCronInterval is custom', () => {
beforeEach(() => {
createWrapper({ initialCronInterval: '1 2 3 4 5' });
});
describe('the input field defaults', () => {
beforeEach(() => {
createWrapper();
});
it('the input is enabled', () => {
expect(findCustomInput().attributes('disabled')).toBeUndefined();
});
it('to a non empty string when no initial value is not passed', () => {
expect(findCustomInput()).not.toBe('');
});
});
describe('and prop initialCronInterval is a preset', () => {
beforeEach(() => {
createWrapper({ initialCronInterval: cronIntervalPresets.everyDay });
});
describe('the input field', () => {
const initialCron = '0 * * * *';
it('the input is disabled', () => {
expect(findCustomInput().attributes('disabled')).toBe('disabled');
});
beforeEach(() => {
createWrapper({ initialCronInterval: initialCron });
});
it('is equal to the prop `initialCronInterval` when passed', () => {
expect(findCustomInput().element.value).toBe(initialCron);
});
});
describe('when prop initialCronInterval is not passed', () => {
describe('The input field is enabled', () => {
beforeEach(() => {
createWrapper();
});
it('the input is enabled since custom is default value', () => {
expect(findCustomInput().attributes('disabled')).toBeUndefined();
it('when a default option is selected', () => {
selectEveryDayRadio();
return wrapper.vm.$nextTick().then(() => {
expect(findCustomInput().attributes('disabled')).toBeUndefined();
});
});
it('when the custom option is selected', () => {
selectCustomRadio();
return wrapper.vm.$nextTick().then(() => {
expect(findCustomInput().attributes('disabled')).toBeUndefined();
});
});
});
describe('User Actions', () => {
describe('formattedTime computed property', () => {
it.each`
desc | hour | expectedValue
${'returns a time in the afternoon if the value of `random time` is higher than 12'} | ${13} | ${'1:00pm'}
${'returns a time in the morning if the value of `random time` is lower than 12'} | ${11} | ${'11:00am'}
${'returns "12:00pm" if the value of `random time` is exactly 12'} | ${12} | ${'12:00pm'}
`('$desc', ({ hour, expectedValue }) => {
createWrapper({}, { hour });
expect(wrapper.vm.formattedTime).toBe(expectedValue);
});
});
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}
......@@ -96,4 +132,23 @@ describe('Interval Pattern Input Component', () => {
});
});
});
describe('User actions with input field for Cron syntax', () => {
beforeEach(() => {
createWrapper();
});
it('when editing the cron input it selects the custom radio button', () => {
const newValue = '0 * * * *';
findCustomInput().setValue(newValue);
expect(wrapper.vm.cronInterval).toBe(newValue);
});
it('when value of input is one of the defaults, it selects the corresponding radio button', () => {
findCustomInput().setValue(cronIntervalPresets.everyWeek);
expect(wrapper.vm.cronInterval).toBe(cronIntervalPresets.everyWeek);
});
});
});
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