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
15 changes: 15 additions & 0 deletions .changeset/12779-schedule-cadence-forms.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,15 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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
Expand Down Expand Up @@ -3976,6 +3985,14 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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': '嵌套节点位置',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** A schedule flow whose start node stores `config`, shaped as the showcase's digest. */
function scheduleFlow(config: Record<string, unknown>, 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<Record<string, unknown>>)[0].config ?? {}) as Record<string, unknown>;

function mount(initial: Draft) {
let current = initial;
const patches: Draft[] = [];
function Host() {
const [draft, setDraft] = React.useState<Draft>(initial);
return (
<FlowNodeInspector
type="flow"
name={String(initial.name)}
draft={draft}
selection={{ kind: 'node', id: 'start' } as MetadataSelection}
onPatch={(patch) => {
patches.push(patch);
setDraft((d) => {
current = { ...d, ...patch };
return current;
});
}}
onClearSelection={vi.fn()}
readOnly={false}
locale="en-US"
/>
);
}
const utils = render(<Host />);
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, unknown>, 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, unknown>, 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);
});
});
Loading
Loading