@tai42/studio-sdk 13.3.0 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist/components/components.css +19 -0
  2. package/dist/components/icons.d.ts +2 -0
  3. package/dist/components/icons.d.ts.map +1 -1
  4. package/dist/components/icons.js +2 -0
  5. package/dist/components/icons.js.map +1 -1
  6. package/dist/components/templated-text-field.d.ts +98 -0
  7. package/dist/components/templated-text-field.d.ts.map +1 -0
  8. package/dist/components/templated-text-field.js +217 -0
  9. package/dist/components/templated-text-field.js.map +1 -0
  10. package/dist/hooks/useApi.d.ts +461 -59
  11. package/dist/hooks/useApi.d.ts.map +1 -1
  12. package/dist/index.d.ts +6 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/navigation/types.d.ts +2 -1
  17. package/dist/navigation/types.d.ts.map +1 -1
  18. package/dist/query-keys.d.ts +13 -0
  19. package/dist/query-keys.d.ts.map +1 -1
  20. package/dist/query-keys.js +13 -0
  21. package/dist/query-keys.js.map +1 -1
  22. package/dist/state-binding/AdapterMapping.d.ts +33 -0
  23. package/dist/state-binding/AdapterMapping.d.ts.map +1 -0
  24. package/dist/state-binding/AdapterMapping.js +205 -0
  25. package/dist/state-binding/AdapterMapping.js.map +1 -0
  26. package/dist/state-binding/BindingJqField.d.ts +43 -0
  27. package/dist/state-binding/BindingJqField.d.ts.map +1 -0
  28. package/dist/state-binding/BindingJqField.js +57 -0
  29. package/dist/state-binding/BindingJqField.js.map +1 -0
  30. package/dist/state-binding/BindingTemplatedJqField.d.ts +24 -0
  31. package/dist/state-binding/BindingTemplatedJqField.d.ts.map +1 -0
  32. package/dist/state-binding/BindingTemplatedJqField.js +7 -0
  33. package/dist/state-binding/BindingTemplatedJqField.js.map +1 -0
  34. package/dist/state-binding/InputInjectionRow.d.ts +18 -0
  35. package/dist/state-binding/InputInjectionRow.d.ts.map +1 -0
  36. package/dist/state-binding/InputInjectionRow.js +55 -0
  37. package/dist/state-binding/InputInjectionRow.js.map +1 -0
  38. package/dist/state-binding/StateAttachRow.d.ts +27 -0
  39. package/dist/state-binding/StateAttachRow.d.ts.map +1 -0
  40. package/dist/state-binding/StateAttachRow.js +102 -0
  41. package/dist/state-binding/StateAttachRow.js.map +1 -0
  42. package/dist/state-binding/StateBindingEditor.d.ts +37 -0
  43. package/dist/state-binding/StateBindingEditor.d.ts.map +1 -0
  44. package/dist/state-binding/StateBindingEditor.js +54 -0
  45. package/dist/state-binding/StateBindingEditor.js.map +1 -0
  46. package/dist/state-binding/StateBindingSection.d.ts +14 -0
  47. package/dist/state-binding/StateBindingSection.d.ts.map +1 -0
  48. package/dist/state-binding/StateBindingSection.js +17 -0
  49. package/dist/state-binding/StateBindingSection.js.map +1 -0
  50. package/dist/state-binding/SubjectScopeFields.d.ts +20 -0
  51. package/dist/state-binding/SubjectScopeFields.d.ts.map +1 -0
  52. package/dist/state-binding/SubjectScopeFields.js +8 -0
  53. package/dist/state-binding/SubjectScopeFields.js.map +1 -0
  54. package/dist/state-binding/UpdateRow.d.ts +20 -0
  55. package/dist/state-binding/UpdateRow.d.ts.map +1 -0
  56. package/dist/state-binding/UpdateRow.js +89 -0
  57. package/dist/state-binding/UpdateRow.js.map +1 -0
  58. package/dist/state-binding/adapter.d.ts +93 -0
  59. package/dist/state-binding/adapter.d.ts.map +1 -0
  60. package/dist/state-binding/adapter.js +251 -0
  61. package/dist/state-binding/adapter.js.map +1 -0
  62. package/dist/state-binding/build-catalog.d.ts +19 -0
  63. package/dist/state-binding/build-catalog.d.ts.map +1 -0
  64. package/dist/state-binding/build-catalog.js +43 -0
  65. package/dist/state-binding/build-catalog.js.map +1 -0
  66. package/dist/state-binding/catalog.d.ts +26 -0
  67. package/dist/state-binding/catalog.d.ts.map +1 -0
  68. package/dist/state-binding/catalog.js +71 -0
  69. package/dist/state-binding/catalog.js.map +1 -0
  70. package/dist/state-binding/index.d.ts +33 -0
  71. package/dist/state-binding/index.d.ts.map +1 -0
  72. package/dist/state-binding/index.js +20 -0
  73. package/dist/state-binding/index.js.map +1 -0
  74. package/dist/state-binding/templated-text-catalog.d.ts +21 -0
  75. package/dist/state-binding/templated-text-catalog.d.ts.map +1 -0
  76. package/dist/state-binding/templated-text-catalog.js +48 -0
  77. package/dist/state-binding/templated-text-catalog.js.map +1 -0
  78. package/dist/state-binding/types.d.ts +78 -0
  79. package/dist/state-binding/types.d.ts.map +1 -0
  80. package/dist/state-binding/types.js +2 -0
  81. package/dist/state-binding/types.js.map +1 -0
  82. package/package.json +5 -5
  83. package/src/components/components.css +19 -0
  84. package/src/components/icons.tsx +8 -0
  85. package/src/components/templated-text-field.tsx +508 -0
  86. package/src/index.ts +76 -1
  87. package/src/navigation/types.ts +4 -1
  88. package/src/query-keys.ts +15 -0
  89. package/src/state-binding/AdapterMapping.tsx +468 -0
  90. package/src/state-binding/BindingJqField.tsx +146 -0
  91. package/src/state-binding/BindingTemplatedJqField.tsx +73 -0
  92. package/src/state-binding/InputInjectionRow.tsx +157 -0
  93. package/src/state-binding/README.md +46 -0
  94. package/src/state-binding/StateAttachRow.tsx +289 -0
  95. package/src/state-binding/StateBindingEditor.tsx +166 -0
  96. package/src/state-binding/StateBindingSection.tsx +37 -0
  97. package/src/state-binding/SubjectScopeFields.tsx +57 -0
  98. package/src/state-binding/UpdateRow.tsx +227 -0
  99. package/src/state-binding/adapter.ts +276 -0
  100. package/src/state-binding/build-catalog.ts +59 -0
  101. package/src/state-binding/catalog.ts +87 -0
  102. package/src/state-binding/index.ts +67 -0
  103. package/src/state-binding/templated-text-catalog.ts +69 -0
  104. package/src/state-binding/types.ts +91 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The input-injection list of one attached state. Each row injects a value into the
