@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.
- package/dist/components/components.css +19 -0
- package/dist/components/icons.d.ts +2 -0
- package/dist/components/icons.d.ts.map +1 -1
- package/dist/components/icons.js +2 -0
- package/dist/components/icons.js.map +1 -1
- package/dist/components/templated-text-field.d.ts +98 -0
- package/dist/components/templated-text-field.d.ts.map +1 -0
- package/dist/components/templated-text-field.js +217 -0
- package/dist/components/templated-text-field.js.map +1 -0
- package/dist/hooks/useApi.d.ts +461 -59
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/navigation/types.d.ts +2 -1
- package/dist/navigation/types.d.ts.map +1 -1
- package/dist/query-keys.d.ts +13 -0
- package/dist/query-keys.d.ts.map +1 -1
- package/dist/query-keys.js +13 -0
- package/dist/query-keys.js.map +1 -1
- package/dist/state-binding/AdapterMapping.d.ts +33 -0
- package/dist/state-binding/AdapterMapping.d.ts.map +1 -0
- package/dist/state-binding/AdapterMapping.js +205 -0
- package/dist/state-binding/AdapterMapping.js.map +1 -0
- package/dist/state-binding/BindingJqField.d.ts +43 -0
- package/dist/state-binding/BindingJqField.d.ts.map +1 -0
- package/dist/state-binding/BindingJqField.js +57 -0
- package/dist/state-binding/BindingJqField.js.map +1 -0
- package/dist/state-binding/BindingTemplatedJqField.d.ts +24 -0
- package/dist/state-binding/BindingTemplatedJqField.d.ts.map +1 -0
- package/dist/state-binding/BindingTemplatedJqField.js +7 -0
- package/dist/state-binding/BindingTemplatedJqField.js.map +1 -0
- package/dist/state-binding/InputInjectionRow.d.ts +18 -0
- package/dist/state-binding/InputInjectionRow.d.ts.map +1 -0
- package/dist/state-binding/InputInjectionRow.js +55 -0
- package/dist/state-binding/InputInjectionRow.js.map +1 -0
- package/dist/state-binding/StateAttachRow.d.ts +27 -0
- package/dist/state-binding/StateAttachRow.d.ts.map +1 -0
- package/dist/state-binding/StateAttachRow.js +102 -0
- package/dist/state-binding/StateAttachRow.js.map +1 -0
- package/dist/state-binding/StateBindingEditor.d.ts +37 -0
- package/dist/state-binding/StateBindingEditor.d.ts.map +1 -0
- package/dist/state-binding/StateBindingEditor.js +54 -0
- package/dist/state-binding/StateBindingEditor.js.map +1 -0
- package/dist/state-binding/StateBindingSection.d.ts +14 -0
- package/dist/state-binding/StateBindingSection.d.ts.map +1 -0
- package/dist/state-binding/StateBindingSection.js +17 -0
- package/dist/state-binding/StateBindingSection.js.map +1 -0
- package/dist/state-binding/SubjectScopeFields.d.ts +20 -0
- package/dist/state-binding/SubjectScopeFields.d.ts.map +1 -0
- package/dist/state-binding/SubjectScopeFields.js +8 -0
- package/dist/state-binding/SubjectScopeFields.js.map +1 -0
- package/dist/state-binding/UpdateRow.d.ts +20 -0
- package/dist/state-binding/UpdateRow.d.ts.map +1 -0
- package/dist/state-binding/UpdateRow.js +89 -0
- package/dist/state-binding/UpdateRow.js.map +1 -0
- package/dist/state-binding/adapter.d.ts +93 -0
- package/dist/state-binding/adapter.d.ts.map +1 -0
- package/dist/state-binding/adapter.js +251 -0
- package/dist/state-binding/adapter.js.map +1 -0
- package/dist/state-binding/build-catalog.d.ts +19 -0
- package/dist/state-binding/build-catalog.d.ts.map +1 -0
- package/dist/state-binding/build-catalog.js +43 -0
- package/dist/state-binding/build-catalog.js.map +1 -0
- package/dist/state-binding/catalog.d.ts +26 -0
- package/dist/state-binding/catalog.d.ts.map +1 -0
- package/dist/state-binding/catalog.js +71 -0
- package/dist/state-binding/catalog.js.map +1 -0
- package/dist/state-binding/index.d.ts +33 -0
- package/dist/state-binding/index.d.ts.map +1 -0
- package/dist/state-binding/index.js +20 -0
- package/dist/state-binding/index.js.map +1 -0
- package/dist/state-binding/templated-text-catalog.d.ts +21 -0
- package/dist/state-binding/templated-text-catalog.d.ts.map +1 -0
- package/dist/state-binding/templated-text-catalog.js +48 -0
- package/dist/state-binding/templated-text-catalog.js.map +1 -0
- package/dist/state-binding/types.d.ts +78 -0
- package/dist/state-binding/types.d.ts.map +1 -0
- package/dist/state-binding/types.js +2 -0
- package/dist/state-binding/types.js.map +1 -0
- package/package.json +5 -5
- package/src/components/components.css +19 -0
- package/src/components/icons.tsx +8 -0
- package/src/components/templated-text-field.tsx +508 -0
- package/src/index.ts +76 -1
- package/src/navigation/types.ts +4 -1
- package/src/query-keys.ts +15 -0
- package/src/state-binding/AdapterMapping.tsx +468 -0
- package/src/state-binding/BindingJqField.tsx +146 -0
- package/src/state-binding/BindingTemplatedJqField.tsx +73 -0
- package/src/state-binding/InputInjectionRow.tsx +157 -0
- package/src/state-binding/README.md +46 -0
- package/src/state-binding/StateAttachRow.tsx +289 -0
- package/src/state-binding/StateBindingEditor.tsx +166 -0
- package/src/state-binding/StateBindingSection.tsx +37 -0
- package/src/state-binding/SubjectScopeFields.tsx +57 -0
- package/src/state-binding/UpdateRow.tsx +227 -0
- package/src/state-binding/adapter.ts +276 -0
- package/src/state-binding/build-catalog.ts +59 -0
- package/src/state-binding/catalog.ts +87 -0
- package/src/state-binding/index.ts +67 -0
- package/src/state-binding/templated-text-catalog.ts +69 -0
- 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’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
|
+
}
|