This commit is contained in:
Rob Hough 2026-05-12 14:36:03 +01:00 committed by GitHub
commit a284773682
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 296 additions and 6 deletions

View File

@ -5857,8 +5857,15 @@
"agents.schedule.description": "Run the published agent on a schedule.",
"agents.schedule.status.active": "Active",
"agents.schedule.status.inactive": "Inactive",
"agents.schedule.cron": "Cron rule",
"agents.schedule.cron": "Schedule",
"agents.schedule.cron.mode.custom": "Expression",
"agents.schedule.cron.mode.preset": "Preset",
"agents.schedule.cron.placeholder": "* * * * *",
"agents.schedule.nextOccurrence": "Next occurrence: {occurrence}",
"agents.schedule.presets.everyDay": "Every day",
"agents.schedule.presets.everyHour": "Every hour",
"agents.schedule.presets.everyMonday": "Every Monday",
"agents.schedule.presets.everyWeekday": "Every weekday",
"agents.schedule.wakeUpPrompt": "Wake up prompt",
"agents.schedule.wakeUpPrompt.placeholder": "Automated message: you were triggered on schedule.",
"agents.schedule.wakeUpPrompt.help": "Sent to the agent each time the schedule fires.",

View File

@ -70,6 +70,7 @@
"chart.js": "^4.4.0",
"comlink": "^4.4.1",
"core-js": "^3.40.0",
"cron": "catalog:",
"curlconverter": "^4.12.0",
"dateformat": "^3.0.3",
"element-plus": "catalog:frontend",

View File

