From 4e965dfca1c3b2210ac540d07b2892ad906d5d9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 01:46:05 +0000 Subject: [PATCH] fix(app-shell): the flow designer's Cron schedule field reads every cadence form the engine runs (objectui#12779) The start group's Cron schedule field read only `config.schedule.expression` or the flat `config.cron`, so a bare cron string, a `{ cron }` cadence and an interval or once cadence all read empty over a schedule the engine runs. - `normalizeScheduleCadence` mirrors the engine's `normalizeSchedule` (objectstack trigger-schedule), and `getFieldValue` reads the field through it: every cron spelling shows its cron. - An interval or once cadence draws read-only as what it is, through the inspector's existing `disabled` control, with a line saying the designer edits cron cadences only. No cron edit is offered over it. - A commit rewrites `config.schedule` through `scheduleWithExpression`, so the stored descriptor resolves to the typed cron: a bare string stays a string, a stale `cron` is replaced, `type` and `timezone` are kept, and the flat `config.cron` migrate-on-edit is unchanged. Claude-Session: https://claude.ai/code/session_01BJXsVHBzUDeavBF1UUAkRK Co-authored-by: Claude --- .changeset/12779-schedule-cadence-forms.md | 15 + .../src/views/metadata-admin/i18n.ts | 17 ++ ...deInspector.scheduleCadence-12779.test.tsx | 272 ++++++++++++++++++ .../inspectors/FlowNodeInspector.tsx | 51 +++- ...-node-config.scheduleCadence-12779.test.ts | 179 ++++++++++++ .../inspectors/flow-node-config.ts | 180 +++++++++++- 6 files changed, 703 insertions(+), 11 deletions(-) create mode 100644 .changeset/12779-schedule-cadence-forms.md create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.scheduleCadence-12779.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.scheduleCadence-12779.test.ts diff --git a/.changeset/12779-schedule-cadence-forms.md b/.changeset/12779-schedule-cadence-forms.md new file mode 100644 index 0000000000..bafaf1bc37 --- /dev/null +++ b/.changeset/12779-schedule-cadence-forms.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +--- + +Clause-②: no + +The flow designer's **Cron schedule** field on a start node reads every cadence form the engine runs, instead of reading empty over a schedule that runs (objectui#12779). + +The field read only `config.schedule.expression` or the legacy flat `config.cron`. It now reads `config.schedule` the way the engine's schedule trigger resolves it (`normalizeSchedule` in `@objectstack/trigger-schedule`): + +- **A cron, in any spelling, shows as its cron:** a bare string such as `schedule: '0 8 * * *'`, `{ expression }` and `{ cron }`, with `type: 'cron'` or untyped. +- **An interval or once cadence shows as what it is, read-only:** for example "Every 1 min" for `{ type: 'interval', intervalMs: 60000 }`, or "Once, at …" for `{ at }`. A line under it says the designer edits cron schedules only. No cron edit is offered over it, and the stored cadence is kept as it is. +- **Typing a cron stores a schedule the engine resolves to that cron.** A bare string stays a bare string. A stale `cron` key is replaced rather than left beside a new `expression`. A typed cron keeps its `type` and `timezone`. A legacy flat `config.cron` still moves into `config.schedule` on the first edit. + +Nothing else changes: no spec key, prop or export is added, and the canvas's node summary is untouched. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index c1304ac5f0..21123f947e 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -463,6 +463,15 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.flowNode.inactiveRetained': 'Kept, not in effect — its controlling field is off. The value stays saved until you clear it.', 'engine.inspector.flowNode.inactiveRetainedOrphan': 'Kept, not in effect — nothing activates this field. The value stays saved so it is not lost silently.', 'engine.inspector.flowNode.inactiveRetainedClear': 'Clear value', + // objectui#12779 — an interval or once cadence on a start node, drawn read-only. + 'engine.inspector.flowNode.cadenceLabel': 'Schedule', + 'engine.inspector.flowNode.cadenceEveryMs': 'Every {n} ms', + 'engine.inspector.flowNode.cadenceEverySeconds': 'Every {n} s', + 'engine.inspector.flowNode.cadenceEveryMinutes': 'Every {n} min', + 'engine.inspector.flowNode.cadenceEveryHours': 'Every {n} h', + 'engine.inspector.flowNode.cadenceEveryDays': 'Every {n} d', + 'engine.inspector.flowNode.cadenceOnce': 'Once, at {at}', + 'engine.inspector.flowNode.cadenceReadOnly': 'This flow runs on an interval or at a set time, not on a cron. The designer edits cron schedules only, so this schedule is shown as stored and kept unchanged.', 'engine.inspector.flowNode.noConfig': 'No configuration needed for this node type.', 'engine.inspector.flowNode.nestedIdHint': 'A node inside a container region keeps its id here — rename it in the container’s Advanced JSON.', // objectui#10748 — the nested-node breadcrumb's accessible name, and the @@ -3976,6 +3985,14 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.flowNode.inactiveRetained': '已保留,未生效 —— 控制字段处于关闭。该值仍然存储,直到你清除它。', 'engine.inspector.flowNode.inactiveRetainedOrphan': '已保留,未生效 —— 没有任何开关能启用此字段。该值仍然存储,以免静默丢失。', 'engine.inspector.flowNode.inactiveRetainedClear': '清除值', + 'engine.inspector.flowNode.cadenceLabel': '调度', + 'engine.inspector.flowNode.cadenceEveryMs': '每 {n} 毫秒', + 'engine.inspector.flowNode.cadenceEverySeconds': '每 {n} 秒', + 'engine.inspector.flowNode.cadenceEveryMinutes': '每 {n} 分钟', + 'engine.inspector.flowNode.cadenceEveryHours': '每 {n} 小时', + 'engine.inspector.flowNode.cadenceEveryDays': '每 {n} 天', + 'engine.inspector.flowNode.cadenceOnce': '仅一次,于 {at}', + 'engine.inspector.flowNode.cadenceReadOnly': '此流程按固定间隔或在指定时刻运行,而非按 cron。设计器只编辑 cron 调度,因此这里按存储原样显示此调度,且保持不变。', 'engine.inspector.flowNode.noConfig': '此节点类型无需配置。', 'engine.inspector.flowNode.nestedIdHint': '容器区域内的节点 ID 在此只读 —— 请在容器的高级 JSON 中重命名。', 'engine.inspector.flowNode.nestedLocation': '嵌套节点位置', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.scheduleCadence-12779.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.scheduleCadence-12779.test.tsx new file mode 100644 index 0000000000..f8cc5401f3 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.scheduleCadence-12779.test.tsx @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12779 — what the author SEES and STORES through a schedule start + * node's Cron schedule field, over every cadence form the engine runs. + * + * 1. A bare cron string (app-todo's `task_reminder`) and a `{ cron }` cadence, + * typed or untyped, read as their cron in the text box. + * 2. An interval or once cadence (the showcase's `showcase_scheduled_digest`) + * draws read-only as what it is, with the line saying the designer edits + * cron cadences only. No cron edit is offered over it, and opening it + * writes nothing (the seat's ruling on the card's open choice). + * 3. A cron typed over a bare string or over `{ cron }` stores a descriptor + * the engine resolves to that cron. + * 4. CONTROLS: a typed cron with a timezone, the flat `config.cron` + * migrate-on-edit and a time-relative sweep's cadence behave as before. + * + * Fixtures are copied from objectstack `examples/app-showcase` and + * `examples/app-todo`. Wording is not pinned beyond the reading itself: the + * interval and once readings are the value the author reads in place of an + * empty cron, so they are asserted as drawn. + * + * The table half (the engine's forms, the reads and the writes) is pinned in + * `flow-node-config.scheduleCadence-12779.test.ts`. + */ + +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'; + +vi.mock('../previews/useFlowNodePalette', () => ({ + useActionConfigSchemas: () => ({}), + useFlowNodePalette: () => [], +})); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { FlowNodeInspector } from './FlowNodeInspector'; +import { normalizeScheduleCadence } from './flow-node-config'; +import type { MetadataSelection } from '../preview-registry'; + +/* ── The `meta/object` double ───────────────────────────────────────── + * The sweep's `Sweep object` is a reference field, which lists objects via + * `GET /api/v1/meta/object` through the GLOBAL `fetch`. Answered as an empty + * registry by a recording double (the shape + * `FlowNodeInspector.inactiveRetained.test.tsx` documents); any other URL + * fails the test in `afterEach`. + * ──────────────────────────────────────────────────────────── */ + +const META_OBJECT_ROUTE = '/api/v1/meta/object'; +let metaCalls: string[] = []; +const routeOf = (url: string) => url.split('?')[0]; + +beforeEach(() => { + metaCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + metaCalls.push(url); + if (routeOf(url) !== META_OBJECT_ROUTE) { + return { ok: false, status: 404, headers: new Headers(), json: async () => ({}) }; + } + return { ok: true, status: 200, headers: new Headers(), json: async () => ({ type: 'object', items: [] }) }; + }), + ); +}); + +afterEach(() => { + expect(metaCalls.filter((url) => routeOf(url) !== META_OBJECT_ROUTE)).toEqual([]); + cleanup(); + vi.unstubAllGlobals(); +}); + +type Draft = Record; + +/** A schedule flow whose start node stores `config`, shaped as the showcase's digest. */ +function scheduleFlow(config: Record, label = 'Every 60s (demo)'): Draft { + return { + name: 'showcase_scheduled_digest', + label: 'Scheduled Project Digest (interval)', + type: 'schedule', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label, config }, + { + id: 'digest', + type: 'notify', + label: 'Post Digest to Inbox', + config: { + topic: 'project.digest', + recipients: ['admin@objectos.ai'], + channels: ['inbox'], + severity: 'info', + title: 'Scheduled project digest', + message: 'Your periodic project digest is ready — open Projects for the latest health.', + actionUrl: '/showcase_project', + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'digest' }, + { id: 'e2', source: 'digest', target: 'end' }, + ], + }; +} + +/** `showcase_scheduled_digest`'s start config, verbatim. */ +const SHOWCASE_INTERVAL = { schedule: { type: 'interval', intervalMs: 60000 } }; +/** app-todo `task_reminder`'s start config, verbatim. */ +const TODO_BARE_STRING = { schedule: '0 8 * * *', objectName: 'todo_task' }; + +const startConfig = (draft: Draft) => + ((draft.nodes as Array>)[0].config ?? {}) as Record; + +function mount(initial: Draft) { + let current = initial; + const patches: Draft[] = []; + function Host() { + const [draft, setDraft] = React.useState(initial); + return ( + { + patches.push(patch); + setDraft((d) => { + current = { ...d, ...patch }; + return current; + }); + }} + onClearSelection={vi.fn()} + readOnly={false} + locale="en-US" + /> + ); + } + const utils = render(); + return { ...utils, patches, latest: () => current }; +} + +/** The text control under the field labelled `label`. */ +function controlOf(label: string): HTMLInputElement { + const labelEl = screen.getByText(label, { selector: 'label' }); + const input = labelEl.parentElement?.querySelector('input'); + if (!input) throw new Error(`no input under ${label}`); + return input as HTMLInputElement; +} + +describe('a cron cadence in any form reads as its cron (objectui#12779)', () => { + const cases: Array<[string, Record, string]> = [ + ["app-todo's bare string", TODO_BARE_STRING, '0 8 * * *'], + ['a typed { cron }', { schedule: { type: 'cron', cron: '0 5 * * *' } }, '0 5 * * *'], + ['an untyped { cron }', { schedule: { cron: '0 4 * * *' } }, '0 4 * * *'], + ]; + for (const [name, config, cron] of cases) { + it(`${name} shows its cron in an editable Cron schedule box`, () => { + mount(scheduleFlow(config)); + const box = controlOf('Cron schedule'); + expect(box.value).toBe(cron); + expect(box.disabled).toBe(false); + }); + } +}); + +describe('an interval or once cadence draws read-only as what it is (objectui#12779)', () => { + it("the showcase's interval reads as every minute, with no cron edit offered", () => { + const draft = scheduleFlow(structuredClone(SHOWCASE_INTERVAL)); + expect(resolveFlowTriggerKind(draft)).toBe('schedule'); + mount(draft); + expect(screen.queryByText('Cron schedule', { selector: 'label' })).toBeNull(); + const box = controlOf('Schedule'); + expect(box.value).toBe('Every 1 min'); + expect(box.disabled).toBe(true); + expect(screen.getByText(/The designer edits cron schedules only/)).toBeTruthy(); + }); + + const cases: Array<[string, Record, string]> = [ + ['an untyped every', { schedule: { every: 5000 } }, 'Every 5 s'], + ['an interval of 90 s', { schedule: { intervalMs: 90000 } }, 'Every 90 s'], + ['an interval of two hours', { schedule: { type: 'interval', intervalMs: 7_200_000 } }, 'Every 2 h'], + ['an interval in ms', { schedule: { type: 'interval', intervalMs: 1500 } }, 'Every 1500 ms'], + ['a typed once', { schedule: { type: 'once', at: '2026-12-01T08:00:00Z' } }, 'Once, at 2026-12-01T08:00:00Z'], + ['an untyped once', { schedule: { at: '2026-12-01T08:00:00Z' } }, 'Once, at 2026-12-01T08:00:00Z'], + ]; + for (const [name, config, reading] of cases) { + it(`${name} reads "${reading}", read-only`, () => { + mount(scheduleFlow(config)); + const box = controlOf('Schedule'); + expect(box.value).toBe(reading); + expect(box.disabled).toBe(true); + }); + } + + it('opening the showcase node writes nothing, and an edit elsewhere keeps the interval as stored', () => { + const authored = scheduleFlow(structuredClone(SHOWCASE_INTERVAL)); + const before = JSON.stringify(authored); + const { patches, latest, unmount } = mount(authored); + expect(patches).toHaveLength(0); + fireEvent.change(screen.getByDisplayValue('Every 60s (demo)'), { target: { value: 'Every minute' } }); + expect(patches.length).toBeGreaterThan(0); + expect(startConfig(latest()).schedule).toEqual(SHOWCASE_INTERVAL.schedule); + unmount(); + expect(JSON.stringify(authored)).toBe(before); + }); +}); + +describe('a typed cron stores a descriptor the engine resolves to it (objectui#12779)', () => { + it("over app-todo's bare string, the string is replaced by the typed cron", () => { + const { latest } = mount(scheduleFlow(structuredClone(TODO_BARE_STRING))); + fireEvent.change(controlOf('Cron schedule'), { target: { value: '0 9 * * *' } }); + const config = startConfig(latest()); + expect(config.schedule).toBe('0 9 * * *'); + expect(normalizeScheduleCadence(config.schedule)).toEqual({ type: 'cron', expression: '0 9 * * *' }); + expect(config.objectName).toBe('todo_task'); + expect(controlOf('Cron schedule').value).toBe('0 9 * * *'); + }); + + it('over a typed { cron }, the typed cron replaces the stale one', () => { + const { latest } = mount(scheduleFlow({ schedule: { type: 'cron', cron: '0 5 * * *' } })); + fireEvent.change(controlOf('Cron schedule'), { target: { value: '0 9 * * *' } }); + const schedule = startConfig(latest()).schedule; + expect(schedule).toEqual({ type: 'cron', expression: '0 9 * * *' }); + expect(normalizeScheduleCadence(schedule)).toEqual({ type: 'cron', expression: '0 9 * * *' }); + }); + + it('over an untyped { cron }, the typed cron replaces the stale one', () => { + const { latest } = mount(scheduleFlow({ schedule: { cron: '0 4 * * *' } })); + fireEvent.change(controlOf('Cron schedule'), { target: { value: '0 9 * * *' } }); + expect(startConfig(latest()).schedule).toEqual({ expression: '0 9 * * *' }); + }); +}); + +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' } })); + expect(controlOf('Cron schedule').value).toBe('0 7 * * *'); + fireEvent.change(controlOf('Cron schedule'), { target: { value: '0 9 * * *' } }); + expect(startConfig(latest()).schedule).toEqual({ type: 'cron', expression: '0 9 * * *', timezone: 'Asia/Shanghai' }); + }); + + it('a legacy flat config.cron reads, and moves into config.schedule on edit', () => { + const { latest } = mount(scheduleFlow({ triggerType: 'schedule', cron: '0 7 * * *' })); + expect(controlOf('Cron schedule').value).toBe('0 7 * * *'); + fireEvent.change(controlOf('Cron schedule'), { target: { value: '0 9 * * *' } }); + const config = startConfig(latest()); + expect(config.schedule).toEqual({ expression: '0 9 * * *' }); + expect('cron' in config).toBe(false); + }); + + it("a time-relative sweep's cadence reads as its cron, and opening it writes nothing", () => { + const sweep = { + timeRelative: { object: 'showcase_task', dateField: 'due_date', offsetDays: [3, 1] }, + schedule: { type: 'cron', expression: '0 8 * * *' }, + }; + const draft = scheduleFlow(structuredClone(sweep), 'Daily Sweep'); + expect(resolveFlowTriggerKind(draft)).toBe('time_relative'); + const { patches, latest } = mount(draft); + expect(controlOf('Cron schedule').value).toBe('0 8 * * *'); + expect(patches).toHaveLength(0); + fireEvent.change(controlOf('Cron schedule'), { target: { value: '0 6 * * *' } }); + expect(startConfig(latest()).schedule).toEqual({ type: 'cron', expression: '0 6 * * *' }); + expect(startConfig(latest()).timeRelative).toEqual(sweep.timeRelative); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx index 4fe1891453..f23e39c4ec 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx @@ -43,7 +43,11 @@ import { switchedBlockOf, isBareSwitchedOffBlock, configKeyOf, + isScheduleExpressionPath, + readOnlyScheduleCadence, + scheduleWithExpression, type FlowConfigField, + type ScheduleCadence, } from './flow-node-config.js'; import { declaredConfigKeys, jsonSchemaToFlowFields } from './json-schema-to-fields.js'; import { @@ -169,6 +173,25 @@ function setAtPath(obj: Record, path: string[], value: unknown) return next; } +/** + * An interval or once cadence as the read-only Cron schedule field shows it + * (objectui#12779): an interval in the largest whole unit it divides into. + */ +function describeCadence(cadence: Exclude, locale: string | undefined): string { + if (cadence.type === 'once') return tFormat('engine.inspector.flowNode.cadenceOnce', locale, { at: cadence.at }); + const ms = cadence.intervalMs; + const units: ReadonlyArray<[number, string]> = [ + [86_400_000, 'engine.inspector.flowNode.cadenceEveryDays'], + [3_600_000, 'engine.inspector.flowNode.cadenceEveryHours'], + [60_000, 'engine.inspector.flowNode.cadenceEveryMinutes'], + [1_000, 'engine.inspector.flowNode.cadenceEverySeconds'], + ]; + for (const [size, key] of units) { + if (ms % size === 0) return tFormat(key, locale, { n: ms / size }); + } + return tFormat('engine.inspector.flowNode.cadenceEveryMs', locale, { n: ms }); +} + /** * The node's ID field (objectui#11827). Unlike the panel's other text fields it * holds what the author types as a DRAFT and commits only a finished id — on @@ -507,7 +530,13 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection, // instead, which says the same OFF. `setAtPath` deletes only the block — // sibling config keys are not rebuilt — and prunes a `config` left empty. const switched = switchedBlockOf(node, field); - let nextNode = setAtPath(node, path, stored); + // objectui#12779 — the Cron schedule commit rewrites the whole + // `config.schedule`, so a bare string or `{ cron }` cadence stores the + // typed cron where the engine reads it, rather than an `expression` + // merged beside the key the engine resolves first. + let nextNode = isScheduleExpressionPath(path) + ? setAtPath(node, path.slice(0, -1), scheduleWithExpression(config.schedule, stored)) + : setAtPath(node, path, stored); if (switched && isBareSwitchedOffBlock(nextNode, switched)) { nextNode = setAtPath(nextNode, [...switched.block], undefined); } @@ -729,6 +758,10 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection, // what the runtime applies while the node stores nothing and writes // nothing. Render-only — the gates read `fields`, never this copy. const triggerReading = unsetTriggerReading(field, node, flowKind); + // objectui#12779 — an interval or once cadence is shown as what it is, + // through the same `disabled` control a read-only flow draws, and is + // never offered as a cron: the designer edits cron cadences only. + const cadence = readOnlyScheduleCadence(node, field); // A NESTED node can't wire top-level edges, so drop the virtual Target // column from its Branches editor and skip the withBranchTargets augment // (its region routing is out of scope this phase). @@ -737,11 +770,19 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection, ? { ...field, columns: (field.columns ?? []).filter((c) => c.key !== 'target') } : triggerReading !== undefined ? { ...field, defaultValue: triggerReading } - : field; + : cadence + ? { + ...field, + label: t('engine.inspector.flowNode.cadenceLabel', locale), + help: t('engine.inspector.flowNode.cadenceReadOnly', locale), + placeholder: undefined, + } + : field; // The Branches editor's Target column (#1942) is virtual: derived from the // node's out-edges, never stored on the branch rows (top-level only). - const value = - branchTarget && !nested + const value = cadence + ? describeCadence(cadence, locale) + : branchTarget && !nested ? withBranchTargets( node.id, getFieldValue(node, field), @@ -761,7 +802,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection, field={effField} value={value} onCommit={(v) => setField(field, v)} - disabled={readOnly} + disabled={readOnly || !!cadence} locale={locale} context={{ draft, node }} scopeGroups={scopeGroups} diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.scheduleCadence-12779.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.scheduleCadence-12779.test.ts new file mode 100644 index 0000000000..279e426719 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.scheduleCadence-12779.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12779 — the start group's Cron schedule field reads every cadence + * form the engine runs, and its write stores a descriptor that resolves to the + * cron it then shows. + * + * The table of forms below is the engine's: `normalizeSchedule` in objectstack + * `packages/triggers/trigger-schedule/src/schedule-trigger.ts` (read at + * objectstack `570c7a2d95`), which the schedule trigger and the time-relative + * sweep both call on a start node's `config.schedule`. Each row names what that + * function returns for the stored value; `normalizeScheduleCadence` mirrors it + * because this package takes no dependency on a `@objectstack/trigger-*` + * package. The write cases assert the stored descriptor itself, not only its + * resolution, so they do not lean on the mirror alone. + * + * The rendered half (the read-only interval and once reading, and the field's + * write through the inspector) is pinned in + * `FlowNodeInspector.scheduleCadence-12779.test.tsx`. + */ + +import { describe, it, expect } from 'vitest'; +import { + fieldsForNodeType, + getFieldValue, + normalizeScheduleCadence, + readOnlyScheduleCadence, + scheduleWithExpression, +} from './flow-node-config'; + +const expr = fieldsForNodeType('start').find((f) => f.id === 'schedule.expression')!; +const startWith = (config: Record) => ({ id: 'start', type: 'start', config }); + +/** Every stored form, and what the engine's `normalizeSchedule` returns for it. */ +const FORMS: Array<{ name: string; stored: unknown; engine: unknown }> = [ + { name: 'a bare cron string', stored: '0 8 * * *', engine: { type: 'cron', expression: '0 8 * * *' } }, + { name: 'a blank string', stored: ' ', engine: null }, + { name: 'typed { expression }', stored: { type: 'cron', expression: '0 7 * * *' }, engine: { type: 'cron', expression: '0 7 * * *' } }, + { + name: 'typed { expression } with a timezone', + stored: { type: 'cron', expression: '0 7 * * *', timezone: 'Asia/Shanghai' }, + engine: { type: 'cron', expression: '0 7 * * *', timezone: 'Asia/Shanghai' }, + }, + { name: 'untyped { expression }', stored: { expression: '0 6 * * *' }, engine: { type: 'cron', expression: '0 6 * * *' } }, + { name: 'typed { cron }', stored: { type: 'cron', cron: '0 5 * * *' }, engine: { type: 'cron', expression: '0 5 * * *' } }, + { name: 'untyped { cron }', stored: { cron: '0 4 * * *' }, engine: { type: 'cron', expression: '0 4 * * *' } }, + { + name: '{ expression } beside { cron }: expression wins', + stored: { expression: '0 3 * * *', cron: '0 2 * * *' }, + engine: { type: 'cron', expression: '0 3 * * *' }, + }, + { name: 'typed cron with no value', stored: { type: 'cron' }, engine: null }, + { name: 'typed { intervalMs } (the showcase)', stored: { type: 'interval', intervalMs: 60000 }, engine: { type: 'interval', intervalMs: 60000 } }, + { name: 'untyped { intervalMs }', stored: { intervalMs: 30000 }, engine: { type: 'interval', intervalMs: 30000 } }, + { name: 'untyped { every }', stored: { every: 5000 }, engine: { type: 'interval', intervalMs: 5000 } }, + { name: 'a non-positive interval', stored: { type: 'interval', intervalMs: -1 }, engine: null }, + { name: 'typed { at }', stored: { type: 'once', at: '2026-12-01T08:00:00Z' }, engine: { type: 'once', at: '2026-12-01T08:00:00Z' } }, + { name: 'untyped { at }', stored: { at: '2026-12-01T08:00:00Z' }, engine: { type: 'once', at: '2026-12-01T08:00:00Z' } }, + { + name: 'an interval type beside an expression: the type wins', + stored: { type: 'interval', intervalMs: 60000, expression: '0 8 * * *' }, + engine: { type: 'interval', intervalMs: 60000 }, + }, + { name: 'an unknown type', stored: { type: 'weekly', expression: '0 8 * * *' }, engine: null }, + { name: 'a number', stored: 60000, engine: null }, +]; + +describe("the engine's cadence forms (normalizeSchedule, objectui#12779)", () => { + for (const form of FORMS) { + it(`${form.name} resolves as the engine resolves it`, () => { + expect(normalizeScheduleCadence(form.stored)).toEqual(form.engine); + }); + } +}); + +describe('the Cron schedule field reads every cron form as its cron (objectui#12779)', () => { + const cases: Array<[string, unknown, string]> = [ + ['a bare string', '0 8 * * *', '0 8 * * *'], + ['a typed { cron }', { type: 'cron', cron: '0 5 * * *' }, '0 5 * * *'], + ['an untyped { cron }', { cron: '0 4 * * *' }, '0 4 * * *'], + ['an untyped { expression }', { expression: '0 6 * * *' }, '0 6 * * *'], + ]; + for (const [name, schedule, cron] of cases) { + it(`${name} reads as its cron and draws as an editable cron`, () => { + const node = startWith({ schedule }); + expect(getFieldValue(node, expr)).toBe(cron); + expect(readOnlyScheduleCadence(node, expr)).toBeUndefined(); + }); + } + + it('CONTROL: a typed cron with a timezone reads its expression, unchanged', () => { + const node = startWith({ schedule: { type: 'cron', expression: '0 7 * * *', timezone: 'Asia/Shanghai' } }); + expect(getFieldValue(node, expr)).toBe('0 7 * * *'); + }); + + it('CONTROL: a legacy flat config.cron still reads through the fallback', () => { + expect(getFieldValue(startWith({ cron: '0 7 * * *' }), expr)).toBe('0 7 * * *'); + // Without the fallback declared, the flat key is not read: the engine never reads it. + expect(getFieldValue(startWith({ cron: '0 7 * * *' }), { ...expr, fallbackPath: undefined })).toBeUndefined(); + }); + + it('an empty node reads nothing', () => { + expect(getFieldValue(startWith({}), expr)).toBeUndefined(); + expect(getFieldValue({ id: 'start', type: 'start' }, expr)).toBeUndefined(); + }); +}); + +describe('an interval or once cadence is read as what it is, never as a cron (objectui#12779)', () => { + const cases: Array<[string, unknown, unknown]> = [ + ["the showcase's interval", { type: 'interval', intervalMs: 60000 }, { type: 'interval', intervalMs: 60000 }], + ['an untyped every', { every: 5000 }, { type: 'interval', intervalMs: 5000 }], + ['a typed once', { type: 'once', at: '2026-12-01T08:00:00Z' }, { type: 'once', at: '2026-12-01T08:00:00Z' }], + ['an untyped once', { at: '2026-12-01T08:00:00Z' }, { type: 'once', at: '2026-12-01T08:00:00Z' }], + ]; + for (const [name, schedule, cadence] of cases) { + it(`${name} draws read-only, and the field holds the cadence rather than an empty cron`, () => { + const node = startWith({ schedule }); + expect(readOnlyScheduleCadence(node, expr)).toEqual(cadence); + expect(getFieldValue(node, expr)).toEqual(cadence); + }); + } + + it('the read-only reading is the Cron schedule field only', () => { + const node = startWith({ schedule: { type: 'interval', intervalMs: 60000 } }); + const other = fieldsForNodeType('start').find((f) => f.id === 'condition')!; + expect(readOnlyScheduleCadence(node, other)).toBeUndefined(); + }); +}); + +describe('a typed cron stores a descriptor that resolves to it (objectui#12779)', () => { + const resolvesTo = (stored: unknown, cron: string) => + expect(normalizeScheduleCadence(stored)).toMatchObject({ type: 'cron', expression: cron }); + + it('over a bare string, the string is replaced', () => { + const stored = scheduleWithExpression('0 8 * * *', '0 9 * * *'); + expect(stored).toBe('0 9 * * *'); + resolvesTo(stored, '0 9 * * *'); + }); + + it('over a typed { cron }, the typed cron replaces the stale cron', () => { + const stored = scheduleWithExpression({ type: 'cron', cron: '0 5 * * *' }, '0 9 * * *'); + expect(stored).toEqual({ type: 'cron', expression: '0 9 * * *' }); + resolvesTo(stored, '0 9 * * *'); + }); + + it('over an untyped { cron }, the typed cron replaces the stale cron', () => { + const stored = scheduleWithExpression({ cron: '0 4 * * *' }, '0 9 * * *'); + expect(stored).toEqual({ expression: '0 9 * * *' }); + resolvesTo(stored, '0 9 * * *'); + }); + + it('over nothing, a new { expression } is stored', () => { + expect(scheduleWithExpression(undefined, '0 9 * * *')).toEqual({ expression: '0 9 * * *' }); + expect(scheduleWithExpression({}, '0 9 * * *')).toEqual({ expression: '0 9 * * *' }); + }); + + it('over a descriptor the engine binds nothing from, the cron wins over the leftover type', () => { + const stored = scheduleWithExpression({ type: 'interval' }, '0 9 * * *'); + expect(stored).toEqual({ expression: '0 9 * * *' }); + resolvesTo(stored, '0 9 * * *'); + }); + + it('CONTROL: a typed cron with a timezone keeps its type and timezone', () => { + const stored = scheduleWithExpression({ type: 'cron', expression: '0 7 * * *', timezone: 'Asia/Shanghai' }, '0 9 * * *'); + expect(stored).toEqual({ type: 'cron', expression: '0 9 * * *', timezone: 'Asia/Shanghai' }); + expect(normalizeScheduleCadence(stored)).toEqual({ type: 'cron', expression: '0 9 * * *', timezone: 'Asia/Shanghai' }); + }); + + it('clearing removes the cron, or the whole schedule when nothing is left', () => { + expect(scheduleWithExpression('0 8 * * *', '')).toBeUndefined(); + 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({ + type: 'cron', + timezone: 'UTC', + }); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index b36a4f39cb..b7e2057f5b 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -511,9 +511,16 @@ const FLOW_NODE_CONFIG: Record = { // field used to write a FLAT `config.cron`, which the backend never reads — so // designer-authored scheduled flows silently never bound. `fallbackPath` // migrates an existing flat `config.cron` to `config.schedule.expression` on - // first edit; reading `.expression` also renders an object-shaped schedule as - // its cron string instead of "[object Object]". Any sibling keys the runtime - // set (`type`, `timezone`) are preserved through edits (setAtPath merges). + // first edit. + // + // objectui#12779 — the path is this field's address, not how it reads: + // `getFieldValue` reads `config.schedule` the way the engine resolves it + // (`scheduleCadenceOf`), so a bare string, `{ expression }` and `{ cron }` + // all show their cron. An interval or once cadence draws read-only as what + // 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. // // objectui#12672 — gated on the flow's RESOLVED kind as well as on the // select: a `type: 'schedule'` flow, or a start node carrying @@ -604,9 +611,10 @@ const FLOW_NODE_CONFIG: Record = { // Legacy `criteria` key — rendered only when present so older metadata never // falls back to raw JSON. Prefer `condition` above for new flows. (There is no // legacy `schedule` text field: the `schedule.expression` field above owns the - // whole `config.schedule` block and reads its `.expression`, so a bare-string - // or object-shaped schedule renders through it — a raw text field on - // `config.schedule` would print "[object Object]" for the object shape.) + // whole `config.schedule` block and reads it as the engine resolves it + // (`scheduleCadenceOf`), so a bare-string or object-shaped schedule renders + // through it — a raw text field on `config.schedule` would print + // "[object Object]" for the object shape.) cfg('criteria', 'Entry condition (legacy)', 'expression', { placeholder: 'status == "active"', help: 'Legacy key — prefer "Entry condition" (condition).', @@ -1538,6 +1546,9 @@ export function localizeFlowFields( /** Read the current value at a field's node path, falling back to `fallbackPath`. */ export function getFieldValue(node: Record | null | undefined, field: FlowConfigField): unknown { + // objectui#12779 — the start node's cadence is read the way the engine reads + // it, not by walking a path a bare string or `{ cron }` does not have. + if (isScheduleExpressionPath(field.path)) return scheduleFieldValue(node, field); const read = (path: string[]): unknown => { let cur: unknown = node; for (const seg of path) { @@ -1551,6 +1562,163 @@ export function getFieldValue(node: Record | null | undefined, return field.fallbackPath ? read(field.fallbackPath) : undefined; } +/** + * A start node's cadence, in the three shapes the engine runs (objectui#12779). + */ +export type ScheduleCadence = + | { type: 'cron'; expression: string; timezone?: string } + | { type: 'interval'; intervalMs: number } + | { type: 'once'; at: string }; + +/** The path of the start group's Cron schedule field. */ +const SCHEDULE_EXPRESSION_PATH: readonly string[] = ['config', 'schedule', 'expression']; + +/** Whether `path` is the start group's Cron schedule field's (objectui#12779). */ +export function isScheduleExpressionPath(path: readonly string[]): boolean { + return path.length === SCHEDULE_EXPRESSION_PATH.length && path.every((seg, i) => seg === SCHEDULE_EXPRESSION_PATH[i]); +} + +/** + * Resolve a stored `config.schedule` to the cadence the engine runs, or `null` + * when the engine binds none (objectui#12779). + * + * A mirror of `normalizeSchedule` in objectstack + * `packages/triggers/trigger-schedule/src/schedule-trigger.ts`, which the + * schedule trigger calls on `config.schedule` when it binds a flow. It is + * mirrored rather than imported: this package takes no dependency on a + * `@objectstack/trigger-*` package. Every branch and its order are the + * engine's, so a form the engine accepts reads here and a form it refuses + * reads `null`: + * - a bare cron string; + * - `{ expression }` or `{ cron }`, with `type: 'cron'` or untyped + * (`expression` wins over `cron`; `timezone` rides along); + * - `{ intervalMs }` or `{ every }`, with `type: 'interval'` or untyped; + * - `{ at }`, with `type: 'once'` or untyped. + * A `type` the engine does not know, or a typed descriptor missing its value, + * resolves to nothing. + */ +export function normalizeScheduleCadence(raw: unknown): ScheduleCadence | null { + if (raw == null) return null; + if (typeof raw === 'string') { + const expr = raw.trim(); + return expr ? { type: 'cron', expression: expr } : null; + } + if (typeof raw !== 'object') return null; + const s = raw as Record; + const type = typeof s.type === 'string' ? s.type : undefined; + if (type === 'cron' || (!type && (typeof s.cron === 'string' || typeof s.expression === 'string'))) { + const expression = + (typeof s.expression === 'string' && s.expression) || (typeof s.cron === 'string' && s.cron) || undefined; + if (!expression) return null; + const out: ScheduleCadence = { type: 'cron', expression }; + if (typeof s.timezone === 'string') out.timezone = s.timezone; + return out; + } + if (type === 'interval' || (!type && (typeof s.intervalMs === 'number' || typeof s.every === 'number'))) { + const intervalMs = + (typeof s.intervalMs === 'number' && s.intervalMs) || (typeof s.every === 'number' && s.every) || undefined; + if (!intervalMs || intervalMs <= 0) return null; + return { type: 'interval', intervalMs }; + } + if (type === 'once' || (!type && typeof s.at === 'string')) { + const at = typeof s.at === 'string' ? s.at : undefined; + if (!at) return null; + return { type: 'once', at }; + } + return null; +} + +function configOfNode(node: Record | null | undefined): Record | undefined { + const c = node?.config; + return c && typeof c === 'object' && !Array.isArray(c) ? (c as Record) : undefined; +} + +/** + * The cadence a start node's Cron schedule field stands for (objectui#12779): + * its `config.schedule` as the engine resolves it, else — only for a field + * that declares the `fallbackPath` — the legacy flat `config.cron` the field + * migrates on first edit. The engine never reads that flat key; the designer + * shows it so an author can move it into place. + */ +export function scheduleCadenceOf( + node: Record | null | undefined, + field?: Pick, +): ScheduleCadence | null { + const config = configOfNode(node); + const cadence = normalizeScheduleCadence(config?.schedule); + if (cadence || (field && !field.fallbackPath)) return cadence; + const flat = config?.cron; + return typeof flat === 'string' && flat.trim() !== '' ? { type: 'cron', expression: flat } : null; +} + +/** + * What the Cron schedule field holds: a cron cadence's expression; an interval + * or once cadence itself, which the designer shows read-only and never offers + * as a cron (the seat's ruling on objectui#12779); or, when the engine binds + * nothing, any `expression` string stored on an object-shaped schedule, so a + * half-written descriptor still reads back what was typed into it. + */ +function scheduleFieldValue(node: Record | null | undefined, field: FlowConfigField): unknown { + const cadence = scheduleCadenceOf(node, field); + if (cadence) return cadence.type === 'cron' ? cadence.expression : cadence; + const schedule = configOfNode(node)?.schedule; + if (schedule && typeof schedule === 'object' && !Array.isArray(schedule)) { + const expression = (schedule as Record).expression; + if (expression !== undefined) return expression; + } + return undefined; +} + +/** + * The cadence the Cron schedule field draws read-only, or `undefined` when the + * field is an ordinary cron text box (objectui#12779). Only an interval or once + * cadence: the designer edits cron cadences only. + */ +export function readOnlyScheduleCadence( + node: Record | null | undefined, + field: Pick, +): Exclude | undefined { + if (!isScheduleExpressionPath(field.path)) return undefined; + const cadence = scheduleCadenceOf(node, field); + return cadence && cadence.type !== 'cron' ? cadence : undefined; +} + +/** The keys a non-cron cadence is spelt with, which a written cron replaces. */ +const NON_CRON_CADENCE_KEYS = ['intervalMs', 'every', 'at'] as const; + +/** + * The `config.schedule` a Cron schedule commit stores (objectui#12779), so the + * stored descriptor resolves, through the engine's `normalizeSchedule`, to the + * cron the field then shows. `undefined` removes the schedule. + * + * - A bare string stays a bare string: the typed cron replaces it. + * - No schedule yet stores `{ expression }`, as the field always has. + * - An object keeps its other keys (`type: 'cron'`, `timezone`) and takes the + * typed cron as `expression`; a stale `cron` beside it is dropped, as is a + * `type` or value key the engine would resolve ahead of the cron. Those are + * 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. + */ +export function scheduleWithExpression(schedule: unknown, value: unknown): unknown { + const expression = typeof value === 'string' ? value : ''; + if (typeof schedule === 'string') return expression === '' ? undefined : expression; + const isObject = !!schedule && typeof schedule === 'object' && !Array.isArray(schedule); + const next: Record = isObject ? { ...(schedule as Record) } : {}; + delete next.cron; + if (expression === '') { + 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 Object.keys(next).length === 0 ? undefined : next; +} + /** * Boolean gates whose spec meaning is "the enclosing block EXISTS" * (objectui#11660), keyed by node type and the gate's path.