@tai42/studio-sdk 17.1.5 → 18.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.
- package/dist/components/execution-key-picker.d.ts +37 -0
- package/dist/components/execution-key-picker.d.ts.map +1 -0
- package/dist/components/execution-key-picker.js +49 -0
- package/dist/components/execution-key-picker.js.map +1 -0
- package/dist/components/json-tree-nodes.d.ts.map +1 -1
- package/dist/components/json-tree-nodes.js +24 -9
- package/dist/components/json-tree-nodes.js.map +1 -1
- package/dist/components/overflow-measure.d.ts +11 -3
- package/dist/components/overflow-measure.d.ts.map +1 -1
- package/dist/components/overflow-measure.js +8 -5
- package/dist/components/overflow-measure.js.map +1 -1
- package/dist/components/scroll-region.d.ts +12 -2
- package/dist/components/scroll-region.d.ts.map +1 -1
- package/dist/components/scroll-region.js +7 -5
- package/dist/components/scroll-region.js.map +1 -1
- package/dist/components/subject-section.d.ts +78 -0
- package/dist/components/subject-section.d.ts.map +1 -0
- package/dist/components/subject-section.js +66 -0
- package/dist/components/subject-section.js.map +1 -0
- package/dist/components/tool-picker.d.ts.map +1 -1
- package/dist/components/tool-picker.js +11 -1
- package/dist/components/tool-picker.js.map +1 -1
- package/dist/hooks/useApi.d.ts +96 -45
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/schema-form/context.d.ts +19 -0
- package/dist/schema-form/context.d.ts.map +1 -1
- package/dist/schema-form/context.js.map +1 -1
- package/dist/schema-form/field-model.d.ts +19 -0
- package/dist/schema-form/field-model.d.ts.map +1 -1
- package/dist/schema-form/string-annotations.d.ts.map +1 -1
- package/dist/schema-form/string-annotations.js +26 -0
- package/dist/schema-form/string-annotations.js.map +1 -1
- package/dist/schema-form/string-field.js +22 -6
- package/dist/schema-form/string-field.js.map +1 -1
- package/dist/testing.d.ts +9 -7
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +16 -13
- package/dist/testing.js.map +1 -1
- package/package.json +9 -9
- package/src/components/execution-key-picker.tsx +116 -0
- package/src/components/json-tree-nodes.tsx +36 -18
- package/src/components/overflow-measure.ts +16 -5
- package/src/components/scroll-region.tsx +16 -3
- package/src/components/subject-section.tsx +177 -0
- package/src/components/tool-picker.tsx +14 -2
- package/src/index.ts +11 -0
- package/src/schema-form/context.ts +20 -0
- package/src/schema-form/field-model.ts +20 -0
- package/src/schema-form/string-annotations.ts +32 -1
- package/src/schema-form/string-field.tsx +25 -6
- package/src/testing.ts +21 -13
|
@@ -7,12 +7,23 @@
|
|
|
7
7
|
|
|
8
8
|
export const SCROLL_REGION_CLASS = 'tai-scroll-region';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The axis a scroll region is measured on. A prose table or a code line scrolls
|
|
12
|
+
* SIDEWAYS (`horizontal`, the default); a bounded, capped-height list scrolls
|
|
13
|
+
* DOWN (`vertical`). Both wear the same conditional region attributes — only the
|
|
14
|
+
* dimension read to decide whether they overflow differs.
|
|
15
|
+
*/
|
|
16
|
+
export type OverflowAxis = 'horizontal' | 'vertical';
|
|
17
|
+
|
|
18
|
+
export function overflows(element: HTMLElement, axis: OverflowAxis = 'horizontal'): boolean {
|
|
19
|
+
return axis === 'vertical'
|
|
20
|
+
? element.scrollHeight > element.clientHeight
|
|
21
|
+
: element.scrollWidth > element.clientWidth;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
/**
|
|
15
|
-
* Whether the element should carry the region attributes right now
|
|
25
|
+
* Whether the element should carry the region attributes right now, measured on
|
|
26
|
+
* `axis`.
|
|
16
27
|
*
|
|
17
28
|
* A region that stops overflowing keeps its tab stop for as long as it holds
|
|
18
29
|
* focus: taking `tabindex` off the focused element drops the reader onto the
|
|
@@ -20,8 +31,8 @@ export function overflows(element: HTMLElement): boolean {
|
|
|
20
31
|
* already has the stop can be the active element, so this can hold a stop open
|
|
21
32
|
* but never invent one.
|
|
22
33
|
*/
|
|
23
|
-
export function needsRegion(element: HTMLElement): boolean {
|
|
24
|
-
return overflows(element) || element.ownerDocument.activeElement === element;
|
|
34
|
+
export function needsRegion(element: HTMLElement, axis: OverflowAxis = 'horizontal'): boolean {
|
|
35
|
+
return overflows(element, axis) || element.ownerDocument.activeElement === element;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
/** A tracked scrollable surface, with the name it wears while it scrolls. */
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import {
|
|
35
35
|
clearScrollRegionAttributes,
|
|
36
36
|
needsRegion,
|
|
37
|
+
type OverflowAxis,
|
|
37
38
|
refreshRegions,
|
|
38
39
|
SCROLL_REGION_CLASS,
|
|
39
40
|
type TrackedSurface,
|
|
@@ -46,12 +47,20 @@ import {
|
|
|
46
47
|
uniquelyNamed,
|
|
47
48
|
} from './prose-regions';
|
|
48
49
|
|
|
50
|
+
export type { OverflowAxis } from './overflow-measure';
|
|
51
|
+
|
|
49
52
|
export interface ScrollRegionProps {
|
|
50
53
|
/** The region's accessible name, applied only while it actually scrolls. */
|
|
51
54
|
readonly label: string;
|
|
52
55
|
readonly children: ReactNode;
|
|
53
56
|
readonly className?: string;
|
|
54
57
|
readonly style?: CSSProperties;
|
|
58
|
+
/**
|
|
59
|
+
* The dimension overflow is measured on. `horizontal` (the default) fits a pane
|
|
60
|
+
* that scrolls sideways; `vertical` fits a bounded, capped-height box that scrolls
|
|
61
|
+
* down. The caller styles the box's own overflow to match.
|
|
62
|
+
*/
|
|
63
|
+
readonly axis?: OverflowAxis;
|
|
55
64
|
/**
|
|
56
65
|
* The one arbitrary attribute this component forwards. The measured attributes
|
|
57
66
|
* — `tabindex`, `role` and `aria-label` — are the component's own and appear
|
|
@@ -118,10 +127,13 @@ export interface OverflowRegionAttributes {
|
|
|
118
127
|
*
|
|
119
128
|
* @param ref - a consumer ref that wants the same element, or `undefined`.
|
|
120
129
|
* @param label - its accessible name, applied only while it actually scrolls.
|
|
130
|
+
* @param axis - the dimension to measure overflow on (`horizontal` by default;
|
|
131
|
+
* `vertical` for a bounded, capped-height box that scrolls down).
|
|
121
132
|
*/
|
|
122
133
|
export function useOverflowRegion(
|
|
123
134
|
ref: Ref<HTMLElement> | undefined,
|
|
124
135
|
label: string,
|
|
136
|
+
axis: OverflowAxis = 'horizontal',
|
|
125
137
|
): OverflowRegionAttributes {
|
|
126
138
|
const [scrollable, setScrollable] = useState(false);
|
|
127
139
|
|
|
@@ -132,7 +144,7 @@ export function useOverflowRegion(
|
|
|
132
144
|
const detachConsumer = attachRef(ref, box);
|
|
133
145
|
|
|
134
146
|
const measure = (): void => {
|
|
135
|
-
setScrollable(needsRegion(box));
|
|
147
|
+
setScrollable(needsRegion(box, axis));
|
|
136
148
|
};
|
|
137
149
|
|
|
138
150
|
// The box gives resize; its children give the overflowing width. The
|
|
@@ -177,7 +189,7 @@ export function useOverflowRegion(
|
|
|
177
189
|
detachConsumer();
|
|
178
190
|
};
|
|
179
191
|
},
|
|
180
|
-
[ref],
|
|
192
|
+
[ref, axis],
|
|
181
193
|
);
|
|
182
194
|
|
|
183
195
|
if (!scrollable) return { ref: measuredRef };
|
|
@@ -189,10 +201,11 @@ export function ScrollRegion({
|
|
|
189
201
|
children,
|
|
190
202
|
className,
|
|
191
203
|
style,
|
|
204
|
+
axis,
|
|
192
205
|
'data-testid': testId,
|
|
193
206
|
ref,
|
|
194
207
|
}: ScrollRegionProps) {
|
|
195
|
-
const region = useOverflowRegion(ref, label);
|
|
208
|
+
const region = useOverflowRegion(ref, label, axis);
|
|
196
209
|
|
|
197
210
|
return (
|
|
198
211
|
<div
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optional-subject sub-form shared by every form that keys a run's state to a
|
|
3
|
+
* conversation subject (the add-schedule dialog and the tool run panel). A collapsible
|
|
4
|
+
* block whose target / kind / key fields render only while expanded, so a form's default
|
|
5
|
+
* (collapsed) shape sends no subject.
|
|
6
|
+
*
|
|
7
|
+
* Presentational and prop-driven, like {@link ExecutionKeyPicker}: the host feature owns
|
|
8
|
+
* the conversation-targets query (the SDK holds no data-fetching library) and hands the
|
|
9
|
+
* resolved `targetOptions` in. {@link useSubjectFields} owns the field state; the host
|
|
10
|
+
* feature wraps it with its own targets query under its own query-key namespace. The
|
|
11
|
+
* caption and the two placeholders are the only copy that differs between hosts, so they
|
|
12
|
+
* are props.
|
|
13
|
+
*/
|
|
14
|
+
import type { StateSubject } from '@tai42/api-client';
|
|
15
|
+
import { type ReactNode, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import { Field } from './field';
|
|
18
|
+
import { TextInput } from './inputs';
|
|
19
|
+
import { Select } from './select';
|
|
20
|
+
|
|
21
|
+
/** One conversation-target option: `target_kind:target_name` value, `kind · name` label. */
|
|
22
|
+
export interface SubjectTargetOption {
|
|
23
|
+
readonly value: string;
|
|
24
|
+
readonly label: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The message a partially-filled subject is refused with (all three fields or none). */
|
|
28
|
+
export const SUBJECT_INCOMPLETE_MESSAGE = 'A subject needs a target, a kind, and a key.';
|
|
29
|
+
|
|
30
|
+
/** Map the conversation routes read to the target select's options. */
|
|
31
|
+
export function toSubjectTargetOptions(
|
|
32
|
+
routes: readonly { readonly target_kind: string; readonly target_name: string }[],
|
|
33
|
+
): SubjectTargetOption[] {
|
|
34
|
+
return routes.map((route) => ({
|
|
35
|
+
value: `${route.target_kind}:${route.target_name}`,
|
|
36
|
+
label: `${route.target_kind} · ${route.target_name}`,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a {@link StateSubject} from the sub-form values: `null` when untouched (no
|
|
42
|
+
* subject), a subject when target, kind and key are all set, or a loud failure when only
|
|
43
|
+
* some are — never a silently dropped partial subject. The target splits on its FIRST
|
|
44
|
+
* colon, so a target name may itself contain one.
|
|
45
|
+
*/
|
|
46
|
+
export function buildSubject(v: {
|
|
47
|
+
readonly target: string;
|
|
48
|
+
readonly kind: string;
|
|
49
|
+
readonly key: string;
|
|
50
|
+
}): { ok: true; subject: StateSubject | null } | { ok: false; message: string } {
|
|
51
|
+
const touched = v.target !== '' || v.kind.trim() !== '' || v.key.trim() !== '';
|
|
52
|
+
if (!touched) return { ok: true, subject: null };
|
|
53
|
+
const separator = v.target.indexOf(':');
|
|
54
|
+
const targetKind = separator < 0 ? '' : v.target.slice(0, separator);
|
|
55
|
+
const targetName = separator < 0 ? '' : v.target.slice(separator + 1);
|
|
56
|
+
if (targetKind === '' || targetName === '' || v.kind.trim() === '' || v.key.trim() === '') {
|
|
57
|
+
return { ok: false, message: SUBJECT_INCOMPLETE_MESSAGE };
|
|
58
|
+
}
|
|
59
|
+
// The target is chosen from the conversation-routes options, so its kind is one of the
|
|
60
|
+
// route target kinds; the value carries it as text and this narrows it to the subject's
|
|
61
|
+
// union without a runtime schema (the SDK holds no api-client value import).
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
subject: {
|
|
65
|
+
target_kind: targetKind as StateSubject['target_kind'],
|
|
66
|
+
target_name: targetName,
|
|
67
|
+
kind: v.kind.trim(),
|
|
68
|
+
key: v.key.trim(),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Subject field state + setters + the collapse toggle. The host feature owns the
|
|
74
|
+
* open-gated conversation-targets query and derives `targetOptions` from it. */
|
|
75
|
+
export function useSubjectFields() {
|
|
76
|
+
const [target, setTarget] = useState('');
|
|
77
|
+
const [kind, setKind] = useState('');
|
|
78
|
+
const [key, setKey] = useState('');
|
|
79
|
+
const [error, setError] = useState<string | null>(null);
|
|
80
|
+
const [open, setOpen] = useState(false);
|
|
81
|
+
return { target, setTarget, kind, setKind, key, setKey, error, setError, open, setOpen };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SubjectSectionProps {
|
|
85
|
+
readonly open: boolean;
|
|
86
|
+
readonly onToggle: () => void;
|
|
87
|
+
readonly target: string;
|
|
88
|
+
readonly onTargetChange: (value: string) => void;
|
|
89
|
+
readonly kind: string;
|
|
90
|
+
readonly onKindChange: (value: string) => void;
|
|
91
|
+
readonly subjectKey: string;
|
|
92
|
+
readonly onKeyChange: (value: string) => void;
|
|
93
|
+
readonly error: string | null;
|
|
94
|
+
readonly targetOptions: readonly SubjectTargetOption[];
|
|
95
|
+
/** The block's descriptive caption, shown under the expanded header. */
|
|
96
|
+
readonly caption: string;
|
|
97
|
+
/** The target select's empty-choice placeholder. */
|
|
98
|
+
readonly targetPlaceholder: string;
|
|
99
|
+
/** The Subject key field's helper description. */
|
|
100
|
+
readonly subjectKeyDescription: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function SubjectSection({
|
|
104
|
+
open,
|
|
105
|
+
onToggle,
|
|
106
|
+
target,
|
|
107
|
+
onTargetChange,
|
|
108
|
+
kind,
|
|
109
|
+
onKindChange,
|
|
110
|
+
subjectKey,
|
|
111
|
+
onKeyChange,
|
|
112
|
+
error,
|
|
113
|
+
targetOptions,
|
|
114
|
+
caption,
|
|
115
|
+
targetPlaceholder,
|
|
116
|
+
subjectKeyDescription,
|
|
117
|
+
}: SubjectSectionProps): ReactNode {
|
|
118
|
+
return (
|
|
119
|
+
<div>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
className="tai-btn tai-btn-ghost"
|
|
123
|
+
aria-expanded={open}
|
|
124
|
+
onClick={onToggle}
|
|
125
|
+
>
|
|
126
|
+
Subject (optional)
|
|
127
|
+
</button>
|
|
128
|
+
{open ? (
|
|
129
|
+
<div
|
|
130
|
+
style={{
|
|
131
|
+
display: 'flex',
|
|
132
|
+
flexDirection: 'column',
|
|
133
|
+
gap: 'var(--tai-space-3)',
|
|
134
|
+
marginTop: 'var(--tai-space-3)',
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<p style={{ margin: 0, color: 'var(--tai-color-text-muted)' }}>{caption}</p>
|
|
138
|
+
<Field label="Target">
|
|
139
|
+
<Select
|
|
140
|
+
value={target}
|
|
141
|
+
onValueChange={onTargetChange}
|
|
142
|
+
aria-label="Subject target"
|
|
143
|
+
placeholder={targetPlaceholder}
|
|
144
|
+
options={targetOptions}
|
|
145
|
+
/>
|
|
146
|
+
</Field>
|
|
147
|
+
<Field
|
|
148
|
+
label="Subject kind"
|
|
149
|
+
description="The subject family the state declares (e.g. person)."
|
|
150
|
+
>
|
|
151
|
+
<TextInput
|
|
152
|
+
value={kind}
|
|
153
|
+
placeholder="e.g. person"
|
|
154
|
+
onChange={(event) => {
|
|
155
|
+
onKindChange(event.target.value);
|
|
156
|
+
}}
|
|
157
|
+
/>
|
|
158
|
+
</Field>
|
|
159
|
+
<Field label="Subject key" description={subjectKeyDescription}>
|
|
160
|
+
<TextInput
|
|
161
|
+
value={subjectKey}
|
|
162
|
+
placeholder="e.g. a-42"
|
|
163
|
+
onChange={(event) => {
|
|
164
|
+
onKeyChange(event.target.value);
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
</Field>
|
|
168
|
+
{error !== null ? (
|
|
169
|
+
<p role="alert" style={{ margin: 0, color: 'var(--tai-color-err-text)' }}>
|
|
170
|
+
{error}
|
|
171
|
+
</p>
|
|
172
|
+
) : null}
|
|
173
|
+
</div>
|
|
174
|
+
) : null}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -38,6 +38,11 @@ import { ALL_TAGS, SelectedToolBadges, TagFilterField, ToolSelect } from './tool
|
|
|
38
38
|
|
|
39
39
|
export { BADGES_NOTE } from './tool-picker-parts';
|
|
40
40
|
|
|
41
|
+
/** The trigger copy when the picker has no option to offer at all (after exclusions).
|
|
42
|
+
* Shown only once the list has settled — a still-loading picker keeps its own
|
|
43
|
+
* placeholder — so it never misreads a pending fetch as "nothing to pick". */
|
|
44
|
+
const NO_TOOLS_AVAILABLE = 'No tools available';
|
|
45
|
+
|
|
41
46
|
export interface ToolPickerProps {
|
|
42
47
|
readonly toolNames: readonly string[];
|
|
43
48
|
readonly value: string | null;
|
|
@@ -94,6 +99,13 @@ export function ToolPicker({
|
|
|
94
99
|
const excluded = new Set(excludeNames ?? []);
|
|
95
100
|
const available = toolNames.filter((name) => !excluded.has(name));
|
|
96
101
|
|
|
102
|
+
// A settled, empty option set (not a load in progress) is a "nothing to pick"
|
|
103
|
+
// state: the trigger reads the empty copy and cannot open onto an empty popup. The
|
|
104
|
+
// caller's `disabled` still marks a loading picker, which keeps its own placeholder.
|
|
105
|
+
const noneAvailable = available.length === 0;
|
|
106
|
+
const pickerDisabled = disabled === true || noneAvailable;
|
|
107
|
+
const pickerPlaceholder = noneAvailable && disabled !== true ? NO_TOOLS_AVAILABLE : placeholder;
|
|
108
|
+
|
|
97
109
|
// Distinct tags across the available tools, sorted, feeding the filter control.
|
|
98
110
|
const allTags =
|
|
99
111
|
tagsByTool === undefined
|
|
@@ -113,9 +125,9 @@ export function ToolPicker({
|
|
|
113
125
|
filtered={filtered}
|
|
114
126
|
value={value}
|
|
115
127
|
onChange={onChange}
|
|
116
|
-
placeholder={
|
|
128
|
+
placeholder={pickerPlaceholder}
|
|
117
129
|
ariaLabel={ariaLabel}
|
|
118
|
-
disabled={
|
|
130
|
+
disabled={pickerDisabled}
|
|
119
131
|
displayNames={displayNames}
|
|
120
132
|
agentToolNames={agentToolNames}
|
|
121
133
|
/>
|
package/src/index.ts
CHANGED
|
@@ -221,6 +221,7 @@ export { Button, Card, EmptyState, ErrorState, Skeleton, Spinner } from './compo
|
|
|
221
221
|
export type { RadioGroupProps, RadioOption } from './components/radio-group';
|
|
222
222
|
export { RadioGroup } from './components/radio-group';
|
|
223
223
|
export type {
|
|
224
|
+
OverflowAxis,
|
|
224
225
|
OverflowRegionAttributes,
|
|
225
226
|
ProseScrollLabels,
|
|
226
227
|
ScrollRegionProps,
|
|
@@ -295,6 +296,8 @@ export { openTargetProps } from './components/open-target';
|
|
|
295
296
|
// hands back the parsed URL, so a caller that needs the URL does not re-parse it.
|
|
296
297
|
export type { CopyFieldProps } from './components/copy-field';
|
|
297
298
|
export { CopyField } from './components/copy-field';
|
|
299
|
+
export type { ExecutionKeyPickerProps, ExecutionKeyQuery } from './components/execution-key-picker';
|
|
300
|
+
export { ExecutionKeyPicker, isExecutionKeyListEmpty } from './components/execution-key-picker';
|
|
298
301
|
export type { ExtensionComboBuilderProps } from './components/extension-combo-builder';
|
|
299
302
|
export { ExtensionComboBuilder } from './components/extension-combo-builder';
|
|
300
303
|
export type { ExtensionFamily, ExtensionKindGroup } from './components/extension-grouping';
|
|
@@ -311,6 +314,14 @@ export type { ExternalLinkButtonProps } from './components/external-link';
|
|
|
311
314
|
export { safeHttpUrl } from './components/primitives';
|
|
312
315
|
export type { RevealInputProps } from './components/reveal-input';
|
|
313
316
|
export { RevealInput } from './components/reveal-input';
|
|
317
|
+
export type { SubjectSectionProps, SubjectTargetOption } from './components/subject-section';
|
|
318
|
+
export {
|
|
319
|
+
buildSubject,
|
|
320
|
+
SUBJECT_INCOMPLETE_MESSAGE,
|
|
321
|
+
SubjectSection,
|
|
322
|
+
toSubjectTargetOptions,
|
|
323
|
+
useSubjectFields,
|
|
324
|
+
} from './components/subject-section';
|
|
314
325
|
export type { TokenName } from './components/tokens';
|
|
315
326
|
export { TOKEN_NAMES } from './components/tokens';
|
|
316
327
|
export type { ToolPickerProps } from './components/tool-picker';
|
|
@@ -52,6 +52,23 @@ export interface ExpressionInputKey {
|
|
|
52
52
|
readonly gloss: string;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* One named jq variable the form hands the door beside `.`, mirroring jq-studio's
|
|
57
|
+
* variable descriptor without importing it. The door lists it in the legend, offers
|
|
58
|
+
* `$name` as a path root, and seeds the Test panel with its `sample`.
|
|
59
|
+
*/
|
|
60
|
+
export interface ExpressionInputVariable {
|
|
61
|
+
/** The variable name WITHOUT the leading `$` (referenced as `$name`). */
|
|
62
|
+
readonly name: string;
|
|
63
|
+
/** One sentence: what this variable holds. */
|
|
64
|
+
readonly blurb: string;
|
|
65
|
+
/** The top-level keys of the variable's value; empty when it is a scalar or
|
|
66
|
+
* the annotation glosses none. */
|
|
67
|
+
readonly keys: readonly ExpressionInputKey[];
|
|
68
|
+
/** A static skeleton of the variable's value for the door's Test panel. */
|
|
69
|
+
readonly sample?: unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
/**
|
|
56
73
|
* What `.` IS for an expression-annotated field, as the form hands it to the
|
|
57
74
|
* injected door. Built by the renderer from the schema's `x-tai42-expression`
|
|
@@ -77,6 +94,9 @@ export interface ExpressionInputShape {
|
|
|
77
94
|
readonly caveats?: readonly string[];
|
|
78
95
|
/** A static skeleton of `.` for the door's test surface. */
|
|
79
96
|
readonly sample?: unknown;
|
|
97
|
+
/** The named variables the expression reads as `$name` beside `.`; omitted when
|
|
98
|
+
* the field declares none. */
|
|
99
|
+
readonly variables?: readonly ExpressionInputVariable[];
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
/**
|
|
@@ -34,6 +34,23 @@ export interface ExpressionAnnotationKey {
|
|
|
34
34
|
readonly gloss: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* One named jq variable the expression may read as `$name` beside its `.` input:
|
|
39
|
+
* its blurb and a representative sample the editor's Test panel binds it to. The
|
|
40
|
+
* `x-tai42-expression` annotation carries these under `variables`; a field whose
|
|
41
|
+
* `.` is its only input carries none.
|
|
42
|
+
*/
|
|
43
|
+
export interface ExpressionAnnotationVariable {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly blurb: string;
|
|
46
|
+
/** True when the annotation carries a `sample` for this variable — tracked
|
|
47
|
+
* separately because a legitimate sample may be JSON `null`. */
|
|
48
|
+
readonly hasSample: boolean;
|
|
49
|
+
/** The sample value the Test panel binds `$name` to. Meaningful only when
|
|
50
|
+
* {@link hasSample} is true. */
|
|
51
|
+
readonly sample: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
/**
|
|
38
55
|
* The `x-tai42-expression` annotation on a string schema: the server declares
|
|
39
56
|
* that the field's value is authored in a pipeline expression language (only jq
|
|
@@ -53,6 +70,9 @@ export interface ExpressionAnnotation {
|
|
|
53
70
|
readonly blurb: string | undefined;
|
|
54
71
|
/** The top-level keys of `.`, each with a one-liner. */
|
|
55
72
|
readonly keys: readonly ExpressionAnnotationKey[] | undefined;
|
|
73
|
+
/** The named variables the expression reads as `$name` beside `.`. Absent when
|
|
74
|
+
* the annotation declares none (the field's `.` is its only input). */
|
|
75
|
+
readonly variables: readonly ExpressionAnnotationVariable[] | undefined;
|
|
56
76
|
/** What the expression must return (e.g. "true or false"). */
|
|
57
77
|
readonly returns: string | undefined;
|
|
58
78
|
/** Per-field caveats. */
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* renders differently from the plain string it falls back to.
|
|
7
7
|
*/
|
|
8
8
|
import { isRecord } from '../guards';
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
ExpressionAnnotation,
|
|
11
|
+
ExpressionAnnotationKey,
|
|
12
|
+
ExpressionAnnotationVariable,
|
|
13
|
+
MediaUpload,
|
|
14
|
+
} from './field-model';
|
|
10
15
|
import type { JsonSchema } from './types';
|
|
11
16
|
|
|
12
17
|
const EXPRESSION_KEYWORD = 'x-tai42-expression';
|
|
@@ -58,6 +63,29 @@ function parseAnnotationKeys(
|
|
|
58
63
|
return keys;
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
/** Parse the optional `variables` array: absent → `undefined`, malformed → `null`
|
|
67
|
+
* (reject). Each entry needs a string `name` and `blurb`; `sample` is any JSON
|
|
68
|
+
* (including `null`), so its presence is tracked rather than typed. */
|
|
69
|
+
function parseAnnotationVariables(
|
|
70
|
+
value: unknown,
|
|
71
|
+
): readonly ExpressionAnnotationVariable[] | undefined | null {
|
|
72
|
+
if (value === undefined) return undefined;
|
|
73
|
+
if (!Array.isArray(value)) return null;
|
|
74
|
+
const variables: ExpressionAnnotationVariable[] = [];
|
|
75
|
+
for (const entry of value as readonly unknown[]) {
|
|
76
|
+
if (!isRecord(entry) || typeof entry.name !== 'string' || typeof entry.blurb !== 'string') {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
variables.push({
|
|
80
|
+
name: entry.name,
|
|
81
|
+
blurb: entry.blurb,
|
|
82
|
+
hasSample: 'sample' in entry,
|
|
83
|
+
sample: entry.sample,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return variables;
|
|
87
|
+
}
|
|
88
|
+
|
|
61
89
|
/** Parse the optional `caveats` array: absent → `undefined`, malformed → `null` (reject). */
|
|
62
90
|
function parseCaveatList(value: unknown): readonly string[] | undefined | null {
|
|
63
91
|
if (value === undefined) return undefined;
|
|
@@ -94,6 +122,8 @@ export function expressionAnnotation(schema: JsonSchema): ExpressionAnnotation |
|
|
|
94
122
|
|
|
95
123
|
const keys = parseAnnotationKeys(raw.keys);
|
|
96
124
|
if (keys === null) return undefined;
|
|
125
|
+
const variables = parseAnnotationVariables(raw.variables);
|
|
126
|
+
if (variables === null) return undefined;
|
|
97
127
|
const caveats = parseCaveatList(raw.caveats);
|
|
98
128
|
if (caveats === null) return undefined;
|
|
99
129
|
|
|
@@ -103,6 +133,7 @@ export function expressionAnnotation(schema: JsonSchema): ExpressionAnnotation |
|
|
|
103
133
|
label: label.value,
|
|
104
134
|
blurb: blurb.value,
|
|
105
135
|
keys,
|
|
136
|
+
variables,
|
|
106
137
|
returns: returns.value,
|
|
107
138
|
caveats,
|
|
108
139
|
hasSample: 'sample' in raw,
|
|
@@ -296,12 +296,14 @@ function expressionShape(
|
|
|
296
296
|
argName: string,
|
|
297
297
|
): ExpressionInputShape | undefined {
|
|
298
298
|
const declared =
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
299
|
+
[
|
|
300
|
+
expression.label,
|
|
301
|
+
expression.blurb,
|
|
302
|
+
expression.keys,
|
|
303
|
+
expression.variables,
|
|
304
|
+
expression.returns,
|
|
305
|
+
expression.caveats,
|
|
306
|
+
].some((field) => field !== undefined) || expression.hasSample;
|
|
305
307
|
if (!declared) return undefined;
|
|
306
308
|
return {
|
|
307
309
|
// Opaque, host-namespaced, stable per field (memoisation/telemetry only).
|
|
@@ -312,6 +314,23 @@ function expressionShape(
|
|
|
312
314
|
returns: expression.returns ?? '',
|
|
313
315
|
...(expression.caveats === undefined ? {} : { caveats: expression.caveats }),
|
|
314
316
|
...(expression.hasSample ? { sample: expression.sample } : {}),
|
|
317
|
+
...(expression.variables === undefined
|
|
318
|
+
? {}
|
|
319
|
+
: { variables: expression.variables.map(expressionVariable) }),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Map one classified annotation variable onto the door's variable descriptor. */
|
|
324
|
+
function expressionVariable(
|
|
325
|
+
variable: NonNullable<ExpressionAnnotation['variables']>[number],
|
|
326
|
+
): NonNullable<ExpressionInputShape['variables']>[number] {
|
|
327
|
+
return {
|
|
328
|
+
name: variable.name,
|
|
329
|
+
blurb: variable.blurb,
|
|
330
|
+
// The annotation glosses no sub-keys of a variable's value, so the door gets
|
|
331
|
+
// the variable's shape as untyped (empty keys).
|
|
332
|
+
keys: [],
|
|
333
|
+
...(variable.hasSample ? { sample: variable.sample } : {}),
|
|
315
334
|
};
|
|
316
335
|
}
|
|
317
336
|
|
package/src/testing.ts
CHANGED
|
@@ -109,25 +109,33 @@ export function flushResizeObserversFor(target: Element): void {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/** The faked
|
|
113
|
-
const
|
|
114
|
-
const
|
|
112
|
+
/** The faked extents: equal reads as "fits", unequal as "scrolls". */
|
|
113
|
+
const FITTING_EXTENT = 100;
|
|
114
|
+
const OVERFLOWING_SCROLL_EXTENT = 400;
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
* Makes `element` report itself as
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
117
|
+
* Makes `element` report itself as overflowing (or not) on `axis`. jsdom runs no
|
|
118
|
+
* layout, so `scroll*` and `client*` are both 0 and every overflow test reads as
|
|
119
|
+
* "fits"; this shadows the prototype getters with own, configurable values so a
|
|
120
|
+
* test can drive either state. Call it again with the opposite flag to flip the
|
|
121
|
+
* element back.
|
|
122
122
|
*
|
|
123
123
|
* @param element - the element whose scroll metrics to fake.
|
|
124
|
-
* @param overflowing - true to report content
|
|
124
|
+
* @param overflowing - true to report content larger than the box on `axis`.
|
|
125
|
+
* @param axis - the dimension to fake: `horizontal` (width, the default) or
|
|
126
|
+
* `vertical` (height).
|
|
125
127
|
*/
|
|
126
|
-
export function setElementOverflow(
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
export function setElementOverflow(
|
|
129
|
+
element: HTMLElement,
|
|
130
|
+
overflowing: boolean,
|
|
131
|
+
axis: 'horizontal' | 'vertical' = 'horizontal',
|
|
132
|
+
): void {
|
|
133
|
+
const [client, scroll] =
|
|
134
|
+
axis === 'vertical' ? ['clientHeight', 'scrollHeight'] : ['clientWidth', 'scrollWidth'];
|
|
135
|
+
Object.defineProperty(element, client, { configurable: true, value: FITTING_EXTENT });
|
|
136
|
+
Object.defineProperty(element, scroll, {
|
|
129
137
|
configurable: true,
|
|
130
|
-
value: overflowing ?
|
|
138
|
+
value: overflowing ? OVERFLOWING_SCROLL_EXTENT : FITTING_EXTENT,
|
|
131
139
|
});
|
|
132
140
|
}
|
|
133
141
|
|