mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
fix: persist a one-time schedule's run time so edit and disable keep its year (#1499)
PR #1497 pinned an enabled one-shot's next_run_at on creation, but the chosen instant did not survive two paths: editing reconstructed the date from the yearless cron (current year), and a disabled one-shot nulled next_run_at with no other store, so enabling it later recomputed from the cron. Both moved a future-year one-shot to a different annual occurrence than the date displayed. Persist the one-shot's absolute fire time in a dedicated run_at column (additive, nullable; recurring schedules leave it null). Create and update store run_at independently of the enabled state; next_run_at is derived from it when enabled and null while disabled, so a disabled one-shot keeps its run_at and the enable toggle restores the exact instant from the column rather than the cron. The editor reconstructs a one-shot's date from the persisted run_at, so opening and re-saving without changes preserves the originally chosen year. No behavior change for recurring schedules or fresh installs; the column is added by an additive migration safe for upgrades from v0.92.0.
This commit is contained in:
@@ -241,7 +241,15 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
setFormTargetId(task.target_id || '');
|
||||
setFormNodeId(task.node_id != null ? String(task.node_id) : '');
|
||||
setFormCron(task.cron_expression);
|
||||
const parsed = parseCron(task.cron_expression, (task.delete_after_run ?? 0) === 1);
|
||||
let parsed = parseCron(task.cron_expression, (task.delete_after_run ?? 0) === 1);
|
||||
// The cron has no year field, so parseCron reconstructs a one-shot's date in
|
||||
// the current year. Rebuild it from the persisted run_at instead, so editing
|
||||
// (and re-saving) preserves the originally chosen instant rather than moving
|
||||
// it to this year's occurrence.
|
||||
if (parsed && parsed.frequency === 'once' && task.run_at != null) {
|
||||
const pinned = new Date(task.run_at);
|
||||
parsed = { ...parsed, date: pinned, hour: pinned.getHours(), minute: pinned.getMinutes() };
|
||||
}
|
||||
setScheduleMode(parsed ? 'simple' : 'advanced');
|
||||
setSimpleSchedule(parsed ?? DEFAULT_SIMPLE_SCHEDULE);
|
||||
setSimpleReplacedCron(false);
|
||||
|
||||
@@ -647,6 +647,29 @@ describe('ScheduledOperationsView', () => {
|
||||
expect(screen.queryByText(/one-time schedule fires on the chosen date/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves the chosen year when editing a future-year one-shot without changing it', async () => {
|
||||
// The cron (0 23 1 7 *) is yearless; the persisted run_at carries the real
|
||||
// year. Editing and re-saving must send that year, not this year's occurrence.
|
||||
const runAt = new Date(new Date().getFullYear() + 1, 6, 1, 23, 0, 0, 0).getTime();
|
||||
tasksFixture = [makeTask({
|
||||
id: 7, name: 'once-next-year', cron_expression: '0 23 1 7 *',
|
||||
delete_after_run: 1, run_at: runAt, next_run_at: runAt,
|
||||
})];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByTitle('Edit'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = mockedFetch.mock.calls.find(
|
||||
([url, opts]) => url === '/scheduled-tasks/7' && opts?.method === 'PUT',
|
||||
);
|
||||
expect(putCall).toBeTruthy();
|
||||
expect(JSON.parse(putCall![1].body).run_at).toBe(runAt);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a non-simple cron in Advanced mode', async () => {
|
||||
tasksFixture = [makeTask({ id: 6, name: 'every-15', cron_expression: '*/15 * * * *' })];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
Reference in New Issue
Block a user