From c2655e8bf8a32de89ccdb8997bf978301f61cb40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 05:40:24 +0000 Subject: [PATCH 1/3] fix(app-shell): clearing the flow designer's cron drops a typed schedule block left with no cadence Co-Authored-By: Claude --- ...igner-cleared-cron-drops-typed-schedule.md | 10 ++++++ ...deInspector.scheduleCadence-12779.test.tsx | 24 +++++++++++++ ...-node-config.scheduleCadence-12779.test.ts | 35 ++++++++++++++++++- .../inspectors/flow-node-config.ts | 12 +++++-- 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 .changeset/31383-flow-designer-cleared-cron-drops-typed-schedule.md 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..8717e28b649 --- /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. The designer now removes `config.schedule` entirely in that case, 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..bdd121a9a13 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 @@ -238,6 +238,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..021c4dcb2fa 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,41 @@ 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 typed 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('CONTROL: clearing an untyped { expression } still removes the whole schedule', () => { + expect(scheduleWithExpression({ expression: '0 4 * * *' }, '')).toBeUndefined(); + }); + + 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..82d70677e21 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 typed 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,13 @@ 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, an object left empty, or a typed + * block left with no cadence (objectstack#31383). A typed block is judged + * by the published `ScheduleSchema`, which refuses `{ type: 'cron' }` with no + * `expression` (a `timezone` beside it does not make it a cadence); the + * engine binds nothing from it either. 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,6 +1724,7 @@ export function scheduleWithExpression(schedule: unknown, value: unknown): unkno const cadence = normalizeScheduleCadence(schedule); if (cadence && cadence.type !== 'cron') return undefined; delete next.expression; + if (next.type !== undefined && normalizeScheduleCadence(next) === null) return undefined; } else { if (typeof next.type === 'string' && next.type !== 'cron') delete next.type; for (const key of NON_CRON_CADENCE_KEYS) delete next[key]; From 93b35e721597c06609e434250e21edf1bbbe135f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 05:41:55 +0000 Subject: [PATCH 2/3] fix(app-shell): clearing the cron drops any schedule block left with no cadence, untyped { timezone } included Co-Authored-By: Claude --- ...igner-cleared-cron-drops-typed-schedule.md | 2 +- ...-node-config.scheduleCadence-12779.test.ts | 15 ++++++++++- .../inspectors/flow-node-config.ts | 26 +++++++++---------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/.changeset/31383-flow-designer-cleared-cron-drops-typed-schedule.md b/.changeset/31383-flow-designer-cleared-cron-drops-typed-schedule.md index 8717e28b649..1d11b388ed3 100644 --- a/.changeset/31383-flow-designer-cleared-cron-drops-typed-schedule.md +++ b/.changeset/31383-flow-designer-cleared-cron-drops-typed-schedule.md @@ -7,4 +7,4 @@ fix(app-shell): clearing a start node's Cron schedule in the flow designer no lo 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. The designer now removes `config.schedule` entirely in that case, as it already did for an untyped `{ expression }`. Typing a cron over a typed block still keeps its `type` and `timezone`. +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/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 021c4dcb2fa..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 @@ -174,7 +174,7 @@ describe('a typed cron stores a descriptor that resolves to it (objectui#12779)' }); }); -describe('clearing never leaves a typed schedule block with no cadence (objectstack#31383)', () => { +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]> = [ @@ -193,10 +193,23 @@ describe('clearing never leaves a typed schedule block with no cadence (objectst }); } + 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', 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 82d70677e21..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 @@ -527,7 +527,7 @@ const FLOW_NODE_CONFIG: Record = { // 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; clearing it never - // leaves a typed block with no cadence (objectstack#31383). + // 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 @@ -1706,13 +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, an object left empty, or a typed - * block left with no cadence (objectstack#31383). A typed block is judged - * by the published `ScheduleSchema`, which refuses `{ type: 'cron' }` with no - * `expression` (a `timezone` beside it does not make it a cadence); the - * engine binds nothing from it either. So a cleared `{ type: 'cron', - * expression, timezone }` leaves no schedule, as an untyped `{ expression }` - * always did. + * 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 : ''; @@ -1724,13 +1723,12 @@ export function scheduleWithExpression(schedule: unknown, value: unknown): unkno const cadence = normalizeScheduleCadence(schedule); if (cadence && cadence.type !== 'cron') return undefined; delete next.expression; - if (next.type !== undefined && normalizeScheduleCadence(next) === null) return undefined; - } 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; } /** From 3251f97c2157646a242751f9772180e62b86ed49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 06:08:47 +0000 Subject: [PATCH 3/3] test(app-shell): warm @objectstack/lint at collection in the schedule cadence inspector test Co-Authored-By: Claude --- .../FlowNodeInspector.scheduleCadence-12779.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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 bdd121a9a13..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: () => ({}),