Skip to content

Commit 4e99a54

Browse files
committed
Merge origin/main into claude/issue-10435-select-label-association
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy
2 parents a75bf59 + d461b0d commit 4e99a54

13 files changed

Lines changed: 881 additions & 72 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@object-ui/plugin-dashboard': patch
3+
---
4+
5+
A fixed-currency field shows its own currency in the dashboard table widget and
6+
in the record drawer it opens, not the tenant's (objectui#10463).
7+
8+
`@objectstack/spec` refuses a field-level `currency`, so a fixed currency is
9+
declared as `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }`.
10+
`buildFieldMeta`, the helper both `ObjectDataTable` and `RecordDetailDrawer`
11+
build their field metadata with, read only the flat `currency` and
12+
`defaultCurrency` keys and never `currencyConfig`. In a USD tenant, a JPY-fixed
13+
amount of 1234 read `$1,234` in the table cell and in the drawer, while the
14+
grid and the metric tile read `¥1,234` for the same field.
15+
16+
`buildFieldMeta` now resolves the field's currency through
17+
`resolveFieldCurrency` from `@object-ui/i18n`, the resolver the grid, the
18+
metric tile and the currency cell already share. A column's own `currency`
19+
still wins over the field's. The resolver is called without the tenant
20+
default, so a column `format` that starts with a currency symbol still wins
21+
over the tenant currency, as before.
22+
23+
**Behaviour change.** In the dashboard table widget and the record drawer, a
24+
field whose `currencyConfig` is in `fixed` mode now shows
25+
`currencyConfig.defaultCurrency` where it used to show the tenant currency. A
26+
field in `dynamic` mode, or with a `currencyConfig` that names no
27+
`currencyMode`, still shows the tenant currency, because the resolver reads
28+
`currencyConfig.defaultCurrency` only in `fixed` mode (objectui#10422). A field
29+
that carries both a fixed `currencyConfig` and the legacy top-level
30+
`defaultCurrency` now shows the fixed code, which is the resolver's order.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@object-ui/app-shell': minor
3+
---
4+
5+
feat(app-shell): an assignment value can be written as a CEL expression in the flow designer
6+
7+
The `assignment` node's **Assignments** editor now has a per-value toggle, "Write
8+
as a CEL expression". Off, the value is text: a `{token}` string is stored exactly
9+
as typed, and numbers, booleans and JSON literals are smart-parsed as before. On,
10+
the value is stored as the CEL value envelope `@objectstack/spec` declares for this
11+
slot (objectstack#14149), `{ dialect: 'cel', source }`, so the CEL stdlib
12+
(`joinNonEmpty`, …) can be authored from the designer:
13+
14+
```json
15+
{ "assignments": { "label": "{record.name}", "digest": { "dialect": "cel", "source": "joinNonEmpty(names, \", \")" } } }
16+
```
17+
18+
- The editor reads which maps take an envelope from the spec's expression ledger
19+
(`FLOW_NODE_EXPRESSION_PATHS`, `value` role). Every other key/value map (action
20+
params, request headers, record field values, subflow input) is unchanged.
21+
- A malformed envelope (no `source`, a blank one, a non-`cel` dialect) shows the
22+
spec's `AssignmentValueSchema` refusal beside the cell, and is kept as the object
23+
it is rather than turned into a string.
24+
- A node still using the legacy `assignments: [{ variable, value }]` array shows the
25+
spec's prescription for it. Switching one of its values to an expression writes
26+
the canonical map, the only shape the spec reads an envelope in; the other values
27+
move across unchanged.

‎content/docs/guide/flow-designer.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,26 @@ also be edited from the edge side — select a connector and use its **Branch**
121121
picker — and the two stay in sync, because the routing always lives on the
122122
edges.
123123

124+
An **Assignment** node's Assignments editor sets one variable per row. Each
125+
value is either text or a CEL expression, chosen with the row's *Write as a CEL
126+
expression* toggle. The node's `config` then reads:
127+
128+
```json
129+
{
130+
"assignments": {
131+
"label": "{record.name}",
132+
"digest": { "dialect": "cel", "source": "joinNonEmpty(names, \", \")" }
133+
}
134+
}
135+
```
136+
137+
Text (`label`) is `{token}` interpolation and is stored exactly as typed. An
138+
expression (`digest`) is stored as the `{ dialect: 'cel', source }` envelope and
139+
evaluated by the expression engine, so the CEL stdlib is available. A malformed
140+
envelope shows the spec's refusal under the cell. The toggle appears only on maps
141+
whose values the spec declares may be an expression; other key/value editors
142+
(field values, headers, inputs) have none.
143+
124144
## Validate, simulate, inspect runs
125145

126146
The toolbar toggles four side panels:

‎packages/app-shell/README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,13 @@ Config keys come in three editable shapes so authors never hand-write JSON:
520520
`connector_action`'s **Input**, a `get_record`'s **Filter** — use an inline
521521
**key/value editor** (`keyValue` kind). Scalar values are auto-typed (`3` →
522522
number, `true` → boolean); object/array values such as a filter operator
523-
`{"$ne": null}` round-trip losslessly.
523+
`{"$ne": null}` round-trip losslessly. On a map the spec's expression ledger
524+
declares `value`-role (`FLOW_NODE_EXPRESSION_PATHS`; today the `assignment`
525+
node's **Assignments**), each value also has a *Write as a CEL expression*
526+
toggle: off, a `{token}` string is stored exactly as typed; on, the value is
527+
stored as the CEL value envelope `{ dialect: 'cel', source }`, and a malformed
528+
envelope shows the spec's `AssignmentValueSchema` refusal inline
529+
(objectui#7588, `flow-value-envelope.ts`).
524530
- **String arrays** — a script's **Recipients** / **Output variables** — use a
525531
single-column **string-list editor** (`stringList` kind).
526532
- **Arrays of objects** — a `screen` node's **Fields** (a list of

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,10 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
404404
'engine.inspector.flowNode.kv.value': 'Value',
405405
'engine.inspector.flowNode.kv.remove': 'Remove entry',
406406
'engine.inspector.flowNode.kv.empty': 'No entries yet.',
407+
// objectui#7588 — the per-value text / expression toggle on a `value`-role
408+
// map (the assignment node's `assignments`); see `flow-value-envelope.ts`.
409+
'engine.inspector.flowNode.kv.asExpression': 'Write as a CEL expression',
410+
'engine.inspector.flowNode.kv.expressionPlaceholder': 'CEL expression, e.g. joinNonEmpty(…)',
407411
'engine.inspector.flowNode.list.add': 'Add item',
408412
'engine.inspector.flowNode.list.item': 'Value',
409413
'engine.inspector.flowNode.list.remove': 'Remove item',
@@ -2370,6 +2374,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
23702374
'engine.inspector.flowNode.kv.value': '值',
23712375
'engine.inspector.flowNode.kv.remove': '删除条目',
23722376
'engine.inspector.flowNode.kv.empty': '暂无条目。',
2377+
'engine.inspector.flowNode.kv.asExpression': '写成 CEL 表达式',
2378+
'engine.inspector.flowNode.kv.expressionPlaceholder': 'CEL 表达式,例如 joinNonEmpty(…)',
23732379
'engine.inspector.flowNode.list.add': '添加项',
23742380
'engine.inspector.flowNode.list.item': '值',
23752381
'engine.inspector.flowNode.list.remove': '删除项',

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.shape.test.ts‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { toEntries, rowsToValue, type Row } from './FlowKeyValueField';
4+
import { toEntries, rowsToValue, toRows, switchRowMode, type Row } from './FlowKeyValueField';
5+
import { valueEnvelopeRefusal } from './flow-value-envelope';
56

67
const row = (key: string, raw: string): Row => ({ id: key, key, raw });
78

@@ -51,3 +52,76 @@ describe('FlowKeyValueField shape handling (#1934 — assignment array form)', (
5152
expect((out as Array<Record<string, unknown>>).map((e) => e.variable)).toEqual(['lead_score', 'enrichment_data']);
5253
});
5354
});
55+
56+
/**
57+
* objectui#7588 — the text / expression choice on a `value`-role slot, pinned
58+
* through BOTH storage paths `rowsToValue` writes (the legacy array and the
59+
* canonical map). A stored value that nobody edits must come back unchanged.
60+
*/
61+
describe('FlowKeyValueField value-envelope slot (objectui#7588)', () => {
62+
const roundTrip = (stored: unknown, arrayShape: boolean) => rowsToValue(toRows(stored, [], true), arrayShape);
63+
64+
it('round-trips a `{token}` string byte-identically in the MAP and the ARRAY shape', () => {
65+
const token = '{record.first_name} {record.last_name}';
66+
const map = roundTrip({ full_name: token, bare: '{amount}' }, false) as Record<string, unknown>;
67+
expect(map.full_name).toBe(token);
68+
expect(map.bare).toBe('{amount}');
69+
const list = roundTrip([{ variable: 'full_name', value: token }], true);
70+
expect(list).toEqual([{ variable: 'full_name', value: token }]);
71+
// Never normalized into an envelope: the text form stays a string.
72+
expect(toRows({ full_name: token }, [], true)[0].mode).toBeUndefined();
73+
});
74+
75+
it('round-trips a CEL value envelope in the MAP as the same object, and in the ARRAY as the literal it is', () => {
76+
const envelope = { dialect: 'cel', source: 'joinNonEmpty(names, ", ")', meta: { rationale: 'digest' } };
77+
const map = roundTrip({ digest: envelope }, false) as Record<string, unknown>;
78+
expect(map.digest).toBe(envelope);
79+
expect(toRows({ digest: envelope }, [], true)[0]).toMatchObject({ mode: 'expression', raw: envelope.source });
80+
// In the legacy array an envelope-shaped object is a literal, so it is a
81+
// text row showing its JSON and it comes back deep-equal.
82+
const listRows = toRows([{ variable: 'digest', value: envelope }], [], true);
83+
expect(listRows[0].mode).toBeUndefined();
84+
expect(rowsToValue(listRows, true)).toEqual([{ variable: 'digest', value: envelope }]);
85+
});
86+
87+
it('writes an expression row as a `{ dialect, source }` envelope, never as a string', () => {
88+
const rows: Row[] = [switchRowMode(row('total', 'amount * 2'), 'expression'), row('label', '{name}')];
89+
expect(rowsToValue(rows, false)).toEqual({ total: { dialect: 'cel', source: 'amount * 2' }, label: '{name}' });
90+
});
91+
92+
it('moves a legacy ARRAY to the map once a row is an expression, leaving the other values unchanged', () => {
93+
const rows = toRows([{ variable: 'label', value: '{name}' }, { variable: 'n', value: 3 }], [], true);
94+
rows[1] = switchRowMode(rows[1], 'expression');
95+
expect(rowsToValue(rows, true)).toEqual({ label: '{name}', n: { dialect: 'cel', source: '3' } });
96+
});
97+
98+
it('keeps a malformed envelope as the stored object with the spec refusal, not as a silent string', () => {
99+
const malformed = { dialect: 'cel' };
100+
const rows = toRows({ bad: malformed }, [], true);
101+
expect(rows[0].mode).toBeUndefined();
102+
const out = rowsToValue(rows, false) as Record<string, unknown>;
103+
expect(out.bad).toEqual(malformed);
104+
expect(valueEnvelopeRefusal(out.bad)).not.toBeNull();
105+
// A blank expression is written as an envelope the spec refuses, not as ''.
106+
const blank = rowsToValue([switchRowMode(row('empty', ''), 'expression')], false) as Record<string, unknown>;
107+
expect(blank.empty).toEqual({ dialect: 'cel', source: '' });
108+
expect(valueEnvelopeRefusal(blank.empty)).not.toBeNull();
109+
});
110+
111+
it('switches text and expression both ways without losing the typed text', () => {
112+
const expr = switchRowMode(row('x', 'a + b'), 'expression');
113+
expect(expr).toMatchObject({ mode: 'expression', raw: 'a + b' });
114+
expect(switchRowMode(expr, 'text')).toEqual(row('x', 'a + b'));
115+
// Text that already is an envelope's JSON becomes that envelope.
116+
const adopted = switchRowMode(row('x', '{"dialect":"cel","source":"a"}'), 'expression');
117+
expect(adopted).toMatchObject({ mode: 'expression', raw: 'a', envelope: { dialect: 'cel', source: 'a' } });
118+
});
119+
120+
it('leaves every map that is NOT a value-envelope slot exactly as before', () => {
121+
const envelope = { dialect: 'cel', source: 'a' };
122+
const rows = toRows({ param: envelope }, []);
123+
expect(rows[0].mode).toBeUndefined();
124+
expect(rows[0].raw).toBe(JSON.stringify(envelope));
125+
expect(rowsToValue(rows, false)).toEqual({ param: envelope });
126+
});
127+
});

0 commit comments

Comments
 (0)