@tai42/studio-sdk 1.0.0 → 1.1.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 (45) hide show
  1. package/dist/hooks/useApi.d.ts +142 -0
  2. package/dist/hooks/useApi.d.ts.map +1 -1
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/schema-form/SecretRefField.d.ts +42 -0
  8. package/dist/schema-form/SecretRefField.d.ts.map +1 -0
  9. package/dist/schema-form/SecretRefField.js +94 -0
  10. package/dist/schema-form/SecretRefField.js.map +1 -0
  11. package/dist/schema-form/classify.d.ts +3 -0
  12. package/dist/schema-form/classify.d.ts.map +1 -1
  13. package/dist/schema-form/classify.js +17 -0
  14. package/dist/schema-form/classify.js.map +1 -1
  15. package/dist/schema-form/context.d.ts +24 -0
  16. package/dist/schema-form/context.d.ts.map +1 -1
  17. package/dist/schema-form/context.js +3 -0
  18. package/dist/schema-form/context.js.map +1 -1
  19. package/dist/schema-form/default-value.d.ts.map +1 -1
  20. package/dist/schema-form/default-value.js +2 -0
  21. package/dist/schema-form/default-value.js.map +1 -1
  22. package/dist/schema-form/field-node.d.ts.map +1 -1
  23. package/dist/schema-form/field-node.js +3 -0
  24. package/dist/schema-form/field-node.js.map +1 -1
  25. package/dist/schema-form/index.d.ts +4 -0
  26. package/dist/schema-form/index.d.ts.map +1 -1
  27. package/dist/schema-form/index.js +2 -0
  28. package/dist/schema-form/index.js.map +1 -1
  29. package/dist/schema-form/record-field.d.ts +15 -0
  30. package/dist/schema-form/record-field.d.ts.map +1 -0
  31. package/dist/schema-form/record-field.js +144 -0
  32. package/dist/schema-form/record-field.js.map +1 -0
  33. package/dist/schema-form/validate.d.ts.map +1 -1
  34. package/dist/schema-form/validate.js +10 -0
  35. package/dist/schema-form/validate.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/index.ts +6 -0
  38. package/src/schema-form/SecretRefField.tsx +247 -0
  39. package/src/schema-form/classify.ts +25 -0
  40. package/src/schema-form/context.ts +29 -0
  41. package/src/schema-form/default-value.ts +2 -0
  42. package/src/schema-form/field-node.tsx +16 -0
  43. package/src/schema-form/index.ts +4 -0
  44. package/src/schema-form/record-field.tsx +243 -0
  45. package/src/schema-form/validate.ts +10 -0
@@ -14,6 +14,7 @@ import { classifySchema } from './classify';
14
14
  import { EnumField } from './enum-field';
15
15
  import { FieldGroup } from './field-group';
16
16
  import { ObjectFields } from './object-fields';
17
+ import { RecordField } from './record-field';
17
18
  import { scalarLabel } from './resolve';
18
19
  import { StringField } from './string-field';
19
20
  import { UnionField } from './union-field';
@@ -142,6 +143,21 @@ export function FieldNode(props: FieldNodeProps): ReactNode {
142
143
  />
143
144
  </FieldGroup>
144
145
  );
146
+ case 'record':
147
+ return (
148
+ <RecordField
149
+ heading={heading}
150
+ description={description}
151
+ error={error}
152
+ values={model.values}
153
+ root={root}
154
+ value={value}
155
+ onChange={onChange}
156
+ path={path}
157
+ errors={errors}
158
+ idPrefix={idPrefix}
159
+ />
160
+ );
145
161
  case 'union':
