@tai42/studio-sdk 13.3.0 → 14.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 (87) hide show
  1. package/dist/components/icons.d.ts +2 -0
  2. package/dist/components/icons.d.ts.map +1 -1
  3. package/dist/components/icons.js +2 -0
  4. package/dist/components/icons.js.map +1 -1
  5. package/dist/hooks/useApi.d.ts +199 -24
  6. package/dist/hooks/useApi.d.ts.map +1 -1
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -2
  10. package/dist/index.js.map +1 -1
  11. package/dist/navigation/types.d.ts +2 -1
  12. package/dist/navigation/types.d.ts.map +1 -1
  13. package/dist/query-keys.d.ts +13 -0
  14. package/dist/query-keys.d.ts.map +1 -1
  15. package/dist/query-keys.js +13 -0
  16. package/dist/query-keys.js.map +1 -1
  17. package/dist/state-binding/AdapterMapping.d.ts +33 -0
  18. package/dist/state-binding/AdapterMapping.d.ts.map +1 -0
  19. package/dist/state-binding/AdapterMapping.js +205 -0
  20. package/dist/state-binding/AdapterMapping.js.map +1 -0
  21. package/dist/state-binding/BindingJqField.d.ts +36 -0
  22. package/dist/state-binding/BindingJqField.d.ts.map +1 -0
  23. package/dist/state-binding/BindingJqField.js +54 -0
  24. package/dist/state-binding/BindingJqField.js.map +1 -0
  25. package/dist/state-binding/InputInjectionRow.d.ts +17 -0
  26. package/dist/state-binding/InputInjectionRow.d.ts.map +1 -0
  27. package/dist/state-binding/InputInjectionRow.js +55 -0
  28. package/dist/state-binding/InputInjectionRow.js.map +1 -0
  29. package/dist/state-binding/StateAttachRow.d.ts +25 -0
  30. package/dist/state-binding/StateAttachRow.d.ts.map +1 -0
  31. package/dist/state-binding/StateAttachRow.js +101 -0
  32. package/dist/state-binding/StateAttachRow.js.map +1 -0
  33. package/dist/state-binding/StateBindingEditor.d.ts +35 -0
  34. package/dist/state-binding/StateBindingEditor.d.ts.map +1 -0
  35. package/dist/state-binding/StateBindingEditor.js +52 -0
  36. package/dist/state-binding/StateBindingEditor.js.map +1 -0
  37. package/dist/state-binding/StateBindingSection.d.ts +14 -0
  38. package/dist/state-binding/StateBindingSection.d.ts.map +1 -0
  39. package/dist/state-binding/StateBindingSection.js +17 -0
  40. package/dist/state-binding/StateBindingSection.js.map +1 -0
  41. package/dist/state-binding/SubjectScopeFields.d.ts +17 -0
  42. package/dist/state-binding/SubjectScopeFields.d.ts.map +1 -0
  43. package/dist/state-binding/SubjectScopeFields.js +8 -0
  44. package/dist/state-binding/SubjectScopeFields.js.map +1 -0
  45. package/dist/state-binding/UpdateRow.d.ts +19 -0
  46. package/dist/state-binding/UpdateRow.d.ts.map +1 -0
  47. package/dist/state-binding/UpdateRow.js +76 -0
  48. package/dist/state-binding/UpdateRow.js.map +1 -0
  49. package/dist/state-binding/adapter.d.ts +93 -0
  50. package/dist/state-binding/adapter.d.ts.map +1 -0
  51. package/dist/state-binding/adapter.js +251 -0
  52. package/dist/state-binding/adapter.js.map +1 -0
  53. package/dist/state-binding/build-catalog.d.ts +19 -0
  54. package/dist/state-binding/build-catalog.d.ts.map +1 -0
  55. package/dist/state-binding/build-catalog.js +43 -0
  56. package/dist/state-binding/build-catalog.js.map +1 -0
  57. package/dist/state-binding/catalog.d.ts +26 -0
  58. package/dist/state-binding/catalog.d.ts.map +1 -0
  59. package/dist/state-binding/catalog.js +71 -0
  60. package/dist/state-binding/catalog.js.map +1 -0
  61. package/dist/state-binding/index.d.ts +29 -0
  62. package/dist/state-binding/index.d.ts.map +1 -0
  63. package/dist/state-binding/index.js +18 -0
  64. package/dist/state-binding/index.js.map +1 -0
  65. package/dist/state-binding/types.d.ts +57 -0
  66. package/dist/state-binding/types.d.ts.map +1 -0
  67. package/dist/state-binding/types.js +2 -0
  68. package/dist/state-binding/types.js.map +1 -0
  69. package/package.json +5 -5
  70. package/src/components/icons.tsx +8 -0
  71. package/src/index.ts +63 -1
  72. package/src/navigation/types.ts +4 -1
  73. package/src/query-keys.ts +15 -0
  74. package/src/state-binding/AdapterMapping.tsx +468 -0
  75. package/src/state-binding/BindingJqField.tsx +134 -0
  76. package/src/state-binding/InputInjectionRow.tsx +148 -0
  77. package/src/state-binding/README.md +46 -0
  78. package/src/state-binding/StateAttachRow.tsx +281 -0
  79. package/src/state-binding/StateBindingEditor.tsx +158 -0
  80. package/src/state-binding/StateBindingSection.tsx +37 -0
  81. package/src/state-binding/SubjectScopeFields.tsx +49 -0
  82. package/src/state-binding/UpdateRow.tsx +194 -0
  83. package/src/state-binding/adapter.ts +276 -0
  84. package/src/state-binding/build-catalog.ts +59 -0
  85. package/src/state-binding/catalog.ts +87 -0
  86. package/src/state-binding/index.ts +61 -0
  87. package/src/state-binding/types.ts +62 -0