@ -6,16 +6,38 @@ const getScheduleIntegrationMock = vi.fn();
const updateScheduleIntegrationMock = vi.fn();
const activateScheduleIntegrationMock = vi.fn();
const deactivateScheduleIntegrationMock = vi.fn();
const cronTimeMock = vi.hoisted(() => vi.fn());
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({
restApiContext: {},
timezone: 'Europe/Berlin',
}),
}));
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
baseText: (key: string, options?: { interpolate?: Record<string, string> }) => {
if (key === 'agents.schedule.nextOccurrence') {
return `Next occurrence: ${options?.interpolate?.occurrence ?? ''}`;
}
return key;
},
}),
}));
vi.mock('cron', () => ({
CronTime: cronTimeMock.mockImplementation((expression: string) => {
if (expression === 'not-a-cron') {
throw new Error('Invalid cron expression');
}
return {
sendAt: () => ({
toJSDate: () => new Date('2026-05-12T09:00:00.000Z'),
}),
};
}),
}));
@ -48,10 +70,26 @@ const STUBS = {
},
N8nInput: {
template:
'<textarea v-if="type === \'textarea\'" :value="modelValue" :data-testid="$attrs[\'data-testid\']" @input="$emit(\'update:modelValue\', $event.target.value)" /><input v-else :value="modelValue" :data-testid="$attrs[\'data-testid\']" @input="$emit(\'update:modelValue\', $event.target.value)" />',
'<textarea v-if="type === \'textarea\'" :value="modelValue" :data-testid="$attrs[\'data-testid\']" @input="$emit(\'update:modelValue\', $event.target.value)" @blur="$emit(\'blur\', $event)" /><input v-else :value="modelValue" :data-testid="$attrs[\'data-testid\']" @input="$emit(\'update:modelValue\', $event.target.value)" @blur="$emit(\'blur\', $event)" />',
props: ['modelValue', 'type', 'rows', 'disabled', 'placeholder'],
emits: ['update:modelValue', 'blur'],
},
N8nRadioButtons: {
template:
'<div :data-testid="$attrs[\'data-testid\']"><button v-for="option in options" :key="option.value" :data-testid="`schedule-cron-mode-${option.value}`" @click="$emit(\'update:modelValue\', option.value)">{{ option.label }}</button></div>',
props: ['modelValue', 'options', 'disabled', 'size'],
emits: ['update:modelValue'],
},
N8nSelect: {
template:
'<select :value="modelValue" :data-testid="$attrs[\'data-testid\']" @change="$emit(\'update:modelValue\', $event.target.value)"><slot /></select>',
props: ['modelValue', 'disabled', 'size'],
emits: ['update:modelValue'],
},
N8nOption: {
template: '<option :value="value">{{ label }}</option>',
props: ['value', 'label'],
},
N8nSwitch2: {
template:
'<input type="checkbox" :checked="modelValue" :disabled="disabled" ' +
@ -100,6 +138,7 @@ describe('AgentScheduleTriggerCard', () => {
},
global: {
stubs: STUBS,
components: STUBS,
},
});
await flushPromises();
@ -122,6 +161,19 @@ describe('AgentScheduleTriggerCard', () => {
expect(wrapper.emitted('status-change')?.[0]).toEqual([true]);
});
it('renders matching cron expressions as presets', async () => {
getScheduleIntegrationMock.mockResolvedValue({
active: true,
cronExpression: '0 9 * * *',
wakeUpPrompt: 'Automated message: you were triggered on schedule.',
});
const wrapper = await renderComponent();
expect(wrapper.find('[data-testid="schedule-preset-select"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="schedule-cron-input"]').exists()).toBe(false);
});
it('activates the schedule after saving the current config and emits trigger-added', async () => {
getScheduleIntegrationMock.mockResolvedValueOnce({
active: false,
@ -201,6 +253,18 @@ describe('AgentScheduleTriggerCard', () => {
).toBeUndefined();
});
it('resets local changes and emits canceled when Cancel is clicked', async () => {
const wrapper = await renderComponent();
await wrapper.find('[data-testid="schedule-cron-input"]').setValue('*/10 * * * *');
await wrapper.find('[data-testid="schedule-cancel-button"]').trigger('click');
expect(wrapper.emitted('canceled')).toBeTruthy();
expect(
(wrapper.find('[data-testid="schedule-cron-input"]').element as HTMLInputElement).value,
).toBe('* * * * *');
});
it('shows invalid cron errors below the cron input and clears them on cron edit', async () => {
const wrapper = await renderComponent();
updateScheduleIntegrationMock.mockRejectedValueOnce(new Error('Invalid cron expression'));
@ -223,6 +287,16 @@ describe('AgentScheduleTriggerCard', () => {
expect(wrapper.find('[data-testid="schedule-cron-error"]').exists()).toBe(false);
});
it('updates custom next occurrence on cron input blur', async () => {
const wrapper = await renderComponent();
await wrapper.find('[data-testid="schedule-cron-input"]').setValue('0 0 * * FRI');
await wrapper.find('[data-testid="schedule-cron-input"]').trigger('blur');
expect(cronTimeMock).toHaveBeenCalledWith('0 0 * * FRI', 'Europe/Berlin');
});
it('disables activation when the agent is not published', async () => {
const wrapper = await renderComponent({ isPublished: false });

View File

@ -500,6 +500,7 @@ onMounted(async () => {
:flat="true"
@status-change="onScheduleStatusChange"
@trigger-added="onScheduleTriggerAdded"
@canceled="closeModal"
@saved="closeModal"
/>

View File

@ -1,10 +1,27 @@
<script setup lang="ts">
import type { AgentScheduleConfig } from '@n8n/api-types';
import { N8nButton, N8nCard, N8nIcon, N8nInput, N8nSwitch2, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import {
N8nButton,
N8nCard,
N8nIcon,
N8nInput,
N8nRadioButtons,
N8nSwitch2,
N8nText,
} from '@n8n/design-system';
import N8nOption from '@n8n/design-system/components/N8nOption';
import N8nSelect from '@n8n/design-system/components/N8nSelect';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, onMounted, ref } from 'vue';
import {
getScheduleInputMode,
getNextScheduleOccurrence,
getSchedulePresetByCronExpression,
schedulePresets,
type ScheduleInputMode,
} from '../utils/schedulePresets';
import {
activateScheduleIntegration,
deactivateScheduleIntegration,
@ -25,6 +42,7 @@ const props = withDefaults(
const emit = defineEmits<{
'status-change': [configured: boolean];
'trigger-added': [];
canceled: [];
saved: [];
}>();
@ -34,6 +52,9 @@ const rootStore = useRootStore();
const active = ref(false);
const lastSavedActive = ref(false);
const cronExpression = ref('');
const nextOccurrenceCronExpression = ref('');
const scheduleInputMode = ref<ScheduleInputMode>('preset');
const lastCustomCronExpression = ref('');
const wakeUpPrompt = ref('');
const loading = ref(false);
const saving = ref(false);
@ -57,6 +78,36 @@ const canSave = computed(
cronErrorMessage.value === '' &&
(!active.value || (props.isPublished && cronExpression.value.trim() !== '')),
);
const scheduleInputModeOptions = computed<Array<{ label: string; value: ScheduleInputMode }>>(
() => [
{ label: locale.baseText('agents.schedule.cron.mode.preset' as BaseTextKey), value: 'preset' },
{ label: locale.baseText('agents.schedule.cron.mode.custom' as BaseTextKey), value: 'custom' },
],
);
const schedulePresetOptions = computed(() =>
schedulePresets.map((preset) => ({
label: locale.baseText(preset.labelKey as BaseTextKey),
value: preset.cronExpression,
})),
);
const selectedPresetCronExpression = computed(
() => getSchedulePresetByCronExpression(cronExpression.value)?.cronExpression ?? '',
);
const nextScheduleOccurrence = computed(() =>
getNextScheduleOccurrence(nextOccurrenceCronExpression.value, rootStore.timezone),
);
const nextScheduleOccurrenceText = computed(() => {
if (!nextScheduleOccurrence.value) return '';
return new Intl.DateTimeFormat(undefined, {
timeZone: rootStore.timezone,
weekday: 'short',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: '2-digit',
}).format(nextScheduleOccurrence.value);
});
type ScheduleErrorKey =
| 'agents.schedule.loadError'
@ -68,6 +119,11 @@ function applyConfig(config: AgentScheduleConfig) {
active.value = config.active;
lastSavedActive.value = config.active;
cronExpression.value = config.cronExpression;
nextOccurrenceCronExpression.value = config.cronExpression;
scheduleInputMode.value = getScheduleInputMode(config.cronExpression);
if (scheduleInputMode.value === 'custom') {
lastCustomCronExpression.value = config.cronExpression;
}
lastSavedCronExpression.value = config.cronExpression;
wakeUpPrompt.value = config.wakeUpPrompt;
lastSavedWakeUpPrompt.value = config.wakeUpPrompt;
@ -209,12 +265,52 @@ async function onSave() {
function onCancel() {
active.value = lastSavedActive.value;
cronExpression.value = lastSavedCronExpression.value;
nextOccurrenceCronExpression.value = lastSavedCronExpression.value;
scheduleInputMode.value = getScheduleInputMode(lastSavedCronExpression.value);
if (scheduleInputMode.value === 'custom') {
lastCustomCronExpression.value = lastSavedCronExpression.value;
}
wakeUpPrompt.value = lastSavedWakeUpPrompt.value;
clearErrors();
emit('canceled');
}
function onCronExpressionInput(value: string) {
cronExpression.value = value;
lastCustomCronExpression.value = value;
clearErrors();
}
function onCronExpressionBlur() {
nextOccurrenceCronExpression.value = cronExpression.value;
}
function onScheduleInputModeInput(value: ScheduleInputMode) {
if (value === scheduleInputMode.value) return;
if (scheduleInputMode.value === 'custom') {
lastCustomCronExpression.value = cronExpression.value;
}
scheduleInputMode.value = value;
if (value === 'preset') {
cronExpression.value =
getSchedulePresetByCronExpression(cronExpression.value)?.cronExpression ??
schedulePresets[0]?.cronExpression ??
'';
nextOccurrenceCronExpression.value = cronExpression.value;
} else if (lastCustomCronExpression.value) {
cronExpression.value = lastCustomCronExpression.value;
nextOccurrenceCronExpression.value = cronExpression.value;
}
clearErrors();
}
function onSchedulePresetInput(value: string) {
cronExpression.value = value;
nextOccurrenceCronExpression.value = value;
clearErrors();
}
@ -271,14 +367,56 @@ onMounted(() => {
</div>
<div :class="$style.field">
<N8nText size="small" bold>{{ locale.baseText('agents.schedule.cron') }}</N8nText>
<div :class="$style.fieldHeader">
<N8nText size="small" bold>{{ locale.baseText('agents.schedule.cron') }}</N8nText>
<N8nRadioButtons
:model-value="scheduleInputMode"
:options="scheduleInputModeOptions"
size="small"
:disabled="loading || saving"
data-testid="schedule-cron-mode-toggle"
@update:model-value="onScheduleInputModeInput"
/>
</div>
<N8nSelect
v-if="scheduleInputMode === 'preset'"
:model-value="selectedPresetCronExpression"
:disabled="loading || saving"
:class="$style.cronSelect"
size="small"
data-testid="schedule-preset-select"
@update:model-value="onSchedulePresetInput"
>
<N8nOption
v-for="preset in schedulePresetOptions"
:key="preset.value"
:value="preset.value"
:label="preset.label"
/>
</N8nSelect>
<N8nInput
v-else
size="medium"
:model-value="cronExpression"
:disabled="loading || saving"
:placeholder="locale.baseText('agents.schedule.cron.placeholder')"
data-testid="schedule-cron-input"
@update:model-value="onCronExpressionInput"
@blur="onCronExpressionBlur"
/>
<N8nText
v-if="nextScheduleOccurrenceText"
:class="$style.helpText"
size="small"
data-testid="schedule-next-occurrence"
>
{{
locale.baseText('agents.schedule.nextOccurrence' as BaseTextKey, {
interpolate: { occurrence: nextScheduleOccurrenceText },
})
}}
</N8nText>
<div v-else :class="$style.fieldDescriptionSpacer" />
<N8nText
v-if="cronErrorMessage"
:class="$style.errorText"
@ -403,6 +541,22 @@ onMounted(() => {
gap: var(--spacing--2xs);
}
.fieldHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
}
/** NOTE: Overwrites misaligned size styles between N8nInput and N8nSelect. **/
.cronSelect {
height: var(--height--md);
:global(.el-input--small .el-input__inner) {
height: var(--height--md);
}
}
.toggleRow {
display: flex;
align-items: center;
@ -427,4 +581,7 @@ onMounted(() => {
.errorText {
color: var(--color--danger);
}
.fieldDescriptionSpacer {
height: var(--height--3xs);
}
</style>

View File

@ -0,0 +1,47 @@
import { CronTime } from 'cron';
export type ScheduleInputMode = 'preset' | 'custom';
export interface SchedulePreset {
labelKey: string;
cronExpression: string;
}
export const schedulePresets: SchedulePreset[] = [
{
labelKey: 'agents.schedule.presets.everyHour',
cronExpression: '0 * * * *',
},
{
labelKey: 'agents.schedule.presets.everyDay',
cronExpression: '0 9 * * *',
},
{
labelKey: 'agents.schedule.presets.everyWeekday',
cronExpression: '0 9 * * 1-5',
},
{
labelKey: 'agents.schedule.presets.everyMonday',
cronExpression: '0 9 * * 1',
},
];
export function getSchedulePresetByCronExpression(cronExpression: string) {
return schedulePresets.find((preset) => preset.cronExpression === cronExpression.trim());
}
export function getScheduleInputMode(cronExpression: string): ScheduleInputMode {
return getSchedulePresetByCronExpression(cronExpression) ? 'preset' : 'custom';
}
export function getNextScheduleOccurrence(cronExpression: string, timezone: string): Date | null {
if (!cronExpression.trim()) {
return null;
}
try {
return new CronTime(cronExpression, timezone).sendAt().toJSDate();
} catch {
return null;
}
}

View File

@ -3974,6 +3974,9 @@ importers:
core-js:
specifier: ^3.40.0
version: 3.40.0
cron:
specifier: 'catalog:'
version: 4.4.0
curlconverter:
specifier: ^4.12.0
version: 4.12.0