146
162
  return (
147
163
  <UnionField
@@ -6,6 +6,10 @@
6
6
  */
7
7
  export { SchemaForm } from './SchemaForm';
8
8
  export type { SchemaFormProps, CompletionProvider } from './SchemaForm';
9
+ export { RecordEntryRendererContext } from './context';
10
+ export type { RecordEntryRenderer, RecordEntryContext } from './context';
11
+ export { SecretRefField } from './SecretRefField';
12
+ export type { SecretRefFieldProps, SecretRef } from './SecretRefField';
9
13
  export { defaultValueForSchema } from './default-value';
10
14
  export { validateAgainstSchema } from './validate';
11
15
  export { resolveRef } from './resolve';
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The record (string-keyed map) field renderer. Each entry is a key input plus a
3
+ * value editor built from the map's value schema; add appends a blank-key entry,
4
+ * remove drops one by row. Rows carry a synthetic stable id — not their key — so
5
+ * an entry keeps its identity (and focus) while its key is being typed, and so a
6
+ * transiently blank or duplicate key never remounts a row or clobbers another.
7
+ *
8
+ * The emitted value is a plain object of the VALID entries. A blank key and a
9
+ * duplicate key are held in local row state and surfaced (never emitted, never
10
+ * silently merged): the map cannot represent two entries under one key, so the
11
+ * later duplicate is withheld and flagged rather than overwriting the first.
12
+ */
13
+ import { useContext, useRef, useState } from 'react';
14
+ import type { ReactNode } from 'react';
15
+
16
+ import { CloseIcon } from '../components/icons';
17
+ import { Field } from '../components/field';
18
+ import { TextInput } from '../components/inputs';
19
+ import { Button } from '../components/primitives';
20
+ import { classifySchema } from './classify';
21
+ import { RecordEntryRendererContext } from './context';
22
+ import { defaultValueForSchema } from './default-value';
23
+ import { FieldGroup } from './field-group';
24
+ import { FieldNode } from './field-node';
25
+ import type { JsonSchema, SchemaFormErrors } from './types';
26
+
27
+ interface Row {
28
+ readonly id: number;
29
+ readonly key: string;
30
+ readonly value: unknown;
31
+ }
32
+
33
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
35
+ }
36
+
37
+ /** The valid map plus the row ids the two structural key faults land on. */
38
+ interface Analysis {
39
+ readonly value: Record<string, unknown>;
40
+ readonly blankRowIds: ReadonlySet<number>;
41
+ readonly duplicateRowIds: ReadonlySet<number>;
42
+ }
43
+
44
+ function analyzeRows(rows: readonly Row[]): Analysis {
45
+ const value: Record<string, unknown> = {};
46
+ const seen = new Set<string>();
47
+ const blankRowIds = new Set<number>();
48
+ const duplicateRowIds = new Set<number>();
49
+ for (const row of rows) {
50
+ if (row.key.trim() === '') {
51
+ blankRowIds.add(row.id);
52
+ continue;
53
+ }
54
+ if (seen.has(row.key)) {
55
+ duplicateRowIds.add(row.id);
56
+ continue;
57
+ }
58
+ seen.add(row.key);
59
+ value[row.key] = row.value;
60
+ }
61
+ return { value, blankRowIds, duplicateRowIds };
62
+ }
63
+
64
+ export function RecordField({
65
+ heading,
66
+ description,
67
+ error,
68
+ values,
69
+ root,
70
+ value,
71
+ onChange,
72
+ path,
73
+ errors,
74
+ idPrefix,
75
+ }: {
76
+ heading: string;
77
+ description: string | undefined;
78
+ error: string | undefined;
79
+ values: JsonSchema;
80
+ root: JsonSchema;
81
+ value: unknown;
82
+ onChange: (value: unknown) => void;
83
+ path: string;
84
+ errors: SchemaFormErrors | undefined;
85
+ idPrefix: string;
86
+ }): ReactNode {
87
+ const nextId = useRef(0);
88
+ const rowsFromValue = (source: unknown): Row[] =>
89
+ isPlainObject(source)
90
+ ? Object.entries(source).map(([key, entryValue]) => ({
91
+ id: nextId.current++,
92
+ key,
93
+ value: entryValue,
94
+ }))
95
+ : [];
96
+
97
+ const [rows, setRows] = useState<Row[]>(() => rowsFromValue(value));
98
+ // The last object THIS field emitted. A parent echoing our own value back
99
+ // passes it identity-equal, so the rows (which may hold a blank/duplicate key
100
+ // the emitted object cannot) survive; a parent that swaps in a different value
101
+ // (a reset, another record loaded) fails the check and resyncs the rows.
102
+ const lastEmitted = useRef<unknown>(value);
103
+ if (value !== lastEmitted.current) {
104
+ lastEmitted.current = value;
105
+ setRows(rowsFromValue(value));
106
+ }
107
+
108
+ const renderEntry = useContext(RecordEntryRendererContext);
109
+
110
+ const commit = (nextRows: Row[]): void => {
111
+ setRows(nextRows);
112
+ const emitted = analyzeRows(nextRows).value;
113
+ lastEmitted.current = emitted;
114
+ onChange(emitted);
115
+ };
116
+ const setKey = (id: number, key: string): void => {
117
+ commit(rows.map((row) => (row.id === id ? { ...row, key } : row)));
118
+ };
119
+ const setValue = (id: number, next: unknown): void => {
120
+ commit(rows.map((row) => (row.id === id ? { ...row, value: next } : row)));
121
+ };
122
+ const addRow = (): void => {
123
+ commit([...rows, { id: nextId.current++, key: '', value: newEntryValue(values, root) }]);
124
+ };
125
+ const removeRow = (id: number): void => {
126
+ commit(rows.filter((row) => row.id !== id));
127
+ };
128
+
129
+ const { blankRowIds, duplicateRowIds } = analyzeRows(rows);
130
+ const summary = keyFaultSummary(blankRowIds.size, duplicateRowIds.size);
131
+
132
+ return (
133
+ <FieldGroup heading={heading} description={description} error={error} atRoot={false}>
134
+ {rows.length === 0 ? <span className="tai-field-hint">No entries</span> : null}
135
+ {summary !== undefined ? (
136
+ <span role="alert" className="tai-field-error">
137
+ {summary}
138
+ </span>
139
+ ) : null}
140
+ {rows.map((row, index) => {
141
+ const position = index + 1;
142
+ const keyFault = rowKeyFault(row.id, blankRowIds, duplicateRowIds);
143
+ const entryPath = path === '' ? row.key : `${path}.${row.key}`;
144
+ const defaultField = (
145
+ <FieldNode
146
+ schema={values}
147
+ root={root}
148
+ value={row.value}
149
+ onChange={(next) => {
150
+ setValue(row.id, next);
151
+ }}
152
+ path={entryPath}
153
+ label={undefined}
154
+ required
155
+ errors={errors}
156
+ idPrefix={idPrefix}
157
+ />
158
+ );
159
+ return (
160
+ <div
161
+ // The synthetic id keys the row (see the module note): keying by the
162
+ // editable key or by index would remount the row as its key is typed.
163
+ key={row.id}
164
+ className="tai-row"
165
+ >
166
+ <Field label={`Key ${String(position)}`} error={keyFault}>
167
+ <TextInput
168
+ value={row.key}
169
+ onChange={(event) => {
170
+ setKey(row.id, event.target.value);
171
+ }}
172
+ />
173
+ </Field>
174
+ <div style={{ flex: 1 }}>
175
+ {renderEntry === undefined
176
+ ? defaultField
177
+ : renderEntry({
178
+ keyName: row.key,
179
+ path: entryPath,
180
+ valueSchema: values,
181
+ value: row.value,
182
+ onChange: (next) => {
183
+ setValue(row.id, next);
184
+ },
185
+ defaultField,
186
+ })}
187
+ </div>
188
+ <Button
189
+ type="button"
190
+ variant="secondary"
191
+ aria-label={`Remove entry ${String(position)}`}
192
+ onClick={() => {
193
+ removeRow(row.id);
194
+ }}
195
+ >
196
+ <CloseIcon />
197
+ Remove
198
+ </Button>
199
+ </div>
200
+ );
201
+ })}
202
+ <div>
203
+ <Button type="button" variant="secondary" onClick={addRow}>
204
+ Add entry
205
+ </Button>
206
+ </div>
207
+ </FieldGroup>
208
+ );
209
+ }
210
+
211
+ /** The inline key error for one row, or `undefined` when its key is fine. */
212
+ function rowKeyFault(
213
+ id: number,
214
+ blankRowIds: ReadonlySet<number>,
215
+ duplicateRowIds: ReadonlySet<number>,
216
+ ): string | undefined {
217
+ if (blankRowIds.has(id)) return 'Key is required';
218
+ if (duplicateRowIds.has(id)) return 'Duplicate key';
219
+ return undefined;
220
+ }
221
+
222
+ /** A one-line summary of the map's key faults, or `undefined` when there are none. */
223
+ function keyFaultSummary(blankCount: number, duplicateCount: number): string | undefined {
224
+ const parts: string[] = [];
225
+ if (blankCount > 0) parts.push(`${String(blankCount)} entry with a blank key`);
226
+ if (duplicateCount > 0) parts.push(`${String(duplicateCount)} entry with a duplicate key`);
227
+ if (parts.length === 0) return undefined;
228
+ return `${parts.join(' and ')} (not saved until fixed)`;
229
+ }
230
+
231
+ function newEntryValue(schema: JsonSchema, root: JsonSchema): unknown {
232
+ const seeded = defaultValueForSchema(schema, root);
233
+ if (seeded !== undefined) return seeded;
234
+ const classified = classifySchema(schema, root);
235
+ switch (classified.model.kind) {
236
+ case 'string':
237
+ return '';
238
+ case 'boolean':
239
+ return false;
240
+ default:
241
+ return undefined;
242
+ }
243
+ }
@@ -153,6 +153,16 @@ function walk(
153
153
  }
154
154
  return;
155
155
  }
156
+ case 'record': {
157
+ if (!isPlainObject(value)) {
158
+ errors[path] = 'must be an object';
159
+ return;
160
+ }
161
+ for (const [key, entryValue] of Object.entries(value)) {
162
+ walk(model.values, entryValue, joinPath(path, key), root, errors, maxUploadBytes);
163
+ }
164
+ return;
165
+ }
156
166
  case 'union': {
157
167
  const index = activeVariantIndex(value, model.variants, model.discriminator, root);
158
168
  const variant = index === -1 ? undefined : model.variants[index];