@@ -0,0 +1,158 @@
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
+ } from './types';
22
+
23
+ export interface StateBindingEditorProps {
24
+ /** The binding, or `null` when the door binds no state. */
25
+ readonly value: StateBinding | null;
26
+ readonly onChange: (value: StateBinding | null) => void;
27
+ /** The states this door may bind, each with its attached templates and fields. */
28
+ readonly statesCatalog: readonly BindingStateOption[];
29
+ /** The templates the editor may attach (attach-on-use), each with its template jq. */
30
+ readonly templatesCatalog: readonly BindingTemplateOption[];
31
+ /** The run's output/input field paths a picker offers when a schema is known. */
32
+ readonly sources?: BindingSourceSchemas;
33
+ /**
34
+ * The TARGET's own binding (e.g. the preset a route dispatches), passed by the door
35
+ * screen — never fetched here. A state this binding names that the inherited one
36
+ * also names shows a precedence warning on its card.
37
+ */
38
+ readonly inherited?: StateBinding | null;
39
+ /** The catalog fetch is in flight. */
40
+ readonly loading?: boolean;
41
+ /** The catalog fetch failed. */
42
+ readonly error?: string;
43
+ }
44
+
45
+ function blankAttach(): StateAttach {
46
+ return {
47
+ state: '',
48
+ templates: [],
49
+ subject_expr: '',
50
+ scope_expr: null,
51
+ input_injections: [],
52
+ updates: [],
53
+ };
54
+ }
55
+
56
+ export function StateBindingEditor({
57
+ value,
58
+ onChange,
59
+ statesCatalog,
60
+ templatesCatalog,
61
+ sources,
62
+ inherited,
63
+ loading = false,
64
+ error,
65
+ }: StateBindingEditorProps): ReactNode {
66
+ if (loading) {
67
+ return (
68
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
69
+ <Skeleton height={32} />
70
+ <Skeleton height={32} />
71
+ </div>
72
+ );
73
+ }
74
+ if (error !== undefined) {
75
+ return <ErrorState message="Couldn't load states and templates." />;
76
+ }
77
+
78
+ const states = value?.states ?? [];
79
+
80
+ const setStates = (next: readonly StateAttach[]): void => {
81
+ onChange(next.length === 0 ? null : { states: [...next] });
82
+ };
83
+
84
+ return (
85
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-3)' }}>
86
+ {states.length === 0 ? (
87
+ <EmptyState
88
+ title="No state bound"
89
+ description="Attach a state to inject a value into the run and update the record after it."
90
+ action={
91
+ <Button
92
+ type="button"
93
+ variant="primary"
94
+ onClick={() => {
95
+ setStates([blankAttach()]);
96
+ }}
97
+ >
98
+ Attach a state
99
+ </Button>
100
+ }
101
+ />
102
+ ) : (
103
+ <>
104
+ {states.map((attach, index) => {
105
+ const inheritedAttach =
106
+ attach.state === ''
107
+ ? undefined
108
+ : inherited?.states.find((entry) => entry.state === attach.state);
109
+ // The subject is a required jq (contract: min_length=1); an empty one is
110
+ // caught inline once a state is chosen, never sent for the server to 422.
111
+ const subjectError =
112
+ attach.state !== '' && attach.subject_expr.trim() === ''
113
+ ? 'Subject is required.'
114
+ : undefined;
115
+ return (
116
+ <StateAttachRow
117
+ // Positional identity within a save; a state name may be blank while picking.
118
+ key={index}
119
+ attach={attach}
120
+ statesCatalog={statesCatalog}
121
+ templatesCatalog={templatesCatalog}
122
+ sources={sources}
123
+ subjectError={subjectError}
124
+ inherited={
125
+ inheritedAttach === undefined
126
+ ? undefined
127
+ : {
128
+ subject_expr: inheritedAttach.subject_expr,
129
+ scope_expr: inheritedAttach.scope_expr,
130
+ }
131
+ }
132
+ onChange={(next) => {
133
+ setStates(
134
+ states.map((current, position) => (position === index ? next : current)),
135
+ );
136
+ }}
137
+ onRemove={() => {
138
+ setStates(states.filter((_, position) => position !== index));
139
+ }}
140
+ />
141
+ );
142
+ })}
143
+ <div>
144
+ <Button
145
+ type="button"
146
+ variant="ghost"
147
+ onClick={() => {
148
+ setStates([...states, blankAttach()]);
149
+ }}
150
+ >
151
+ Attach a state
152
+ </Button>
153
+ </div>
154
+ </>
155
+ )}
156
+ </div>
157
+ );
158
+ }
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The subject + scope jq of one attached state: the SUBJECT expression (required)
3
+ * yields either a bare record-key string or a full subject object; the optional SCOPE
4
+ * expression is a boolean jq predicate — when it is false the state is skipped for the run.
5
+ */
6
+ import type { ReactNode } from 'react';
7
+
8
+ import { BindingJqField, type TemplateJqSuggestion } from './BindingJqField';
9
+
10
+ export interface SubjectScopeFieldsProps {
11
+ readonly subjectExpr: string;
12
+ readonly scopeExpr: string | null;
13
+ readonly onSubjectChange: (value: string) => void;
14
+ readonly onScopeChange: (value: string | null) => void;
15
+ readonly subjectError?: string;
16
+ readonly suggestions?: readonly TemplateJqSuggestion[];
17
+ }
18
+
19
+ export function SubjectScopeFields({
20
+ subjectExpr,
21
+ scopeExpr,
22
+ onSubjectChange,
23
+ onScopeChange,
24
+ subjectError,
25
+ suggestions,
26
+ }: SubjectScopeFieldsProps): ReactNode {
27
+ return (
28
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
29
+ <BindingJqField
30
+ label="Subject"
31
+ description="The record key this binding reads and updates: a key string, or a full subject object."
32
+ value={subjectExpr}
33
+ onChange={onSubjectChange}
34
+ error={subjectError}
35
+ suggestions={suggestions}
36
+ placeholder=".subject_id"
37
+ />
38
+ <BindingJqField
39
+ label="Scope"
40
+ description="Optional. A jq condition; when it is false this state is skipped for the run."
41
+ value={scopeExpr ?? ''}
42
+ onChange={(next) => {
43
+ onScopeChange(next.trim() === '' ? null : next);
44
+ }}
45
+ suggestions={suggestions}
46
+ />
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The update list of one attached state. Each update applies AFTER the run: either
3
+ * a template `update` jq (picked by name, whose declared input is authored through
4
+ * the adapter mapping) or a custom jq over `{ record, output, input }` that authors
5
+ * the whole op batch itself. An optional op-id expression carries idempotency.
6
+ */
7
+ import type { ReactNode } from 'react';
8
+
9
+ import { Badge } from '../components/badge';
10
+ import { Button } from '../components/primitives';
11
+ import { Field } from '../components/field';
12
+ import { Select } from '../components/select';
13
+ import { CloseIcon } from '../components/icons';
14
+ import { AdapterMapping } from './AdapterMapping';
15
+ import { BindingJqField, type TemplateJqSuggestion } from './BindingJqField';
16
+ import { findByRef, type ResolvedTemplateJq } from './catalog';
17
+ import type { BindingSourceSchemas, StateUpdate } from './types';
18
+
19
+ const CUSTOM = '__custom__';
20
+
21
+ function optionLabel(entry: ResolvedTemplateJq): string {
22
+ return entry.description !== undefined && entry.description !== ''
23
+ ? `${entry.ref} — ${entry.description}`
24
+ : entry.ref;
25
+ }
26
+
27
+ function writesLabel(entry: ResolvedTemplateJq | undefined): string | null {
28
+ if (entry?.writes === undefined || entry.writes.length === 0) return null;
29
+ return entry.writes.map((path) => path.join('.')).join(', ');
30
+ }
31
+
32
+ function UpdateRowEditor({
33
+ update,
34
+ index,
35
+ updateJq,
36
+ sources,
37
+ suggestions,
38
+ onChange,
39
+ onRemove,
40
+ }: {
41
+ readonly update: StateUpdate;
42
+ readonly index: number;
43
+ readonly updateJq: readonly ResolvedTemplateJq[];
44
+ readonly sources?: BindingSourceSchemas;
45
+ readonly suggestions?: readonly TemplateJqSuggestion[];
46
+ readonly onChange: (update: StateUpdate) => void;
47
+ readonly onRemove: () => void;
48
+ }): ReactNode {
49
+ const isCustom = update.jq !== null;
50
+ const selectValue = isCustom ? CUSTOM : (update.template_jq ?? '');
51
+ const resolved =
52
+ update.template_jq !== null ? findByRef(update.template_jq, updateJq) : undefined;
53
+ const writes = writesLabel(resolved);
54
+
55
+ return (
56
+ <div
57
+ data-testid={`update-row-${String(index)}`}
58
+ style={{
59
+ display: 'flex',
60
+ flexDirection: 'column',
61
+ gap: 'var(--tai-space-2)',
62
+ padding: 'var(--tai-space-2)',
63
+ border: '1px solid var(--tai-color-border)',
64
+ borderRadius: 'var(--tai-radius-md)',
65
+ }}
66
+ >
67
+ <div
68
+ style={{
69
+ display: 'flex',
70
+ gap: 'var(--tai-space-2)',
71
+ alignItems: 'center',
72
+ flexWrap: 'wrap',
73
+ }}
74
+ >
75
+ <Field label={`Update source ${String(index + 1)}`} hideLabel>
76
+ <Select
77
+ placeholder="Choose a template jq"
78
+ value={selectValue}
79
+ onValueChange={(next) => {
80
+ if (next === CUSTOM)
81
+ onChange({ template_jq: null, jq: '', adapter: null, op_id: update.op_id });
82
+ else onChange({ template_jq: next, jq: null, adapter: null, op_id: update.op_id });
83
+ }}
84
+ groups={[
85
+ {
86
+ label: 'Template jq',
87
+ options: updateJq.map((entry) => ({ value: entry.ref, label: optionLabel(entry) })),
88
+ },
89
+ { label: 'Custom', options: [{ value: CUSTOM, label: 'Custom jq' }] },
90
+ ]}
91
+ />
92
+ </Field>
93
+ {writes !== null ? <Badge variant="neutral">{`writes: ${writes}`}</Badge> : null}
94
+ <Button
95
+ type="button"
96
+ variant="ghost"
97
+ aria-label={`Remove update ${String(index + 1)}`}
98
+ onClick={onRemove}
99
+ >
100
+ <CloseIcon aria-hidden="true" />
101
+ </Button>
102
+ </div>
103
+
104
+ {isCustom ? (
105
+ <BindingJqField
106
+ label={`Custom update jq ${String(index + 1)}`}
107
+ description="A jq over `{ record, output, input }` returning a template-relative op batch."
108
+ value={update.jq ?? ''}
109
+ onChange={(next) => {
110
+ onChange({ template_jq: null, jq: next, adapter: null, op_id: update.op_id });
111
+ }}
112
+ suggestions={suggestions}
113
+ />
114
+ ) : (
115
+ <Field label="Adapter" group>
116
+ <AdapterMapping
117
+ // Remount on a template change so the mapping rebuilds from the newly
118
+ // declared input (and re-seeds its shown default) instead of stale rows.
119
+ key={resolved?.ref ?? '__none__'}
120
+ declaredInput={resolved?.params ?? []}
121
+ value={update.adapter ?? ''}
122
+ sources={sources}
123
+ suggestions={suggestions}
124
+ onChange={(adapterJq) => {
125
+ onChange({ ...update, adapter: adapterJq });
126
+ }}
127
+ />
128
+ </Field>
129
+ )}
130
+
131
+ <BindingJqField
132
+ label="Op id"
133
+ description="Optional — an idempotency-key expression."
134
+ value={update.op_id ?? ''}
135
+ onChange={(next) => {
136
+ onChange({ ...update, op_id: next.trim() === '' ? null : next });
137
+ }}
138
+ suggestions={suggestions}
139
+ />
140
+ </div>
141
+ );
142
+ }
143
+
144
+ export interface UpdateListProps {
145
+ readonly updates: readonly StateUpdate[];
146
+ readonly onChange: (updates: readonly StateUpdate[]) => void;
147
+ readonly updateJq: readonly ResolvedTemplateJq[];
148
+ readonly sources?: BindingSourceSchemas;
149
+ readonly suggestions?: readonly TemplateJqSuggestion[];
150
+ }
151
+
152
+ export function UpdateList({
153
+ updates,
154
+ onChange,
155
+ updateJq,
156
+ sources,
157
+ suggestions,
158
+ }: UpdateListProps): ReactNode {
159
+ const add = (): void => {
160
+ const first = updateJq[0];
161
+ const next: StateUpdate =
162
+ first !== undefined
163
+ ? { template_jq: first.ref, jq: null, adapter: null, op_id: null }
164
+ : { template_jq: null, jq: '', adapter: null, op_id: null };
165
+ onChange([...updates, next]);
166
+ };
167
+
168
+ return (
169
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
170
+ {updates.map((update, index) => (
171
+ <UpdateRowEditor
172
+ // Rows are positional; the index is their stable identity within a save.
173
+ key={index}
174
+ update={update}
175
+ index={index}
176
+ updateJq={updateJq}
177
+ sources={sources}
178
+ suggestions={suggestions}
179
+ onChange={(next) => {
180
+ onChange(updates.map((current, position) => (position === index ? next : current)));
181
+ }}
182
+ onRemove={() => {
183
+ onChange(updates.filter((_, position) => position !== index));
184
+ }}
185
+ />
186
+ ))}
187
+ <div>
188
+ <Button type="button" variant="ghost" onClick={add}>
189
+ Add update
190
+ </Button>
191
+ </div>
192
+ </div>
193
+ );
194
+ }