Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): clearing a start node's Cron schedule in the flow designer no longer leaves a typed schedule block with no cadence

Clause-②: no
Metadata-protocol: none

Clearing the Cron schedule box on a typed `config.schedule` such as `{ type: 'cron', expression, timezone }` used to store `{ type: 'cron', timezone }`. The engine binds nothing from that block, so a schedule flow silently never ran and a time-relative sweep did not fall back to its daily default the way the field's help says. The published `ScheduleSchema` refuses the same block. An untyped `{ expression, timezone }` had the same fault: clearing it left `{ timezone }`, which binds nothing either. The designer now removes `config.schedule` entirely whenever clearing the cron leaves no cadence, as it already did for an untyped `{ expression }`. Typing a cron over a typed block still keeps its `type` and `timezone`.
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
// The inspector's object picker and CEL authoring load `@objectstack/lint` with
// function-body `import()`s. Warmed here so its first load is paid at
// collection, not inside a case's bounded wait.
import '@objectstack/lint';

vi.mock('../previews/useFlowNodePalette', () => ({
useActionConfigSchemas: () => ({}),
Expand Down Expand Up @@ -238,6 +242,30 @@ describe('a typed cron stores a descriptor the engine resolves to it (objectui#1
});
});

describe('clearing the Cron schedule box drops a typed block it would leave with no cadence (objectstack#31383)', () => {
const typed: Array<[string, Record<string, unknown>]> = [
['a typed cron', { type: 'cron', expression: '0 7 * * *' }],
['a typed cron with a timezone', { type: 'cron', expression: '0 7 * * *', timezone: 'Asia/Shanghai' }],
];
for (const [name, schedule] of typed) {
it(`clearing ${name} stores no schedule`, () => {
const { latest, patches } = mount(scheduleFlow({ schedule, objectName: 'todo_task' }));
fireEvent.change(controlOf('Cron schedule'), { target: { value: '' } });
expect(patches.length).toBeGreaterThan(0);
const config = startConfig(latest());
expect('schedule' in config).toBe(false);
expect(config.objectName).toBe('todo_task');
expect(controlOf('Cron schedule').value).toBe('');
});
}

it('CONTROL: clearing an untyped { expression } stores no schedule, as before', () => {
const { latest } = mount(scheduleFlow({ schedule: { expression: '0 6 * * *' }, objectName: 'todo_task' }));
fireEvent.change(controlOf('Cron schedule'), { target: { value: '' } });
expect('schedule' in startConfig(latest())).toBe(false);
});
});

