Skip to content

Commit 31add5f

Browse files
fix(app-shell): notify recipients render online and are picked; record field values pick their field; New automation asks for the trigger (objectui#11788) (#11875)
Fixes #11788 Clause-②: no Studio can now build a "notify the owner when the ticket is done" flow without editing JSON. This PR closes the three gaps on the card. Starter templates are not part of it: triage comment 6043731723 leaves them out. ## What changes 1. **Notify recipients render online, and the same fix restores the http node's Headers and Body.** With a backend, the notify form had no Recipients field, and the node's `recipients: []` seed sat in Advanced (JSON). Now a config key that the published descriptor declares, but that the JSON-Schema mapper cannot type, keeps the offline table's editor. The editor appears at the key's declared position and carries the descriptor's own title and description. A key the descriptor does not declare is never brought back, because the engine rejects undeclared config keys at `registerFlow()`. 2. **Recipients are picked, not typed.** Each row picks a type and then a value. The types are a field of the trigger record, a user, a team or an email address. The picker stores only spellings the platform already reads (see H2). 3. **Record field values pick their field.** On `create_record` and `update_record`, the key cell of `config.fields` lists the fields of the node's `config.objectName`. The key stays free text. The value cell is untouched and keeps the slot's own dialect, including the text / CEL toggle the spec's expression ledger declares for `fields.*`. 4. **New automation asks for the trigger.** The dialog offers the Start node's own trigger field, using its options and labels. It asks for the object when the Start node would show its Object field for that trigger. The choice is written where the Start node's field writes it. Left unset, the flow is created as before. ## Measured first ### H1: why the Recipients field was missing The PM's candidate was close but not exact: an **untyped** key is dropped, not an array-typed one. - **Code path.** The inspector's `fields` memo in `FlowNodeInspector.tsx` decides the fields of a registry-described node. It runs `jsonSchemaToFlowFields(configSchemas[type])`, and then `mergeServerFlowFields` lets the mapped fields replace every config-rooted field of the offline table. - **The published descriptor.** The notify `configSchema` declares `recipients` with a description and **no `type`**. That is objectstack `registerNotifyNode` in `service-automation` `builtin/notify-node.ts`, read on objectstack `main` `51290bca`. The contract (`NotifyConfigSchema.recipients`) takes a string or a string array. - **What the mapper does.** It skips untyped properties ("unrepresentable, so Advanced"). The merge then dropped the offline `recipients` row. Array-typed `channels` did map, to `stringList`. - **Probe on the base tree.** Feeding that descriptor shape to the real inspector, before the fix: - offline: Recipients label count 1, and Advanced holds nothing; - engine: Recipients label count 0, and Advanced holds the `recipients: []` JSON. - **Census of every builtin descriptor `configSchema` in `service-automation` at `51290bca`.** These are the declared keys the mapper drops: - notify: `recipients`, `templateData`, `payload`; - http: `headers` (an object with neither `properties` nor `additionalProperties`) and `body` (untyped); - loop: `body`; - try_catch: `try`, `catch`. Of these, only `recipients`, `headers` and `body` have an offline editor. Those three are what the merge rule restores. The others stay in Advanced, as before. Keeping the editor for a declared-but-unmapped key is one rule that closes this whole class, rather than a fix for `recipients` alone. ### H2: the recipients grammar - `NotifyConfigSchema.recipients` is a string or a string array. The notify executor interpolates `{token}` templates in it and hands each resolved string to the messaging service as an audience spec. - objectstack `service-messaging` `RecipientResolver` (ADR-0030) reads these specs: - a bare user id, or `user:ID`; - an email-shaped value, resolved to a `sys_user`; - `role:NAME`; - `team:ID`, which expands through `sys_team_member.team_id`; - `owner_of:OBJECT:ID`. - The picker writes these spellings: | Type | Stored as | |---|---| | Record field | `{record.FIELD}` | | User | the bare user id (ADR-0030's "explicit ids") | | Team | `team:ID` | | Email | the address | The user and team lookups are the approval picker's own `sys_user.id` and `sys_team.id` bindings, from the spec's `APPROVER_VALUE_SOURCES`. - A user can be spelled two ways. Both are documented and resolve to the same recipient, so the schema does not read two ways that store different values. The picker writes the bare id and reads both. - Every other stored value is shown under "Other" and saved byte for byte. That covers a flow variable such as `{ownerId}`, `{task.owner}`, a dotted `{record.a.b}`, `role:`, `owner_of:` and `user:ID`. ### H3: the trigger in the New dialog The H3 premise is only partly true. - No trigger descriptor registry exists to read. ADR-0041 section 4 is not implemented on objectstack `main`: `maturity` exists only on `ActionDescriptorSchema`, no `start` descriptor is registered, and no route serves trigger descriptors. - The Start node's trigger picker reads the offline `start` field `triggerType`, through `fieldsForNodeType('start')`. The flow header pill already reads it the same way. - The dialog reads that same field. It writes the shape the Start node's select writes: `config.triggerType`, plus `config.objectName` when the trigger watches an object. ### H4: the field source `useObjectFields(objectName)` in `previews/useObjectFields.ts` is the source that the reference picker's `object-field` kind already reads. - The picker is keyed by node type and path (`create_record` and `update_record`, `config.fields`), the same way `BLOCK_SWITCHES` is keyed. That way it holds for both descriptor sources. The published `fields` is `additionalProperties: true`, which the mapper turns into `keyValue`. - The value dialect is unchanged. objectui#11824 and objectstack-ai/objectstack#19939 own any change to it. ## Files and regions beyond the claim's list The claim listed regions inside these files. The measured cause sits in neighbouring regions of the same files, named here: - `inspectors/flow-node-config.ts`: - the `FlowConfigFieldKind` union gains `recipients`; - `mergeServerFlowFields` gains the declared-key rule (H1). This is the class fix, and the http restore comes with it. - `inspectors/FlowNodeInspector.tsx`: one line in the `fields` memo passes the declared keys. This is not the ID-field rename path that objectui#11838 owned (now landed). - `inspectors/json-schema-to-fields.ts`: `declaredConfigKeys`, the declared key list with each key's title and description. - `inspectors/FlowKeyValueField.tsx`: the editor the field-value rows render through. It gains optional `keyOptions` and `keyHint`. Every other map renders as before. - `inspectors/FlowNodeConfigField.tsx`: the `recipients` case, the `keyValue` case's field-value map wrapper, and the path table. - `studio-design/StudioDesignSurface.tsx`: - the Automations pillar's New dialog (`extra` slot), `doCreateFlow`, and the New button's reset of the two new state values; - two module-level helpers, `newFlowTriggerFields` and `withStartTrigger`, just above `AutomationsPillar`; - two imports. `CreateItemDialog.tsx` is not edited. Its existing `extra` slot carries the trigger choice. - New file: `inspectors/FlowRecipientsField.tsx`. It is the one module-private picker the claim allows. - Tests: four new files, plus one updated expectation in `flow-node-config.test.ts` (the notify recipients kind). - `.changeset/11788-notify-recipients-picker.md`: an `@object-ui/app-shell` patch. i18n: 17 new rows, each in `en` and `zh`, under prefixes no in-flight card uses: - `engine.inspector.recipients.*` - `engine.inspector.fieldMap.*` - `engine.studio.newAutoTrigger.*` The notify `recipients` zh help row is also updated. Nothing is added to the package entry: no export, prop, `@object-ui/types` member or `packages/i18n` key. ## Tests and gates The head is `8c156f6`, which merges `origin/main` `b0aa892`. - **The full set below was run on `3c844f8`,** which merged `4e7dd56`. - **The last merge brought three PRs.** Of the files this branch touches, they overlap only `i18n.ts`. On `8c156f6` I re-ran: - the i18n, citation, control-byte and changeset gates (all exit 0, with the verdict lines below); - `pnpm --filter @object-ui/app-shell type-check` (exit 0); - 8 test files: the four new pins, `flow-node-config.test.ts`, `notify-node.dogfood.test.tsx`, `StudioDesignSurface.flowStatusTruth-11779.test.tsx` and `AutomationsPillar.authorRefusal-11785.test.tsx`. Result: `Test Files 8 passed (8)`, `Tests 94 passed (94)`. - **CI runs the full set on the head.** Each command below was run from the repo root, through the container's verify lock, against `3c844f8`, unless noted. **Tests** | Command | Result | |---|---| | `pnpm --filter @object-ui/app-shell type-check`, after `turbo run build --filter=@object-ui/app-shell^...` | exit 0 (`tsc --noEmit` and `tsc -p tsconfig.test.json`) | | `pnpm exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/` plus the 9 other metadata-admin tests that read `flow-node-config` | `Test Files 147 passed (147)`, `Tests 2050 passed, 1 skipped (2051)` | | `pnpm exec vitest run packages/app-shell/src/views/studio-design/` | `Test Files 103 passed (103)`, `Tests 648 passed (648)` | The test type-check program lists all four new test files. That was counted with `tsc -p tsconfig.test.json --listFilesOnly`: 4 of 4. **The four new pins** - `FlowNodeInspector.declaredUnmapped-11788.test.tsx` - `FlowRecipientsField.test.tsx` - `FlowNodeInspector.fieldValueMap-11788.test.tsx` - `StudioDesignSurface.newFlowTrigger-11788.test.tsx` **Gates (each verdict line as printed)** - `pnpm check:i18n-designer-parity`: "Every en row has a zh row, and every shared row carries the same placeholders." - `pnpm check:i18n-keys`: "Every in-scope call-site key resolves against the en pack (3300 keys)…" - `pnpm check:i18n-drift`: "No designer-table en value changed in this range." It also reports 17 designer keys added. - `pnpm check:i18n-dead-keys` is a report. None of the 17 new keys is listed in it. - `pnpm check:new-line-citations`: "VERDICT new-cross-file-line-citations: 0 new citation(s)" - `pnpm check:control-bytes`: "OK" - `pnpm check:designer-field-key-parity`: "OK" - `node scripts/check-changeset-presence.mjs`: "13 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)" - `node scripts/check-changeset-no-major.mjs`: "No changeset declares a `major` bump." - Also exit 0: - `check:changeset-claims`, `check:pending-changeset-literals`; - `check-changeset-fixed`, `check-changeset-overwrite`; - `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`; - `check:test-path-roots`, `check:unreferenced-sources`, `check:spec-symbols`; - `check:esm-specifiers`, `check:phantom-deps`; - `check:metadata-write-doors`, `check:handler-key-reads`; - `check-type-check-coverage`, `check-lint-coverage`. **ESLint on the touched files: 0 errors, 26 warnings** This is a narrowed lint run, not the repo-wide one. - The population was every touched `.ts` / `.tsx` file. The `--format json` report counts 13 files, matching the 13 touched; none was ignored. - Invariance: `eslint.config.js` enables no type-aware linting (no `projectService` and no `parserOptions.project`). So this diff cannot change a verdict on a file it does not touch. - Warning delta against `main` `4e7dd56`: - unchanged: `FlowKeyValueField.tsx` 4, `FlowNodeInspector.tsx` 3, `StudioDesignSurface.tsx` 13; - new: 6, all in the new `FlowRecipientsField.tsx`. They are `react-refresh/only-export-components` on its exported grammar helpers, the same pattern as `FlowKeyValueField.tsx`. ## Ablations (one-time, from committed state) Each leg below: 1. mutated one committed line with `ablation-replace`, and checked on disk that the anchor hit once and the blob changed; 2. ran the pin; 3. restored the line, proved by blob equal to HEAD and an empty `git diff HEAD`. | Leg | Mutation | Result | |---|---|---| | declared keys | `mergeServerFlowFields(serverFields, nodeType, null)` | `3 failed / 6 passed`. All failures are the engine-source cases (notify Recipients, http Headers/Body, and the payload control's recipients half). The offline cases stayed green. | | field-value map | `fieldValueMapObject` call replaced by `null` | `6 failed / 2 passed`. The `update_record` filter control stayed green. | | Start trigger | `withStartTrigger` returns the skeleton unchanged | `2 failed / 2 passed`. The "left unset" control stayed green. | | record-field spelling | `writeRecipient` field case returns the bare name | `5 failed / 13 passed` | The restored tree ran the four pins together: `Test Files 4 passed (4)`, `Tests 39 passed (39)`, and `git diff HEAD` was empty. ## Acceptance notes - **Gaps against the triage direction:** - No "role" type is offered. The direction names four types: field, user, team and email. `role:NAME`, `owner_of:` and flow variables stay authorable under "Other", saved as typed. - The Record field list shows every field of the trigger object. The field catalog carries no reference target, so the list is not narrowed to user lookups. - **Producer note, not filed.** The notify descriptor publishes `recipients` untyped. - If it is ever typed as a plain string array, the mapper will draw a `stringList`, and the online form will lose the picker. The merge restores only unmapped keys. - A recipient annotation on the descriptor would be the contract-first way to keep the picker. That is an objectstack contract question this card does not open. - Carrier: none. - **Boundary, not a filing class.** The offline `http_request` Headers help still says "e.g. Authorization". The published descriptor warns never to put a credential in headers. Online, the restored editor now carries the descriptor's warning. The offline text is unchanged. Carrier: none. - **Drift, not filed.** The offline notify table has a `url` row, while the descriptor and contract key is `actionUrl`; `url` is the pre-17 alias the load-time conversion rewrites. The merge rule does not bring `url` back online, because the descriptor does not declare it. Carrier: none. - **Same as the Start node picker.** Choosing `schedule` or `time_relative` writes `triggerType` alone. `resolveFlowTriggerKind` answers no kind until `config.schedule` or `config.timeRelative` is set. The dialog mirrors the Start node picker here and changes neither. - **Fixed in passing.** The previous `stringList` recipients editor read a stored single string, for example `recipients: '{record.assignee}'`, as an empty list. The picker reads it as one row and keeps it a string while it holds one recipient. - **Not measured: `check:eager-closure`.** It needs a console production build, which CI runs. `StudioDesignSurface.tsx` now imports `ObjectPicker.tsx`. A static-import walk of the base tree read `ObjectPicker.tsx` as not reachable from the app-shell entry, and its own imports as already reachable. - **Not measured: repo-wide lint.** `pnpm lint` is CI's to run. The local narrowing is given under Tests and gates. --- _Generated by [Claude Code](https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8962734 commit 31add5f

14 files changed

Lines changed: 1568 additions & 51 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Studio builds a "notify the owner when the ticket is done" flow without JSON (objectui#11788).
6+
7+
- **Notify recipients render online.** With a backend, the notify node's form had no Recipients field, and its recipients sat in Advanced (JSON). The node descriptor publishes `recipients` with no `type`, because the contract takes a string or a list of strings, so the descriptor-driven form left the key out. A key the descriptor declares but the form cannot type now keeps Studio's own editor for it, in its declared place and with the descriptor's description. The same fix brings back the http node's Headers and Body editors.
8+
- **Recipients are picked.** Each recipient row is a field of the trigger record (`{record.FIELD}`), a user (the user id), a team (`team:ID`) or an email address. These are the values the messaging service already resolves. Any other stored value, such as a flow variable or a `role:` selector, shows under "Other" and is saved unchanged. A recipient stored as a single string now shows as a row and stays a string.
9+
- **Record field values pick their field.** On a create or update record node, the key of each field value lists the fields of the node's object. The value input is unchanged.
10+
- **New automation asks for the trigger.** The dialog offers the Start node's own triggers, asks for the object when the trigger watches one, and writes the choice to the Start node. Left unset, the flow is created as before.
11+
12+
Nothing is added to the package entry: no export, prop, type member or language-pack key. The new strings are in the metadata-admin designer's own string tables (en and zh).

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1167,6 +1167,24 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
11671167
'engine.inspector.reference.actionsOf': 'Actions of {connector}.',
11681168
'engine.inspector.reference.chooseConnector': 'Choose a Connector above to list its actions.',
11691169
'engine.inspector.reference.declarative': 'declarative',
1170+
// objectui#11788 — the notify node's recipient picker (`FlowRecipientsField`).
1171+
'engine.inspector.recipients.kindLabel': 'Recipient type',
1172+
'engine.inspector.recipients.kind.field': 'Record field',
1173+
'engine.inspector.recipients.kind.user': 'User',
1174+
'engine.inspector.recipients.kind.team': 'Team',
1175+
'engine.inspector.recipients.kind.email': 'Email address',
1176+
'engine.inspector.recipients.kind.custom': 'Other',
1177+
'engine.inspector.recipients.placeholder.field': 'Field of the trigger record',
1178+
'engine.inspector.recipients.placeholder.user': 'Pick a user',
1179+
'engine.inspector.recipients.placeholder.team': 'Pick a team',
1180+
'engine.inspector.recipients.placeholder.email': 'name@example.com',
1181+
'engine.inspector.recipients.placeholder.custom': 'Saved as typed, e.g. {ownerId}',
1182+
'engine.inspector.recipients.add': 'Add recipient',
1183+
'engine.inspector.recipients.remove': 'Remove recipient',
1184+
'engine.inspector.recipients.empty': 'No recipients yet.',
1185+
'engine.inspector.recipients.notEmail': 'Not an email address — the messaging service would read it as a user id.',
1186+
// objectui#11788 — the create/update record nodes' field-value keys, picked from the target object.
1187+
'engine.inspector.fieldMap.chooseObject': 'Choose the Object above to list its fields.',
11701188
// objectui#11783 — the shared object picker (inspectors/ObjectPicker.tsx).
11711189
'engine.inspector.objectPicker.groupPackage': 'This package',
11721190
'engine.inspector.objectPicker.groupOther': 'Other objects',
@@ -3221,6 +3239,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
32213239
'engine.studio.auto.none': 'No automations yet — click “New” to start',
32223240
'engine.studio.auto.namePlaceholder': 'Name (e.g. Offer Notice)',
32233241
'engine.studio.auto.idPlaceholder': 'Identifier (e.g. offer_notice)',
3242+
// objectui#11788 — the New automation dialog's trigger choice, left unset.
3243+
'engine.studio.newAutoTrigger.later': 'Choose later on the Start node',
32243244
'engine.studio.auto.canvasHint': 'Visual orchestration · click a node to configure',
32253245
'engine.studio.auto.pick': 'Select an automation',
32263246
'engine.studio.auto.config': 'Configuration',
@@ -4284,6 +4304,24 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
42844304
'engine.inspector.reference.actionsOf': '{connector} 的动作。',
42854305
'engine.inspector.reference.chooseConnector': '请先在上方选择连接器,才能列出其动作。',
42864306
'engine.inspector.reference.declarative': '声明式',
4307+
// objectui#11788 — 通知节点的收件人选择器(`FlowRecipientsField`)。
4308+
'engine.inspector.recipients.kindLabel': '收件人类型',
4309+
'engine.inspector.recipients.kind.field': '记录字段',
4310+
'engine.inspector.recipients.kind.user': '用户',
4311+
'engine.inspector.recipients.kind.team': '团队',
4312+
'engine.inspector.recipients.kind.email': '邮箱地址',
4313+
'engine.inspector.recipients.kind.custom': '其他',
4314+
'engine.inspector.recipients.placeholder.field': '触发记录的字段',
4315+
'engine.inspector.recipients.placeholder.user': '选择用户',
4316+
'engine.inspector.recipients.placeholder.team': '选择团队',
4317+
'engine.inspector.recipients.placeholder.email': 'name@example.com',
4318+
'engine.inspector.recipients.placeholder.custom': '按原样保存,例如 {ownerId}',
4319+
'engine.inspector.recipients.add': '添加收件人',
4320+
'engine.inspector.recipients.remove': '移除收件人',
4321+
'engine.inspector.recipients.empty': '还没有收件人。',
4322+
'engine.inspector.recipients.notEmail': '这不是邮箱地址 —— 消息服务会把它当作用户 id。',
4323+
// objectui#11788 — 新建/更新记录节点的字段值键,从目标对象中选取。
4324+
'engine.inspector.fieldMap.chooseObject': '请先在上方选择对象,才能列出它的字段。',
42874325
// objectui#11783 — the shared object picker (inspectors/ObjectPicker.tsx).
42884326
'engine.inspector.objectPicker.groupPackage': '本软件包',
42894327
'engine.inspector.objectPicker.groupOther': '其他对象',
@@ -6170,6 +6208,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
61706208
'engine.studio.auto.none': '还没有自动化 — 点「新建」开始',
61716209
'engine.studio.auto.namePlaceholder': '名称(如:录用通知)',
61726210
'engine.studio.auto.idPlaceholder': '标识符(如:offer_notice)',
6211+
// objectui#11788 — 新建自动化对话框里不选触发方式时的选项。
6212+
'engine.studio.newAutoTrigger.later': '稍后在开始节点上选择',
61736213
'engine.studio.auto.canvasHint': '点选画布上的节点即可配置',
61746214
'engine.studio.auto.pick': '选择一个自动化',
61756215
'engine.studio.auto.config': '配置',
@@ -6589,7 +6629,7 @@ const FLOW_FIELD_ZH: Record<string, Record<string, FlowFieldZh>> = {
65896629
// notify has no client field table (offline → Advanced JSON); these localize
65906630
// the engine-published configSchema fields shown when online.
65916631
notify: {
6592-
recipients: { label: '收件人', help: '接收通知的用户 id / 受众。' },
6632+
recipients: { label: '收件人', help: '接收通知的人:触发记录的某个字段、用户、团队或邮箱地址。至少需要一个。' },
65936633
title: { label: '标题', help: '通知标题(别名:subject)。' },
65946634
message: { label: '内容', help: '通知正文(别名:body)。' },
65956635
channels: { label: '渠道', help: '扇出投递的渠道(默认站内)。' },

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,15 @@ export interface FlowKeyValueFieldProps {
213213
};
214214
/** The spec requires this map (objectui#10948): the label carries {@link RequiredMarker}. */
215215
required?: boolean;
216+
/**
217+
* objectui#11788: suggestions for the KEY cell — the target object's fields
218+
* on a record node's field-value map. The key stays free text (a field the
219+
* catalog does not list yet is still typeable); omitted, the key is a plain
220+
* box as before.
221+
*/
222+
keyOptions?: ReadonlyArray<{ value: string; label: string }>;
223+
/** One line under the rows about where {@link keyOptions} come from. */
224+
keyHint?: string;
216225
}
217226

218227
/**
@@ -245,7 +254,11 @@ export function FlowKeyValueField({
245254
scopeGroups,
246255
valueEnvelope,
247256
required,
257+
keyOptions,
258+
keyHint,
248259
}: FlowKeyValueFieldProps) {
260+
const keyListId = React.useId();
261+
const suggestsKeys = !!keyOptions && keyOptions.length > 0;
249262
// Preserve whichever shape the value was authored in (object map vs the
250263
// assignment-node array form) across edits.
251264
const arrayShape = Array.isArray(value);
@@ -324,6 +337,7 @@ export function FlowKeyValueField({
324337
<div key={row.id} className="space-y-1">
325338
<div className="flex items-center gap-1.5">
326339
<Input
340+
list={suggestsKeys ? keyListId : undefined}
327341
value={row.key}
328342
onChange={(e) => setRowField(row.id, { key: e.target.value })}
329343
onBlur={flushShown}
@@ -404,6 +418,16 @@ export function FlowKeyValueField({
404418
<Plus className="mr-1 h-3.5 w-3.5" />
405419
{addLabel}
406420
</Button>
421+
{suggestsKeys && keyOptions && (
422+
<datalist id={keyListId}>
423+
{keyOptions.map((o) => (
424+
<option key={o.value} value={o.value}>
425+
{o.label}
426+
</option>
427+
))}
428+
</datalist>
429+
)}
430+
{keyHint && <p className="text-[11px] leading-snug text-muted-foreground">{keyHint}</p>}
407431
{help && <p className="text-[11px] leading-snug text-muted-foreground">{help}</p>}
408432
</div>
409433
);

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx‎

Lines changed: 116 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import * as React from 'react';
1010
import { isUnsetFieldValue, unsetNoticeApplies } from './flow-node-config.js';
1111
import type { FlowConfigField, InactiveRetainedKind } from './flow-node-config.js';
12-
import { t } from '../i18n.js';
12+
import { t, tFormat } from '../i18n.js';
13+
import { useObjectFields } from '../previews/useObjectFields.js';
1314
import { WIDGETS, OBJECTUI_SECRET_MASK } from '../widgets.js';
1415
import {
1516
InspectorNumberField,
@@ -19,9 +20,10 @@ import {
1920
flagUnknownValue,
2021
} from './_shared.js';
2122
import { Button, Label } from '@object-ui/components';
22-
import { FlowKeyValueField } from './FlowKeyValueField.js';
23+
import { FlowKeyValueField, type FlowKeyValueFieldProps } from './FlowKeyValueField.js';
2324
import { isValueEnvelopeSlot } from './flow-value-envelope.js';
2425
import { FlowStringListField } from './FlowStringListField.js';
26+
import { FlowRecipientsField } from './FlowRecipientsField.js';
2527
import { FlowObjectListField } from './FlowObjectListField.js';
2628
import { FlowReferenceField, type FlowReferenceContext } from './FlowReferenceField.js';
2729
import { validateExpressionClient } from './expression-validate.js';
@@ -49,6 +51,73 @@ import { findUnknownRefs, scopeRoots, describeUnknownRefs } from './flow-ref-che
4951
*/
5052
const FLOW_TRIGGER_CONTEXT_SUBJECTS: ReadonlyArray<{ value: string; label?: string }> = [];
5153

54+
/**
55+
* The field-value maps whose KEYS are fields of the node's own target object
56+
* (`config.objectName`) — objectui#11788. `CreateRecordConfigSchema.fields` and
57+
* `UpdateRecordConfigSchema.fields` (in `@objectstack/spec/automation`) map a
58+
* field name to the value written to it.
59+
*
60+
* Keyed by node type and PATH, not by a descriptor member, like
61+
* `BLOCK_SWITCHES` in `flow-node-config.ts`: the offline table and the
62+
* engine-published `configSchema` (`additionalProperties` → `keyValue`) both
63+
* emit this path, so the picker holds whichever of the two drew the field.
64+
* Only the key cell changes; the value cell keeps the slot's own dialect.
65+
*/
66+
const FIELD_VALUE_MAPS: ReadonlyArray<{ nodeType: string; path: readonly string[] }> = [
67+
{ nodeType: 'create_record', path: ['config', 'fields'] },
68+
{ nodeType: 'update_record', path: ['config', 'fields'] },
69+
];
70+
71+
/**
72+
* The target object whose fields key the map at `path` on `node`: its
73+
* `config.objectName` ('' while none is chosen), or `null` when `path` is not a
74+
* field-value map.
75+
*/
76+
function fieldValueMapObject(
77+
node: Record<string, unknown> | null | undefined,
78+
path: readonly string[],
79+
): string | null {
80+
const type = node?.type;
81+
if (typeof type !== 'string') return null;
82+
const hit = FIELD_VALUE_MAPS.some(
83+
(m) => m.nodeType === type && m.path.length === path.length && m.path.every((seg, i) => path[i] === seg),
84+
);
85+
if (!hit) return null;
86+
const cfg = node?.config;
87+
const objectName =
88+
cfg && typeof cfg === 'object' && !Array.isArray(cfg) ? (cfg as Record<string, unknown>).objectName : undefined;
89+
return typeof objectName === 'string' ? objectName.trim() : '';
90+
}
91+
92+
/** A record node's field-value map: the key-value editor with the object's fields as key suggestions. */
93+
function FieldValueMapField({
94+
objectName,
95+
locale,
96+
...kv
97+
}: FlowKeyValueFieldProps & { objectName: string; locale?: string }) {
98+
// The same field source the reference picker's `object-field` kind reads.
99+
const { fields } = useObjectFields(objectName || undefined);
100+
const keyOptions = React.useMemo(
101+
() =>
102+
fields.map((f) => ({
103+
value: f.name,
104+
label: f.label && f.label !== f.name ? `${f.label} (${f.name})` : f.name,
105+
})),
106+
[fields],
107+
);
108+
return (
109+
<FlowKeyValueField
110+
{...kv}
111+
keyOptions={keyOptions}
112+
keyHint={
113+
objectName
114+
? tFormat('engine.inspector.reference.fieldsOf', locale, { object: objectName })
115+
: t('engine.inspector.fieldMap.chooseObject', locale)
116+
}
117+
/>
118+
);
119+
}
120+
52121
/** The metadata form's write-only credential input, reused as-is (objectui#11054). */
53122
const SecretWidget = WIDGETS['secret'];
54123

@@ -237,45 +306,65 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,
237306
required={required}
238307
/>
239308
);
240-
case 'keyValue':
309+
case 'keyValue': {
310+
const kv: FlowKeyValueFieldProps = {
311+
label: field.label,
312+
value,
313+
onCommit: (v) => onCommit(v),
314+
disabled,
315+
addLabel: t('engine.inspector.flowNode.kv.add', locale),
316+
keyLabel: t('engine.inspector.flowNode.kv.key', locale),
317+
valueLabel: t('engine.inspector.flowNode.kv.value', locale),
318+
removeLabel: t('engine.inspector.flowNode.kv.remove', locale),
319+
emptyLabel: t('engine.inspector.flowNode.kv.empty', locale),
320+
scopeGroups,
321+
required,
322+
// objectui#7588 — the per-value text / expression toggle, offered
323+
// only on a map the spec's expression ledger
324+
// (`FLOW_NODE_EXPRESSION_PATHS`) declares `value`-role for this
325+
// node type.
326+
valueEnvelope: isValueEnvelopeSlot(context?.node?.type, field.path)
327+
? {
328+
toggleLabel: t('engine.inspector.flowNode.kv.asExpression', locale),
329+
expressionPlaceholder: t('engine.inspector.flowNode.kv.expressionPlaceholder', locale),
330+
}
331+
: undefined,
332+
};
333+
// objectui#11788 — a record node's field-value map: its keys are fields
334+
// of the node's own target object, so the key cell lists them. The
335+
// value cell is untouched.
336+
const fieldMapObject = fieldValueMapObject(context?.node, field.path);
337+
return fieldMapObject !== null ? (
338+
<FieldValueMapField {...kv} objectName={fieldMapObject} locale={locale} />
339+
) : (
340+
<FlowKeyValueField {...kv} />
341+
);
342+
}
343+
case 'stringList':
241344
return (
242-
<FlowKeyValueField
345+
<FlowStringListField
243346
label={field.label}
244347
value={value}
245348
onCommit={(v) => onCommit(v)}
246349
disabled={disabled}
247-
addLabel={t('engine.inspector.flowNode.kv.add', locale)}
248-
keyLabel={t('engine.inspector.flowNode.kv.key', locale)}
249-
valueLabel={t('engine.inspector.flowNode.kv.value', locale)}
250-
removeLabel={t('engine.inspector.flowNode.kv.remove', locale)}
251-
emptyLabel={t('engine.inspector.flowNode.kv.empty', locale)}
252-
scopeGroups={scopeGroups}
253350
required={required}
254-
// objectui#7588 — the per-value text / expression toggle, offered
255-
// only on a map the spec's expression ledger declares `value`-role
256-
// for this node type (today the assignment node's `assignments`).
257-
valueEnvelope={
258-
isValueEnvelopeSlot(context?.node?.type, field.path)
259-
? {
260-
toggleLabel: t('engine.inspector.flowNode.kv.asExpression', locale),
261-
expressionPlaceholder: t('engine.inspector.flowNode.kv.expressionPlaceholder', locale),
262-
}
263-
: undefined
264-
}
351+
addLabel={t('engine.inspector.flowNode.list.add', locale)}
352+
itemLabel={t('engine.inspector.flowNode.list.item', locale)}
353+
removeLabel={t('engine.inspector.flowNode.list.remove', locale)}
354+
emptyLabel={t('engine.inspector.flowNode.list.empty', locale)}
265355
/>
266356
);
267-
case 'stringList':
357+
case 'recipients':
268358
return (
269-
<FlowStringListField
359+
<FlowRecipientsField
270360
label={field.label}
271361
value={value}
272362
onCommit={(v) => onCommit(v)}
273363
disabled={disabled}
364+
locale={locale}
365+
context={context}
366+
scopeGroups={scopeGroups}
274367
required={required}
275-
addLabel={t('engine.inspector.flowNode.list.add', locale)}
276-
itemLabel={t('engine.inspector.flowNode.list.item', locale)}
277-
removeLabel={t('engine.inspector.flowNode.list.remove', locale)}
278-
emptyLabel={t('engine.inspector.flowNode.list.empty', locale)}
279368
/>
280369
);
281370
case 'numberList':

0 commit comments

Comments
 (0)