3
+ * run input BEFORE the run: either a template `input` jq (picked by name) or a
4
+ * custom jq over the record, placed under the `into` run-input field.
5
+ */
6
+ import type { ReactNode } from 'react';
7
+
8
+ import { Button } from '../components/primitives';
9
+ import { Field } from '../components/field';
10
+ import { Select } from '../components/select';
11
+ import { TextInput } from '../components/inputs';
12
+ import { CloseIcon } from '../components/icons';
13
+ import { BindingTemplatedJqField } from './BindingTemplatedJqField';
14
+ import type { TemplateJqSuggestion } from './BindingJqField';
15
+ import type { ResolvedTemplateJq } from './catalog';
16
+ import type { StateInjection, TemplatedTextCatalog } from './types';
17
+
18
+ function optionLabel(entry: ResolvedTemplateJq): string {
19
+ return entry.description !== undefined && entry.description !== ''
20
+ ? `${entry.ref} — ${entry.description}`
21
+ : entry.ref;
22
+ }
23
+
24
+ const CUSTOM = '__custom__';
25
+
26
+ function InjectionRow({
27
+ injection,
28
+ index,
29
+ inputJq,
30
+ suggestions,
31
+ templates,
32
+ onChange,
33
+ onRemove,
34
+ }: {
35
+ readonly injection: StateInjection;
36
+ readonly index: number;
37
+ readonly inputJq: readonly ResolvedTemplateJq[];
38
+ readonly suggestions?: readonly TemplateJqSuggestion[];
39
+ readonly templates?: TemplatedTextCatalog;
40
+ readonly onChange: (injection: StateInjection) => void;
41
+ readonly onRemove: () => void;
42
+ }): ReactNode {
43
+ const isCustom = injection.jq !== null;
44
+ const selectValue = isCustom ? CUSTOM : (injection.template_jq ?? '');
45
+
46
+ return (
47
+ <div
48
+ data-testid={`injection-row-${String(index)}`}
49
+ style={{
50
+ display: 'flex',
51
+ gap: 'var(--tai-space-2)',
52
+ alignItems: 'flex-start',
53
+ flexWrap: 'wrap',
54
+ }}
55
+ >
56
+ <Field label={`Injection source ${String(index + 1)}`} hideLabel>
57
+ <Select
58
+ placeholder="Choose a template jq"
59
+ value={selectValue}
60
+ onValueChange={(next) => {
61
+ if (next === CUSTOM)
62
+ onChange({ template_jq: null, jq: { content: '' }, into: injection.into });
63
+ else onChange({ template_jq: next, jq: null, into: injection.into });
64
+ }}
65
+ groups={[
66
+ {
67
+ label: 'Template jq',
68
+ options: inputJq.map((entry) => ({ value: entry.ref, label: optionLabel(entry) })),
69
+ },
70
+ { label: 'Custom', options: [{ value: CUSTOM, label: 'Custom jq' }] },
71
+ ]}
72
+ />
73
+ </Field>
74
+ {isCustom ? (
75
+ <BindingTemplatedJqField
76
+ label={`Custom injection jq ${String(index + 1)}`}
77
+ required
78
+ value={injection.jq}
79
+ onChange={(next) => {
80
+ onChange({ template_jq: null, jq: next ?? { content: '' }, into: injection.into });
81
+ }}
82
+ suggestions={suggestions}
83
+ templates={templates}
84
+ />
85
+ ) : null}
86
+ <Field label={`Into field ${String(index + 1)}`} hideLabel>
87
+ <TextInput
88
+ placeholder="Input field"
89
+ value={injection.into}
90
+ onChange={(event) => {
91
+ onChange({ ...injection, into: event.target.value });
92
+ }}
93
+ />
94
+ </Field>
95
+ <Button
96
+ type="button"
97
+ variant="ghost"
98
+ aria-label={`Remove input ${String(index + 1)}`}
99
+ onClick={onRemove}
100
+ >
101
+ <CloseIcon aria-hidden="true" />
102
+ </Button>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ export interface InjectionListProps {
108
+ readonly injections: readonly StateInjection[];
109
+ readonly onChange: (injections: readonly StateInjection[]) => void;
110
+ readonly inputJq: readonly ResolvedTemplateJq[];
111
+ readonly suggestions?: readonly TemplateJqSuggestion[];
112
+ readonly templates?: TemplatedTextCatalog;
113
+ }
114
+
115
+ export function InjectionList({
116
+ injections,
117
+ onChange,
118
+ inputJq,
119
+ suggestions,
120
+ templates,
121
+ }: InjectionListProps): ReactNode {
122
+ const add = (): void => {
123
+ const first = inputJq[0];
124
+ const next: StateInjection =
125
+ first !== undefined
126
+ ? { template_jq: first.ref, jq: null, into: '' }
127
+ : { template_jq: null, jq: { content: '' }, into: '' };
128
+ onChange([...injections, next]);
129
+ };
130
+
131
+ return (
132
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
133
+ {injections.map((injection, index) => (
134
+ <InjectionRow
135
+ // Rows are positional; the index is their stable identity within a save.
136
+ key={index}
137
+ injection={injection}
138
+ index={index}
139
+ inputJq={inputJq}
140
+ suggestions={suggestions}
141
+ templates={templates}
142
+ onChange={(next) => {
143
+ onChange(injections.map((current, position) => (position === index ? next : current)));
144
+ }}
145
+ onRemove={() => {
146
+ onChange(injections.filter((_, position) => position !== index));
147
+ }}
148
+ />
149
+ ))}
150
+ <div>
151
+ <Button type="button" variant="ghost" onClick={add}>
152
+ Add input
153
+ </Button>
154
+ </div>
155
+ </div>
156
+ );
157
+ }
@@ -0,0 +1,46 @@
1
+ # State-binding editor
2
+
3
+ The one binding shape every door (preset, channel route, schedule, hook) and every
4
+ flow node authors, plus its pure helpers. Lives in the SDK so any plugin door reuses
5
+ it; holds no edge to jq (the jq fields render through the host's ambient
6
+ `ExpressionFieldContext`, else a plain textarea) and paints from SDK tokens only.
7
+
8
+ ## The canonical jq contracts
9
+
10
+ - **template `input` jq** — input is the record (`.`), declared params bound as
11
+ `$params`; returns the injected value.
12
+ - **custom injection jq** — over `{ record, input }`; returns the value placed at `into`.
13
+ - **adapter** — over `{ output, input }`; CONSTRUCTS the update jq's declared input
14
+ object.
15
+ - **template `update` jq** — over `{ record, input }` where `input` is the adapter's
16
+ output; returns a template-relative op batch.
17
+ - **custom `update` jq** — over `{ record, output, input }`; returns `[{op, path, value}]`.
18
+ - **`subject_expr`** — a jq yielding the record KEY: a bare key string, or a full subject object.
19
+ - **`scope_expr`** — optional; a boolean jq predicate. When it is false, the state is skipped for the run.
20
+
21
+ ## The adapter's parseable shape (round-trip)
22
+
23
+ Every form is rebuilt from STORED data, never UI-only state. `compileAdapter` emits a
24
+ canonical, parseable object so a stored adapter reopens as the form it was authored in:
25
+
26
+ ```
27
+ { <key>: (<value>), … }
28
+ ```
29
+
30
+ - `<key>` — a bare identifier, or a JSON string when it is not one.
31
+ - `<value>` — one of: a field path (`.output…` / `.input…`), a JSON literal, or a raw
32
+ jq expression, each wrapped in `(…)`.
33
+
34
+ `parseAdapter` inverts it: `parseAdapter(compileAdapter(rows).jq) === rows` for every
35
+ row kind whose literal is already canonical JSON. A shape it does not recognise returns
36
+ `null`, and the editor opens the raw-jq escape hatch instead.
37
+
38
+ A template update that must ride inside ONE jq field is written as the call
39
+ `tjq_<name>(<adapter object>)` (`generateTemplateCall` / `parseTemplateCall`); a field
40
+ whose expression is exactly that call reopens as the mapping form, anything else as raw
41
+ jq.
42
+
43
+ A bound state or template absent on the server renders the raw jq read-only under a
44
+ `template not attached` error, never a blank form. When the edited binding names a
45
+ state the target's own (`inherited`) binding also names, the state card warns
46
+ `Overrides the preset's subject` and shows the inherited subject/scope.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * One attached state of a binding: the state selector, the templates it attaches
3
+ * (attach-on-use — a not-yet-attached pick shows the "attached on save" hint), the subject
4
+ * and scope expressions, the input injections, and the updates.
5
+ */
6
+ import { useMemo, type ReactNode } from 'react';
7
+
8
+ import { Button } from '../components/primitives';
9
+ import { CodeBlock } from '../components/code-block';
10
+ import { Checkbox } from '../components/checkbox';
11
+ import { Field } from '../components/field';
12
+ import { Select } from '../components/select';
13
+ import { templatedTextSummary } from '../components/templated-text-field';
14
+ import { AlertTriangleIcon, CloseIcon } from '../components/icons';
15
+ import { InjectionList } from './InputInjectionRow';
16
+ import { UpdateList } from './UpdateRow';
17
+ import { SubjectScopeFields } from './SubjectScopeFields';
18
+ import type { TemplateJqSuggestion } from './BindingJqField';
19
+ import { resolveTemplateJq } from './catalog';
20
+ import type {
21
+ BindingSourceSchemas,
22
+ BindingStateOption,
23
+ BindingTemplateOption,
24
+ StateAttach,
25
+ TemplatedText,
26
+ TemplatedTextCatalog,
27
+ } from './types';
28
+
29
+ const ATTACH_ON_USE_HINT = 'This template will be attached to the state when you save.';
30
+
31
+ /** The subject/scope of the target's own (inherited) binding for a state this one also names. */
32
+ export interface InheritedSubject {
33
+ readonly subject_expr: TemplatedText;
34
+ readonly scope_expr: TemplatedText | null;
35
+ }
36
+
37
+ /** The stored binding's raw jq, for the unresolved-catalog read-only fallback. */
38
+ function rawBindingLines(attach: StateAttach): string {
39
+ const lines = [`subject: ${templatedTextSummary(attach.subject_expr)}`];
40
+ if (attach.scope_expr !== null) lines.push(`scope: ${templatedTextSummary(attach.scope_expr)}`);
41
+ for (const injection of attach.input_injections) {
42
+ lines.push(
43
+ `input ${injection.into || '(unset)'} <- ${injection.template_jq ?? templatedTextSummary(injection.jq)}`,
44
+ );
45
+ }
46
+ for (const update of attach.updates) {
47
+ const ref = update.template_jq ?? templatedTextSummary(update.jq);
48
+ lines.push(
49
+ `update ${ref}${update.adapter !== null ? ` adapter ${templatedTextSummary(update.adapter)}` : ''}`,
50
+ );
51
+ }
52
+ return lines.join('\n');
53
+ }
54
+
55
+ export interface StateAttachRowProps {
56
+ readonly attach: StateAttach;
57
+ readonly onChange: (attach: StateAttach) => void;
58
+ readonly onRemove: () => void;
59
+ readonly statesCatalog: readonly BindingStateOption[];
60
+ readonly templatesCatalog: readonly BindingTemplateOption[];
61
+ readonly sources?: BindingSourceSchemas;
62
+ readonly subjectError?: string;
63
+ /** The target's own binding subject for this state, when it also binds it (precedence). */
64
+ readonly inherited?: InheritedSubject;
65
+ /** The stored templates a templated-text field's id picker offers. */
66
+ readonly templates?: TemplatedTextCatalog;
67
+ }
68
+
69
+ export function StateAttachRow({
70
+ attach,
71
+ onChange,
72
+ onRemove,
73
+ statesCatalog,
74
+ templatesCatalog,
75
+ sources,
76
+ subjectError,
77
+ inherited,
78
+ templates,
79
+ }: StateAttachRowProps): ReactNode {
80
+ const stateOption = statesCatalog.find((option) => option.name === attach.state);
81
+ const attachedTemplates = stateOption?.attachedTemplates ?? [];
82
+ // A bound state or template absent on the server cannot resolve its pickers: show
83
+ // the raw jq read-only rather than a blank form.
84
+ const stateMissing = attach.state !== '' && stateOption === undefined;
85
+ const missingTemplates = attach.templates.filter(
86
+ (name) => !templatesCatalog.some((template) => template.name === name),
87
+ );
88
+ const unresolved = stateMissing || missingTemplates.length > 0;
89
+
90
+ const inputJq = useMemo(
91
+ () => resolveTemplateJq(attach.templates, templatesCatalog, 'input'),
92
+ [attach.templates, templatesCatalog],
93
+ );
94
+ const updateJq = useMemo(
95
+ () => resolveTemplateJq(attach.templates, templatesCatalog, 'update'),
96
+ [attach.templates, templatesCatalog],
97
+ );
98
+ // An `update` jq returns an op batch and is never callable from an expression (it is
99
+ // picked only by an update's own select), so the `tjq_…` insert suggestions offered to
100
+ // EVERY jq field are the attached templates' `input`-purpose jq only.
101
+ const suggestions: TemplateJqSuggestion[] = useMemo(
102
+ () =>
103
+ inputJq.map((entry) => ({
104
+ ref: entry.ref,
105
+ purpose: entry.purpose,
106
+ description: entry.description,
107
+ })),
108
+ [inputJq],
109
+ );
110
+
111
+ const toggleTemplate = (name: string, checked: boolean): void => {
112
+ const next = checked
113
+ ? [...attach.templates, name]
114
+ : attach.templates.filter((template) => template !== name);
115
+ onChange({ ...attach, templates: next });
116
+ };
117
+
118
+ return (
119
+ <div
120
+ data-testid={`state-attach-${attach.state}`}
121
+ style={{
122
+ display: 'flex',
123
+ flexDirection: 'column',
124
+ gap: 'var(--tai-space-3)',
125
+ padding: 'var(--tai-space-3)',
126
+ border: '1px solid var(--tai-color-border)',
127
+ borderRadius: 'var(--tai-radius-md)',
128
+ }}
129
+ >
130
+ <div
131
+ style={{
132
+ display: 'flex',
133
+ gap: 'var(--tai-space-2)',
134
+ alignItems: 'flex-end',
135
+ flexWrap: 'wrap',
136
+ }}
137
+ >
138
+ <Field label="State">
139
+ <Select
140
+ aria-label="State"
141
+ placeholder="Choose a state"
142
+ value={attach.state}
143
+ onValueChange={(next) => {
144
+ // A new state resets the template picks (its attachments differ).
145
+ onChange({ ...attach, state: next, templates: [] });
146
+ }}
147
+ options={statesCatalog.map((option) => ({ value: option.name, label: option.name }))}
148
+ />
149
+ </Field>
150
+ <Button
151
+ type="button"
152
+ variant="ghost"
153
+ aria-label={`Remove state ${attach.state === '' ? '(unset)' : attach.state}`}
154
+ onClick={onRemove}
155
+ >
156
+ <CloseIcon aria-hidden="true" />
157
+ </Button>
158
+ </div>
159
+
160
+ {inherited !== undefined && attach.state !== '' ? (
161
+ <div
162
+ role="status"
163
+ style={{
164
+ display: 'flex',
165
+ gap: 'var(--tai-space-2)',
166
+ alignItems: 'flex-start',
167
+ padding: 'var(--tai-space-2)',
168
+ borderRadius: 'var(--tai-radius-md)',
169
+ background: 'var(--tai-color-warn-tint)',
170
+ color: 'var(--tai-color-warn-text)',
171
+ }}
172
+ >
173
+ <AlertTriangleIcon aria-hidden="true" />
174
+ <div>
175
+ <strong>Overrides the preset&rsquo;s subject</strong>
176
+ <div style={{ display: 'flex', gap: 'var(--tai-space-2)', flexWrap: 'wrap' }}>
177
+ <span
178
+ style={{ color: 'var(--tai-color-text-muted)', fontSize: 'var(--tai-text-sm)' }}
179
+ >
180
+ Preset default:
181
+ </span>
182
+ <span style={{ fontFamily: 'var(--tai-font-mono)', fontSize: 'var(--tai-text-sm)' }}>
183
+ {`subject: ${templatedTextSummary(inherited.subject_expr)}`}
184
+ {inherited.scope_expr !== null
185
+ ? `, scope: ${templatedTextSummary(inherited.scope_expr)}`
186
+ : ''}
187
+ </span>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ ) : null}
192
+
193
+ {attach.state !== '' && unresolved ? (
194
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
195
+ <p role="alert" style={{ margin: 0, color: 'var(--tai-color-err-text)' }}>
196
+ Template not attached.
197
+ </p>
198
+ <CodeBlock code={rawBindingLines(attach)} language="text" />
199
+ </div>
200
+ ) : null}
201
+
202
+ {attach.state !== '' && !unresolved ? (
203
+ <>
204
+ <Field
205
+ label="Templates"
206
+ group
207
+ description="One or more templates this binding attaches to the state."
208
+ >
209
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-1)' }}>
210
+ {templatesCatalog.length === 0 ? (
211
+ <p style={{ margin: 0, color: 'var(--tai-color-text-muted)' }}>
212
+ No templates available.
213
+ </p>
214
+ ) : (
215
+ templatesCatalog.map((template) => {
216
+ const checked = attach.templates.includes(template.name);
217
+ const willAttach = checked && !attachedTemplates.includes(template.name);
218
+ return (
219
+ <div
220
+ key={template.name}
221
+ style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}
222
+ >
223
+ <Checkbox
224
+ checked={checked}
225
+ label={template.name}
226
+ onCheckedChange={(next) => {
227
+ toggleTemplate(template.name, next);
228
+ }}
229
+ />
230
+ {willAttach ? (
231
+ <span
232
+ style={{
233
+ color: 'var(--tai-color-text-muted)',
234
+ fontSize: 'var(--tai-text-sm)',
235
+ }}
236
+ >
237
+ {ATTACH_ON_USE_HINT}
238
+ </span>
239
+ ) : null}
240
+ </div>
241
+ );
242
+ })
243
+ )}
244
+ </div>
245
+ </Field>
246
+
247
+ <SubjectScopeFields
248
+ subjectExpr={attach.subject_expr}
249
+ scopeExpr={attach.scope_expr}
250
+ subjectError={subjectError}
251
+ suggestions={suggestions}
252
+ templates={templates}
253
+ onSubjectChange={(next) => {
254
+ onChange({ ...attach, subject_expr: next });
255
+ }}
256
+ onScopeChange={(next) => {
257
+ onChange({ ...attach, scope_expr: next });
258
+ }}
259
+ />
260
+
261
+ <Field label="Inputs" group>
262
+ <InjectionList
263
+ injections={attach.input_injections}
264
+ inputJq={inputJq}
265
+ suggestions={suggestions}
266
+ templates={templates}
267
+ onChange={(next) => {
268
+ onChange({ ...attach, input_injections: [...next] });
269
+ }}
270
+ />
271
+ </Field>
272
+
273
+ <Field label="Updates" group>
274
+ <UpdateList
275
+ updates={attach.updates}
276
+ updateJq={updateJq}
277
+ sources={sources}
278
+ suggestions={suggestions}
279
+ templates={templates}
280
+ onChange={(next) => {
281
+ onChange({ ...attach, updates: [...next] });
282
+ }}
283
+ />
284
+ </Field>
285
+ </>
286
+ ) : null}
287
+ </div>
288
+ );
289
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The optional state-binding editor — one shape on every door (a preset, a channel
3
+ * route, a schedule, a hook) and, in the flow engine, per node. It attaches one or
4
+ * more states, each with the templates it attaches on save, a subject/scope, input
5
+ * injections evaluated before the run, and updates applied after it.
6
+ *
7
+ * It lives in the Studio SDK so every plugin door reuses it; it holds NO edge to jq
8
+ * (the jq fields render through the host's ambient expression door, else a plain
9
+ * textarea) and paints from SDK tokens only.
10
+ */
11
+ import type { ReactNode } from 'react';
12
+
13
+ import { Button, EmptyState, ErrorState, Skeleton } from '../components/primitives';
14
+ import { StateAttachRow } from './StateAttachRow';
15
+ import type {
16
+ BindingSourceSchemas,
17
+ BindingStateOption,
18
+ BindingTemplateOption,
19
+ StateAttach,
20
+ StateBinding,
21
+ TemplatedTextCatalog,
22
+ } from './types';
23
+
24
+ export interface StateBindingEditorProps {
25
+ /** The binding, or `null` when the door binds no state. */
26
+ readonly value: StateBinding | null;
27
+ readonly onChange: (value: StateBinding | null) => void;
28
+ /** The states this door may bind, each with its attached templates and fields. */
29
+ readonly statesCatalog: readonly BindingStateOption[];
30
+ /** The templates the editor may attach (attach-on-use), each with its template jq. */
31
+ readonly templatesCatalog: readonly BindingTemplateOption[];
32
+ /** The run's output/input field paths a picker offers when a schema is known. */
33
+ readonly sources?: BindingSourceSchemas;
34
+ /**
35
+ * The TARGET's own binding (e.g. the preset a route dispatches), passed by the door
36
+ * screen — never fetched here. A state this binding names that the inherited one
37
+ * also names shows a precedence warning on its card.
38
+ */
39
+ readonly inherited?: StateBinding | null;
40
+ /** The catalog fetch is in flight. */
41
+ readonly loading?: boolean;
42
+ /** The catalog fetch failed. */
43
+ readonly error?: string;
44
+ /** The stored templates a templated-text field's id picker offers, resolved by the door. */
45
+ readonly templatedTextTemplates?: TemplatedTextCatalog;
46
+ }
47
+
48
+ function blankAttach(): StateAttach {
49
+ return {
50
+ state: '',
51
+ templates: [],
52
+ subject_expr: { content: '' },
53
+ scope_expr: null,
54
+ input_injections: [],
55
+ updates: [],
56
+ };
57
+ }
58
+
59
+ export function StateBindingEditor({
60
+ value,
61
+ onChange,
62
+ statesCatalog,
63
+ templatesCatalog,
64
+ sources,
65
+ inherited,
66
+ loading = false,
67
+ error,
68
+ templatedTextTemplates,
69
+ }: StateBindingEditorProps): ReactNode {
70
+ if (loading) {
71
+ return (
72
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
73
+ <Skeleton height={32} />
74
+ <Skeleton height={32} />
75
+ </div>
76
+ );
77
+ }
78
+ if (error !== undefined) {
79
+ return <ErrorState message="Couldn't load states and templates." />;
80
+ }
81
+
82
+ const states = value?.states ?? [];
83
+
84
+ const setStates = (next: readonly StateAttach[]): void => {
85
+ onChange(next.length === 0 ? null : { states: [...next] });
86
+ };
87
+
88
+ return (
89
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-3)' }}>
90
+ {states.length === 0 ? (
91
+ <EmptyState
92
+ title="No state bound"
93
+ description="Attach a state to inject a value into the run and update the record after it."
94
+ action={
95
+ <Button
96
+ type="button"
97
+ variant="primary"
98
+ onClick={() => {
99
+ setStates([blankAttach()]);
100
+ }}
101
+ >
102
+ Attach a state
103
+ </Button>
104
+ }
105
+ />
106
+ ) : (
107
+ <>
108
+ {states.map((attach, index) => {
109
+ const inheritedAttach =
110
+ attach.state === ''
111
+ ? undefined
112
+ : inherited?.states.find((entry) => entry.state === attach.state);
113
+ // The subject is a required authored value; an empty one (no stored id and
114
+ // no inline text) is caught inline once a state is chosen, never sent for
115
+ // the server to 422.
116
+ const subjectEmpty =
117
+ attach.subject_expr.id !== undefined
118
+ ? attach.subject_expr.id.trim() === ''
119
+ : (attach.subject_expr.content ?? '').trim() === '';
120
+ const subjectError =
121
+ attach.state !== '' && subjectEmpty ? 'Subject is required.' : undefined;
122
+ return (
123
+ <StateAttachRow
124
+ // Positional identity within a save; a state name may be blank while picking.
125
+ key={index}
126
+ attach={attach}
127
+ statesCatalog={statesCatalog}
128
+ templatesCatalog={templatesCatalog}
129
+ sources={sources}
130
+ subjectError={subjectError}
131
+ templates={templatedTextTemplates}
132
+ inherited={
133
+ inheritedAttach === undefined
134
+ ? undefined
135
+ : {
136
+ subject_expr: inheritedAttach.subject_expr,
137
+ scope_expr: inheritedAttach.scope_expr,
138
+ }
139
+ }
140
+ onChange={(next) => {
141
+ setStates(
142
+ states.map((current, position) => (position === index ? next : current)),
143
+ );
144
+ }}
145
+ onRemove={() => {
146
+ setStates(states.filter((_, position) => position !== index));
147
+ }}
148
+ />
149
+ );
150
+ })}
151
+ <div>
152
+ <Button
153
+ type="button"
154
+ variant="ghost"
155
+ onClick={() => {
156
+ setStates([...states, blankAttach()]);
157
+ }}
158
+ >
159
+ Attach a state
160
+ </Button>
161
+ </div>
162
+ </>
163
+ )}
164
+ </div>
165
+ );
166
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The collapsed "Bind state (optional)" section every platform door screen adds
3
+ * below its own fields. It is chrome only — the door fetches the catalogs (via its
4
+ * own api-client + query stack) and passes them in; this wraps `StateBindingEditor`
5
+ * in a keyboard-native disclosure that opens when a binding already exists.
6
+ */
7
+ import { useState, type ReactNode } from 'react';
8
+
9
+ import { Button } from '../components/primitives';
10
+ import { StateBindingEditor, type StateBindingEditorProps } from './StateBindingEditor';
11
+
12
+ export interface StateBindingSectionProps extends StateBindingEditorProps {
13
+ /** Copy on the disclosure toggle. */
14
+ readonly label?: string;
15
+ }
16
+
17
+ export function StateBindingSection({
18
+ label = 'Bind state (optional)',
19
+ ...editor
20
+ }: StateBindingSectionProps): ReactNode {
21
+ const [open, setOpen] = useState((editor.value?.states.length ?? 0) > 0);
22
+ return (
23
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
24
+ <Button
25
+ type="button"
26
+ variant="ghost"
27
+ aria-expanded={open}
28
+ onClick={() => {
29
+ setOpen((current) => !current);
30
+ }}
31
+ >
32
+ {label}
33
+ </Button>
34
+ {open ? <StateBindingEditor {...editor} /> : null}
35
+ </div>
36
+ );
37
+ }