mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
Merge 2995a22c51 into 0ce820de73
This commit is contained in:
commit
a284773682
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -500,6 +500,7 @@ onMounted(async () => {
|
|||
:flat="true"
|
||||
@status-change="onScheduleStatusChange"
|
||||
@trigger-added="onScheduleTriggerAdded"
|
||||
@canceled="closeModal"
|
||||
@saved="closeModal"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user