describe('CONTROLS: the forms the field already read behave as before (objectui#12779)', () => {
it('a typed cron with a timezone reads and writes with its type and timezone kept', () => {
const { latest } = mount(scheduleFlow({ schedule: { type: 'cron', expression: '0 7 * * *', timezone: 'Asia/Shanghai' } }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,54 @@ describe('a typed cron stores a descriptor that resolves to it (objectui#12779)'
expect(scheduleWithExpression({ cron: '0 4 * * *' }, '')).toBeUndefined();
expect(scheduleWithExpression({ expression: '0 4 * * *' }, undefined)).toBeUndefined();
expect(scheduleWithExpression({ type: 'interval', intervalMs: 60000 }, undefined)).toBeUndefined();
expect(scheduleWithExpression({ type: 'cron', expression: '0 7 * * *', timezone: 'UTC' }, '')).toEqual({
});
});

describe('clearing never leaves a schedule block with no cadence (objectstack#31383)', () => {
// A typed block is judged by the published `ScheduleSchema`, which refuses a
// `type: 'cron'` with no `expression`; the engine binds nothing from it.
const typedCleared: Array<[string, Record<string, unknown>, unknown]> = [
["a typed cron's expression", { type: 'cron', expression: '0 7 * * *' }, ''],
["a typed cron's expression beside a timezone", { type: 'cron', expression: '0 7 * * *', timezone: 'UTC' }, ''],
['a typed cron cleared to undefined', { type: 'cron', expression: '0 7 * * *', timezone: 'Asia/Shanghai' }, undefined],
["a typed { cron }'s value", { type: 'cron', cron: '0 5 * * *', timezone: 'UTC' }, ''],
['a typed cron that already carried no cadence', { type: 'cron', timezone: 'UTC' }, ''],
['an interval type that carried only an expression', { type: 'interval', expression: '0 7 * * *' }, ''],
['an unknown type beside an expression', { type: 'weekly', expression: '0 8 * * *', timezone: 'UTC' }, ''],
];
for (const [name, schedule, value] of typedCleared) {
it(`clearing ${name} removes the whole schedule`, () => {
expect(normalizeScheduleCadence(scheduleWithExpression(schedule, value))).toBeNull();
expect(scheduleWithExpression(schedule, value)).toBeUndefined();
});
}

it('clearing an untyped { expression } beside a timezone removes the whole schedule', () => {
// `{ timezone }` alone binds nothing, and the flow parse reads nothing from it.
expect(normalizeScheduleCadence({ timezone: 'UTC' })).toBeNull();
expect(scheduleWithExpression({ expression: '0 4 * * *', timezone: 'UTC' }, '')).toBeUndefined();
});

it('CONTROL: clearing an untyped { expression } still removes the whole schedule', () => {
expect(scheduleWithExpression({ expression: '0 4 * * *' }, '')).toBeUndefined();
});

it('CONTROL: a cadence the cron stood in front of is kept when the cron is cleared', () => {
// The engine resolves `expression` ahead of `intervalMs`; with the cron gone the interval binds.
const stored = scheduleWithExpression({ expression: '0 4 * * *', intervalMs: 60000 }, '');
expect(stored).toEqual({ intervalMs: 60000 });
expect(normalizeScheduleCadence(stored)).toEqual({ type: 'interval', intervalMs: 60000 });
});

it('CONTROL: a cron typed over a typed block keeps its type and the other keys', () => {
expect(scheduleWithExpression({ type: 'cron', expression: '0 7 * * *', timezone: 'UTC' }, '0 9 * * *')).toEqual({
type: 'cron',
expression: '0 9 * * *',
timezone: 'UTC',
});
expect(scheduleWithExpression({ type: 'cron', timezone: 'UTC' }, '0 9 * * *')).toEqual({
type: 'cron',
expression: '0 9 * * *',
timezone: 'UTC',
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
// it is (`readOnlyScheduleCadence`): the designer edits cron cadences only.
// A commit rewrites the whole block through `scheduleWithExpression`, which
// keeps a bare string a string and an object's `type` / `timezone`, so the
// stored cadence resolves to the cron the field shows.
// stored cadence resolves to the cron the field shows; clearing it never
// leaves a block with no cadence (objectstack#31383).
//
// objectui#12672 — gated on the flow's RESOLVED kind as well as on the
// select: a `type: 'schedule'` flow, or a start node carrying
Expand Down Expand Up @@ -1705,7 +1706,12 @@ const NON_CRON_CADENCE_KEYS = ['intervalMs', 'every', 'at'] as const;
* only met on a descriptor the engine binds nothing from, since a live
* interval or once cadence offers no cron edit at all.
* - Clearing removes `expression` and `cron`, or the whole schedule when it is
* a string, an interval or once cadence, or an object left empty.
* a string, an interval or once cadence, or an object left with no cadence
* (objectstack#31383). A `type` or `timezone` left behind is not a cadence:
* the engine binds nothing from `{ type: 'cron', timezone }` or
* `{ timezone }`, and the published `ScheduleSchema` refuses a typed block
* with no `expression`. So a cleared `{ type: 'cron', expression, timezone }`
* leaves no schedule, as an untyped `{ expression }` always did.
*/
export function scheduleWithExpression(schedule: unknown, value: unknown): unknown {
const expression = typeof value === 'string' ? value : '';
Expand All @@ -1717,12 +1723,12 @@ export function scheduleWithExpression(schedule: unknown, value: unknown): unkno
const cadence = normalizeScheduleCadence(schedule);
if (cadence && cadence.type !== 'cron') return undefined;
delete next.expression;
} else {
if (typeof next.type === 'string' && next.type !== 'cron') delete next.type;
for (const key of NON_CRON_CADENCE_KEYS) delete next[key];
next.expression = expression;
return normalizeScheduleCadence(next) === null ? undefined : next;
}
return Object.keys(next).length === 0 ? undefined : next;
if (typeof next.type === 'string' && next.type !== 'cron') delete next.type;
for (const key of NON_CRON_CADENCE_KEYS) delete next[key];
next.expression = expression;
return next;
}

/**
Expand Down
Loading