diff --git a/.changeset/31383-flow-designer-cleared-cron-drops-typed-schedule.md b/.changeset/31383-flow-designer-cleared-cron-drops-typed-schedule.md new file mode 100644 index 00000000000..1d11b388ed3 --- /dev/null +++ b/.changeset/31383-flow-designer-cleared-cron-drops-typed-schedule.md @@ -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`. diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.scheduleCadence-12779.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.scheduleCadence-12779.test.tsx index f8cc5401f39..cfaec6e0b2d 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.scheduleCadence-12779.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.scheduleCadence-12779.test.tsx @@ -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: () => ({}), @@ -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]> = [ + ['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' } })); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.scheduleCadence-12779.test.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.scheduleCadence-12779.test.ts index 279e4267198..6e85c4bc6cc 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.scheduleCadence-12779.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.scheduleCadence-12779.test.ts @@ -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, 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', }); }); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index fa39ec391ed..aa3d830d2ae 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -526,7 +526,8 @@ const FLOW_NODE_CONFIG: Record = { // 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 @@ -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 : ''; @@ -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; } /**