@selvajs/ui 4.4.0 → 4.6.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/compute/AppLayout.svelte +3 -3
- package/dist/components/compute/AppLayout.svelte.d.ts +1 -1
- package/dist/components/preview/InputControl.svelte +82 -3
- package/dist/components/preview/InputControl.svelte.d.ts +2 -0
- package/dist/components/preview/OutputDisplay.svelte +48 -45
- package/dist/components/preview/TabContent.svelte +13 -2
- package/dist/components/preview/TabContent.svelte.d.ts +2 -0
- package/dist/components/preview/TabLayout.svelte +6 -7
- package/dist/components/preview/TabLayout.svelte.d.ts +1 -1
- package/dist/components/preview/inputs/ChecklistInput.svelte +1 -1
- package/dist/components/preview/inputs/DropdownInput.svelte +1 -1
- package/dist/components/preview/inputs/FileInput.svelte +11 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -0
- package/dist/schema/dynamic-value-list.d.ts +17 -0
- package/dist/schema/dynamic-value-list.js +76 -0
- package/package.json +6 -5
- package/src/lib/components/compute/AppLayout.svelte +3 -3
- package/src/lib/components/preview/InputControl.svelte +82 -3
- package/src/lib/components/preview/OutputDisplay.svelte +48 -45
- package/src/lib/components/preview/TabContent.svelte +13 -2
- package/src/lib/components/preview/TabLayout.svelte +6 -7
- package/src/lib/components/preview/inputs/ChecklistInput.svelte +1 -1
- package/src/lib/components/preview/inputs/DropdownInput.svelte +1 -1
- package/src/lib/components/preview/inputs/FileInput.svelte +11 -0
- package/src/lib/index.ts +14 -1
- package/src/lib/schema/dynamic-value-list.test.ts +133 -0
- package/src/lib/schema/dynamic-value-list.ts +102 -0
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
oncalculate?: () => void;
|
|
26
26
|
values: Record<string, unknown>;
|
|
27
27
|
onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
|
|
28
|
-
onLoadValues?: () => void | Promise<void>;
|
|
28
|
+
onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
|
|
29
29
|
panelActions?: ActionButton[];
|
|
30
30
|
showSaveButton?: boolean;
|
|
31
31
|
showLoadButton?: boolean;
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
|
|
100
100
|
async function handleLoadValues(loadedValues: Record<string, unknown>) {
|
|
101
101
|
Object.assign(values, loadedValues);
|
|
102
|
-
await onLoadValues?.();
|
|
102
|
+
await onLoadValues?.(loadedValues);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
let _touchStartY = 0;
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
)}
|
|
126
126
|
<div class="panel-content-wrapper">
|
|
127
127
|
{#if schema.layout.type === 'tabbed'}
|
|
128
|
-
<TabLayout {schema}
|
|
128
|
+
<TabLayout {schema} {values} {onValueChange} {panelFilter} {requestedTabId} />
|
|
129
129
|
{/if}
|
|
130
130
|
{#if showParameterStateManager || (!isMobile && showCalculateButton && schema.instanceSolve === false)}
|
|
131
131
|
<div class="panel-footer px-3">
|
|
@@ -13,7 +13,7 @@ interface Props {
|
|
|
13
13
|
oncalculate?: () => void;
|
|
14
14
|
values: Record<string, unknown>;
|
|
15
15
|
onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
|
|
16
|
-
onLoadValues?: () => void | Promise<void>;
|
|
16
|
+
onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
|
|
17
17
|
panelActions?: ActionButton[];
|
|
18
18
|
showSaveButton?: boolean;
|
|
19
19
|
showLoadButton?: boolean;
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
InputLayoutItem,
|
|
4
|
+
FileInputWidgetConfig,
|
|
5
|
+
DynamicValueListWidgetConfig,
|
|
6
|
+
DropdownWidgetConfig,
|
|
7
|
+
SupportedTypes
|
|
8
|
+
} from '@selvajs/schemas';
|
|
3
9
|
import {
|
|
4
10
|
isNumberWidget,
|
|
5
11
|
isTextWidget,
|
|
6
12
|
isDropdownWidget,
|
|
13
|
+
isDynamicValueListWidget,
|
|
7
14
|
isCheckboxWidget,
|
|
8
15
|
isFileWidget,
|
|
9
16
|
isColorWidget
|
|
@@ -28,6 +35,8 @@
|
|
|
28
35
|
displayName?: string;
|
|
29
36
|
onChange: (paramId: string, value: SupportedTypes) => void;
|
|
30
37
|
disabled?: boolean;
|
|
38
|
+
/** Runtime-computed options for a dynamic value list input (name -> value). */
|
|
39
|
+
dynamicOptions?: Record<string, string>;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
let {
|
|
@@ -35,7 +44,8 @@
|
|
|
35
44
|
value = $bindable(undefined),
|
|
36
45
|
displayName,
|
|
37
46
|
onChange,
|
|
38
|
-
disabled = false
|
|
47
|
+
disabled = false,
|
|
48
|
+
dynamicOptions
|
|
39
49
|
}: Props = $props();
|
|
40
50
|
|
|
41
51
|
const inputId = $derived(`input-${item.paramId}`);
|
|
@@ -68,6 +78,50 @@
|
|
|
68
78
|
|
|
69
79
|
const showRangeInLabel = $derived(isNumberWidget(item) && numberRangeHint !== null);
|
|
70
80
|
|
|
81
|
+
// Dynamic value list: computed options (from the last solve) take precedence over the
|
|
82
|
+
// author's seed list. Empty until the first solve produces options, unless a default is set.
|
|
83
|
+
const dynamicListConfig = $derived(
|
|
84
|
+
isDynamicValueListWidget(item)
|
|
85
|
+
? (item.config as DynamicValueListWidgetConfig | undefined)
|
|
86
|
+
: undefined
|
|
87
|
+
);
|
|
88
|
+
const dynamicListOptions = $derived<Record<string, string>>(
|
|
89
|
+
dynamicOptions && Object.keys(dynamicOptions).length > 0
|
|
90
|
+
? dynamicOptions
|
|
91
|
+
: (Object.fromEntries(
|
|
92
|
+
Object.entries(dynamicListConfig?.defaultOptions ?? {}).filter(
|
|
93
|
+
(entry): entry is [string, string] => entry[1] !== undefined
|
|
94
|
+
)
|
|
95
|
+
) as Record<string, string>)
|
|
96
|
+
);
|
|
97
|
+
const dynamicListHasOptions = $derived(Object.keys(dynamicListOptions).length > 0);
|
|
98
|
+
// As a dropdown config so DropdownInput/ChecklistInput can consume it unchanged.
|
|
99
|
+
const dynamicListAsDropdownConfig = $derived<DropdownWidgetConfig>({
|
|
100
|
+
options: dynamicListOptions,
|
|
101
|
+
displayAs: dynamicListConfig?.displayAs ?? 'dropdown'
|
|
102
|
+
});
|
|
103
|
+
const hideDynamicListWhenEmpty = $derived(
|
|
104
|
+
isDynamicValueListWidget(item) &&
|
|
105
|
+
!dynamicListHasOptions &&
|
|
106
|
+
(dynamicListConfig?.emptyBehavior ?? 'hide') === 'hide'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// When a dynamic value list recomputes, a previously-selected value may no longer be an
|
|
110
|
+
// available option. Prune the stale selection so the control shows a valid option (or empty)
|
|
111
|
+
// instead of rendering the orphaned raw value as its own label.
|
|
112
|
+
$effect(() => {
|
|
113
|
+
if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
|
|
114
|
+
const validValues = new Set(Object.values(dynamicListOptions));
|
|
115
|
+
// Route through onChange (not commit) — value is a one-way prop here, so writing it
|
|
116
|
+
// directly from an effect trips Svelte's binding-ownership check.
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
|
|
119
|
+
if (pruned.length !== value.length) onChange(item.paramId, pruned);
|
|
120
|
+
} else if (typeof value === 'string' && value && !validValues.has(value)) {
|
|
121
|
+
onChange(item.paramId, '');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
71
125
|
function commit(newValue: SupportedTypes) {
|
|
72
126
|
value = newValue;
|
|
73
127
|
onChange(item.paramId, newValue);
|
|
@@ -83,7 +137,7 @@
|
|
|
83
137
|
value
|
|
84
138
|
})}
|
|
85
139
|
{/if}
|
|
86
|
-
{:else}
|
|
140
|
+
{:else if !hideDynamicListWhenEmpty}
|
|
87
141
|
<Field.Field>
|
|
88
142
|
<Field.Label for={inputId} class="gap-2 flex items-center">
|
|
89
143
|
{label}
|
|
@@ -152,6 +206,31 @@
|
|
|
152
206
|
{disabled}
|
|
153
207
|
/>
|
|
154
208
|
{/if}
|
|
209
|
+
{:else if isDynamicValueListWidget(item)}
|
|
210
|
+
{#if dynamicListHasOptions}
|
|
211
|
+
{#if dynamicListAsDropdownConfig.displayAs === 'checklist'}
|
|
212
|
+
<ChecklistInput
|
|
213
|
+
{inputId}
|
|
214
|
+
value={Array.isArray(value)
|
|
215
|
+
? (value as string[])
|
|
216
|
+
: typeof value === 'string' && value
|
|
217
|
+
? [value]
|
|
218
|
+
: []}
|
|
219
|
+
config={dynamicListAsDropdownConfig}
|
|
220
|
+
onChange={commit}
|
|
221
|
+
{disabled}
|
|
222
|
+
/>
|
|
223
|
+
{:else}
|
|
224
|
+
<DropdownInput
|
|
225
|
+
value={typeof value === 'string' ? value : ''}
|
|
226
|
+
config={dynamicListAsDropdownConfig}
|
|
227
|
+
onChange={commit}
|
|
228
|
+
{disabled}
|
|
229
|
+
/>
|
|
230
|
+
{/if}
|
|
231
|
+
{:else}
|
|
232
|
+
<p class="text-sm text-muted-foreground">No options available yet.</p>
|
|
233
|
+
{/if}
|
|
155
234
|
{:else if isFileWidget(item)}
|
|
156
235
|
{@const config = item.config as FileInputWidgetConfig}
|
|
157
236
|
<FileInput
|
|
@@ -5,6 +5,8 @@ interface Props {
|
|
|
5
5
|
displayName?: string;
|
|
6
6
|
onChange: (paramId: string, value: SupportedTypes) => void;
|
|
7
7
|
disabled?: boolean;
|
|
8
|
+
/** Runtime-computed options for a dynamic value list input (name -> value). */
|
|
9
|
+
dynamicOptions?: Record<string, string>;
|
|
8
10
|
}
|
|
9
11
|
declare const InputControl: import("svelte").Component<Props, {}, "value">;
|
|
10
12
|
type InputControl = ReturnType<typeof InputControl>;
|
|
@@ -288,49 +288,52 @@
|
|
|
288
288
|
{/if}
|
|
289
289
|
{/snippet}
|
|
290
290
|
|
|
291
|
-
|
|
292
|
-
|
|
291
|
+
<!-- Dynamic value list outputs are routing sinks (their options feed an input), not displayed. -->
|
|
292
|
+
{#if item.widgetType !== 'dynamicValueList'}
|
|
293
|
+
<div class="gap-2 flex flex-col">
|
|
294
|
+
{@render fieldHeader()}
|
|
293
295
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
</div>
|
|
296
|
+
{#if item.widgetType === 'chart'}
|
|
297
|
+
<ChartOutput
|
|
298
|
+
{item}
|
|
299
|
+
value={typeof value === 'string' ? value : value != null ? JSON.stringify(value) : ''}
|
|
300
|
+
/>
|
|
301
|
+
{:else if item.widgetType === 'image'}
|
|
302
|
+
<ImageOutput {item} {value} />
|
|
303
|
+
{:else if item.widgetType === 'file'}
|
|
304
|
+
{@render fileDisplay()}
|
|
305
|
+
{:else if item.widgetType === 'number'}
|
|
306
|
+
<div class="{boxClass} flex items-center bg-muted/50 wrap-break-word">
|
|
307
|
+
{#if hasValue}
|
|
308
|
+
<span class="font-bold text-primary">{formattedValue}</span>
|
|
309
|
+
{:else}
|
|
310
|
+
{@render placeholder()}
|
|
311
|
+
{/if}
|
|
312
|
+
</div>
|
|
313
|
+
{:else if item.widgetType === 'text'}
|
|
314
|
+
<div class="group relative">
|
|
315
|
+
{#if isObjectValue}
|
|
316
|
+
<pre
|
|
317
|
+
class="{boxClass} overflow-wrap-anywhere max-h-96 overflow-auto bg-muted/10 text-foreground">{formattedValue}</pre>
|
|
318
|
+
{:else}
|
|
319
|
+
<div
|
|
320
|
+
class="{boxClass} overflow-wrap-anywhere bg-muted/10 wrap-break-word whitespace-pre-wrap text-foreground"
|
|
321
|
+
>
|
|
322
|
+
{#if hasValue}{value}{:else}{@render placeholder()}{/if}
|
|
323
|
+
</div>
|
|
324
|
+
{/if}
|
|
325
|
+
{#if hasValue}
|
|
326
|
+
<Button
|
|
327
|
+
onclick={copyToClipboard}
|
|
328
|
+
class="right-2 top-2 absolute transition-opacity {copied
|
|
329
|
+
? 'opacity-100'
|
|
330
|
+
: 'opacity-0 group-hover:opacity-100'}"
|
|
331
|
+
size="sm"
|
|
332
|
+
>
|
|
333
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
334
|
+
</Button>
|
|
335
|
+
{/if}
|
|
336
|
+
</div>
|
|
337
|
+
{/if}
|
|
338
|
+
</div>
|
|
339
|
+
{/if}
|
|
@@ -23,10 +23,20 @@
|
|
|
23
23
|
onValueChange: (paramId: string, value: SupportedTypes) => void;
|
|
24
24
|
inputs: SchemaInput[];
|
|
25
25
|
outputs: DiscoveredOutput[];
|
|
26
|
+
/** Computed value list options keyed by the target dynamic-value-list input id. */
|
|
27
|
+
dynamicOptions?: Record<string, Record<string, string>>;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
let {
|
|
29
|
-
|
|
30
|
+
let {
|
|
31
|
+
tab,
|
|
32
|
+
values,
|
|
33
|
+
collapsedGroups,
|
|
34
|
+
onToggleGroup,
|
|
35
|
+
onValueChange,
|
|
36
|
+
inputs,
|
|
37
|
+
outputs,
|
|
38
|
+
dynamicOptions = {}
|
|
39
|
+
}: Props = $props();
|
|
30
40
|
|
|
31
41
|
function getInputById(paramId: string): SchemaInput | undefined {
|
|
32
42
|
return inputs.find((i) => i.id === paramId);
|
|
@@ -49,6 +59,7 @@
|
|
|
49
59
|
displayName={layoutItem.displayName}
|
|
50
60
|
onChange={onValueChange}
|
|
51
61
|
disabled={visibility.disabled}
|
|
62
|
+
dynamicOptions={dynamicOptions[input.id]}
|
|
52
63
|
/>
|
|
53
64
|
{/if}
|
|
54
65
|
{/snippet}
|
|
@@ -7,6 +7,8 @@ interface Props {
|
|
|
7
7
|
onValueChange: (paramId: string, value: SupportedTypes) => void;
|
|
8
8
|
inputs: SchemaInput[];
|
|
9
9
|
outputs: DiscoveredOutput[];
|
|
10
|
+
/** Computed value list options keyed by the target dynamic-value-list input id. */
|
|
11
|
+
dynamicOptions?: Record<string, Record<string, string>>;
|
|
10
12
|
}
|
|
11
13
|
declare const TabContent: import("svelte").Component<Props, {}, "">;
|
|
12
14
|
type TabContent = ReturnType<typeof TabContent>;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import TabBar from './TabBar.svelte';
|
|
6
6
|
import TabContent from './TabContent.svelte';
|
|
7
7
|
import { buildVisibilityMap, itemKey } from '../../schema/visibility-rules';
|
|
8
|
+
import { buildDynamicValueListOptions } from '../../schema/dynamic-value-list';
|
|
8
9
|
|
|
9
10
|
interface Props {
|
|
10
11
|
schema: UISchema;
|
|
@@ -16,13 +17,7 @@
|
|
|
16
17
|
requestedTabId?: string | null;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
let {
|
|
20
|
-
schema,
|
|
21
|
-
values = $bindable(),
|
|
22
|
-
onValueChange,
|
|
23
|
-
panelFilter,
|
|
24
|
-
requestedTabId = null
|
|
25
|
-
}: Props = $props();
|
|
20
|
+
let { schema, values, onValueChange, panelFilter, requestedTabId = null }: Props = $props();
|
|
26
21
|
|
|
27
22
|
let activeTabId = $state('');
|
|
28
23
|
let collapsedGroups = $state<Record<string, boolean>>({});
|
|
@@ -38,6 +33,9 @@
|
|
|
38
33
|
|
|
39
34
|
const showTabBar = $derived(visibleTabs.length > 1);
|
|
40
35
|
|
|
36
|
+
// Computed value list options keyed by the target input id, derived from solved outputs.
|
|
37
|
+
const dynamicOptions = $derived(buildDynamicValueListOptions(schema, values));
|
|
38
|
+
|
|
41
39
|
// Tab selection
|
|
42
40
|
$effect(() => {
|
|
43
41
|
if (requestedTabId && visibleTabs.some((t) => t.id === requestedTabId)) {
|
|
@@ -104,6 +102,7 @@
|
|
|
104
102
|
{onValueChange}
|
|
105
103
|
inputs={schema.inputs}
|
|
106
104
|
outputs={schema.outputs}
|
|
105
|
+
{dynamicOptions}
|
|
107
106
|
/>
|
|
108
107
|
{/each}
|
|
109
108
|
</Tabs.Root>
|
|
@@ -8,6 +8,6 @@ interface Props {
|
|
|
8
8
|
/** Externally request a specific tab to be active (e.g. from collapsed strip click) */
|
|
9
9
|
requestedTabId?: string | null;
|
|
10
10
|
}
|
|
11
|
-
declare const TabLayout: import("svelte").Component<Props, {}, "
|
|
11
|
+
declare const TabLayout: import("svelte").Component<Props, {}, "">;
|
|
12
12
|
type TabLayout = ReturnType<typeof TabLayout>;
|
|
13
13
|
export default TabLayout;
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
</script>
|
|
29
29
|
|
|
30
30
|
<div class="divide-y divide-border/60 overflow-hidden rounded-md border border-input">
|
|
31
|
-
{#each Object.entries(options) as [name, expr] (
|
|
31
|
+
{#each Object.entries(options) as [name, expr] (name)}
|
|
32
32
|
{@const optionValue = expr ?? name}
|
|
33
33
|
{@const optionId = `${inputId}-${optionValue}`}
|
|
34
34
|
{@const isSelected = selected.has(optionValue)}
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
{currentLabel || 'Select an option...'}
|
|
34
34
|
</Select.Trigger>
|
|
35
35
|
<Select.Content>
|
|
36
|
-
{#each Object.entries(options) as [name, expr] (
|
|
36
|
+
{#each Object.entries(options) as [name, expr] (name)}
|
|
37
37
|
<Select.Item value={expr ?? name} label={name} />
|
|
38
38
|
{/each}
|
|
39
39
|
</Select.Content>
|
|
@@ -201,6 +201,17 @@
|
|
|
201
201
|
return;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
// Guard the upload size client-side. The file is base64-embedded into the
|
|
205
|
+
// compute request body, so an oversize file would otherwise be rejected
|
|
206
|
+
// server-side with an opaque 413 (see COMPUTE_REQUEST_MAX_BYTES). The URL
|
|
207
|
+
// import path has the same check; keep the two in sync.
|
|
208
|
+
if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
|
|
209
|
+
alert(
|
|
210
|
+
`File too large: ${(file.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB).`
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
204
215
|
uploadedFileName = file.name;
|
|
205
216
|
|
|
206
217
|
const reader = new FileReader();
|
package/dist/index.d.ts
CHANGED
|
@@ -6,8 +6,10 @@ export * from './components/primitives';
|
|
|
6
6
|
export { default as StateDisplay } from './components/primitives/StateDisplay.svelte';
|
|
7
7
|
export { default as Viewer } from './components/viewer/Viewer.svelte';
|
|
8
8
|
export * from './schema/defaults';
|
|
9
|
+
export * from './schema/dynamic-value-list';
|
|
9
10
|
export * from './schema/traversal';
|
|
10
11
|
export * from './compute/solving.svelte';
|
|
12
|
+
export { createSolveSession, createRequestResponseDriver, type SolveSession, type SolveSessionArgs, type SolveDriver, type SolveReporter } from './compute/createSolveSession.svelte';
|
|
11
13
|
export * from './external/storage';
|
|
12
14
|
export * from './contexts/footerContext.svelte';
|
|
13
15
|
export * from './contexts/clientSlotContext.svelte';
|
|
@@ -15,5 +17,5 @@ export * from './composables/useFooterItem.svelte';
|
|
|
15
17
|
export * from './utils';
|
|
16
18
|
export { randomId } from './utils/randomId';
|
|
17
19
|
export type { ActionButton } from './types/actionButton';
|
|
18
|
-
export type { SolveFn } from './types/solveFn';
|
|
20
|
+
export type { SolveFn, SolveResult } from './types/solveFn';
|
|
19
21
|
export { DEFAULT_PRESET_LABELS, type PresetLabels } from './types/presetLabels';
|
package/dist/index.js
CHANGED
|
@@ -12,8 +12,13 @@ export { default as StateDisplay } from './components/primitives/StateDisplay.sv
|
|
|
12
12
|
export { default as Viewer } from './components/viewer/Viewer.svelte';
|
|
13
13
|
// Utilities
|
|
14
14
|
export * from './schema/defaults';
|
|
15
|
+
export * from './schema/dynamic-value-list';
|
|
15
16
|
export * from './schema/traversal';
|
|
16
17
|
export * from './compute/solving.svelte';
|
|
18
|
+
// Solve Session seam (transport-agnostic value/lifecycle state machine + its driver
|
|
19
|
+
// interface). Exported so transports outside this package — e.g. plugin-ui's WebSocket
|
|
20
|
+
// driver — can satisfy SolveDriver and drive a session. See CONTEXT.md.
|
|
21
|
+
export { createSolveSession, createRequestResponseDriver } from './compute/createSolveSession.svelte';
|
|
17
22
|
// External-input transit storage (used by routes that wire pre-step producers)
|
|
18
23
|
export * from './external/storage';
|
|
19
24
|
// Contexts & Composables
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { UISchema } from '@selvajs/schemas';
|
|
2
|
+
/**
|
|
3
|
+
* The runtime payload a dynamic value list output produces, keyed by the output's id in `values`.
|
|
4
|
+
* Routed back into the dynamic value list input identified by `targetInputId`.
|
|
5
|
+
*/
|
|
6
|
+
export interface DynamicValueListPayload {
|
|
7
|
+
targetInputId?: string | null;
|
|
8
|
+
options?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Build a map of `inputId -> computed options` from the solved output values.
|
|
12
|
+
*
|
|
13
|
+
* Reads each dynamicValueList source's `{ targetInputId, options }` payload from `values` and routes
|
|
14
|
+
* the options to the targeted input. The payload's own `targetInputId` wins; the schema-side
|
|
15
|
+
* `targetInputId` is the fallback when the payload omits it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildDynamicValueListOptions(schema: UISchema, values: Record<string, unknown>): Record<string, Record<string, string>>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { getLayoutItems } from '@selvajs/schemas';
|
|
2
|
+
function isDynamicValueListPayload(value) {
|
|
3
|
+
return (typeof value === 'object' && value !== null && ('targetInputId' in value || 'options' in value));
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a raw output value into a payload object.
|
|
7
|
+
*
|
|
8
|
+
* The local/WebSocket path delivers a real object; the Rhino.Compute path delivers the
|
|
9
|
+
* component's JSON output as a string (possibly double-encoded by the compute layer), so try
|
|
10
|
+
* to parse strings before giving up.
|
|
11
|
+
*/
|
|
12
|
+
function coercePayload(value) {
|
|
13
|
+
if (isDynamicValueListPayload(value))
|
|
14
|
+
return value;
|
|
15
|
+
let candidate = value;
|
|
16
|
+
// Unwrap up to two layers of JSON string encoding (compute may quote the string output).
|
|
17
|
+
for (let i = 0; i < 2 && typeof candidate === 'string'; i++) {
|
|
18
|
+
try {
|
|
19
|
+
candidate = JSON.parse(candidate);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (isDynamicValueListPayload(candidate))
|
|
25
|
+
return candidate;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Every dynamicValueList output reference in the schema.
|
|
31
|
+
*
|
|
32
|
+
* Canonical location is `schema.outputs[]` — the plugin's SchemaSynchronizer enforces that every
|
|
33
|
+
* dynamicValueList layout item is mirrored there (see CanonicalizeDynamicValueListOutputs). We ALSO
|
|
34
|
+
* scan the layout purely as back-compat defense for schemas persisted by an older plugin that lacked
|
|
35
|
+
* that invariant; for current schemas the layout pass finds nothing new.
|
|
36
|
+
* Deduped by id, outputs[] winning so the canonical record's targetInputId takes precedence.
|
|
37
|
+
*/
|
|
38
|
+
function collectDynamicValueListSources(schema) {
|
|
39
|
+
const byId = new Map();
|
|
40
|
+
for (const item of getLayoutItems(schema)) {
|
|
41
|
+
if (item.type !== 'output' || item.widgetType !== 'dynamicValueList')
|
|
42
|
+
continue;
|
|
43
|
+
const dvl = item;
|
|
44
|
+
if (typeof dvl.paramId !== 'string')
|
|
45
|
+
continue;
|
|
46
|
+
byId.set(dvl.paramId, { id: dvl.paramId, targetInputId: dvl.config?.targetInputId });
|
|
47
|
+
}
|
|
48
|
+
for (const output of schema.outputs ?? []) {
|
|
49
|
+
if (output.type !== 'dynamicValueList')
|
|
50
|
+
continue;
|
|
51
|
+
byId.set(output.id, { id: output.id, targetInputId: output.targetInputId });
|
|
52
|
+
}
|
|
53
|
+
return [...byId.values()];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build a map of `inputId -> computed options` from the solved output values.
|
|
57
|
+
*
|
|
58
|
+
* Reads each dynamicValueList source's `{ targetInputId, options }` payload from `values` and routes
|
|
59
|
+
* the options to the targeted input. The payload's own `targetInputId` wins; the schema-side
|
|
60
|
+
* `targetInputId` is the fallback when the payload omits it.
|
|
61
|
+
*/
|
|
62
|
+
export function buildDynamicValueListOptions(schema, values) {
|
|
63
|
+
const result = {};
|
|
64
|
+
for (const source of collectDynamicValueListSources(schema)) {
|
|
65
|
+
const payload = coercePayload(values[source.id]);
|
|
66
|
+
if (!payload)
|
|
67
|
+
continue;
|
|
68
|
+
const targetInputId = payload.targetInputId ?? source.targetInputId;
|
|
69
|
+
if (!targetInputId)
|
|
70
|
+
continue;
|
|
71
|
+
if (payload.options && typeof payload.options === 'object') {
|
|
72
|
+
result[targetInputId] = payload.options;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.0",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"svelte": "^5",
|
|
40
40
|
"tailwind-variants": "^3.2.2",
|
|
41
41
|
"three": "^0.184.0",
|
|
42
|
-
"@selvajs/schemas": "^4.
|
|
42
|
+
"@selvajs/schemas": "^4.5.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependenciesMeta": {
|
|
45
45
|
"three": {
|
|
@@ -63,15 +63,16 @@
|
|
|
63
63
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
64
64
|
"@types/three": "^0.184.0",
|
|
65
65
|
"bits-ui": "^2.18.0",
|
|
66
|
+
"rimraf": "^6.0.1",
|
|
66
67
|
"svelte": "5.55.5",
|
|
67
68
|
"tailwind-variants": "^3.2.2",
|
|
68
69
|
"vitest": "^3.2.4",
|
|
69
|
-
"@selvajs/
|
|
70
|
-
"@selvajs/
|
|
70
|
+
"@selvajs/config": "0.0.0",
|
|
71
|
+
"@selvajs/schemas": "4.5.0"
|
|
71
72
|
},
|
|
72
73
|
"scripts": {
|
|
73
74
|
"dev": "vite dev",
|
|
74
|
-
"build": "
|
|
75
|
+
"build": "rimraf dist && pnpm prepack",
|
|
75
76
|
"build:fast": "svelte-kit sync && svelte-package",
|
|
76
77
|
"build:watch": "svelte-kit sync && svelte-package --watch",
|
|
77
78
|
"preview": "vite preview",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
oncalculate?: () => void;
|
|
26
26
|
values: Record<string, unknown>;
|
|
27
27
|
onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
|
|
28
|
-
onLoadValues?: () => void | Promise<void>;
|
|
28
|
+
onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
|
|
29
29
|
panelActions?: ActionButton[];
|
|
30
30
|
showSaveButton?: boolean;
|
|
31
31
|
showLoadButton?: boolean;
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
|
|
100
100
|
async function handleLoadValues(loadedValues: Record<string, unknown>) {
|
|
101
101
|
Object.assign(values, loadedValues);
|
|
102
|
-
await onLoadValues?.();
|
|
102
|
+
await onLoadValues?.(loadedValues);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
let _touchStartY = 0;
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
)}
|
|
126
126
|
<div class="panel-content-wrapper">
|
|
127
127
|
{#if schema.layout.type === 'tabbed'}
|
|
128
|
-
<TabLayout {schema}
|
|
128
|
+
<TabLayout {schema} {values} {onValueChange} {panelFilter} {requestedTabId} />
|
|
129
129
|
{/if}
|
|
130
130
|
{#if showParameterStateManager || (!isMobile && showCalculateButton && schema.instanceSolve === false)}
|
|
131
131
|
<div class="panel-footer px-3">
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
InputLayoutItem,
|
|
4
|
+
FileInputWidgetConfig,
|
|
5
|
+
DynamicValueListWidgetConfig,
|
|
6
|
+
DropdownWidgetConfig,
|
|
7
|
+
SupportedTypes
|
|
8
|
+
} from '@selvajs/schemas';
|
|
3
9
|
import {
|
|
4
10
|
isNumberWidget,
|
|
5
11
|
isTextWidget,
|
|
6
12
|
isDropdownWidget,
|
|
13
|
+
isDynamicValueListWidget,
|
|
7
14
|
isCheckboxWidget,
|
|
8
15
|
isFileWidget,
|
|
9
16
|
isColorWidget
|
|
@@ -28,6 +35,8 @@
|
|
|
28
35
|
displayName?: string;
|
|
29
36
|
onChange: (paramId: string, value: SupportedTypes) => void;
|
|
30
37
|
disabled?: boolean;
|
|
38
|
+
/** Runtime-computed options for a dynamic value list input (name -> value). */
|
|
39
|
+
dynamicOptions?: Record<string, string>;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
let {
|
|
@@ -35,7 +44,8 @@
|
|
|
35
44
|
value = $bindable(undefined),
|
|
36
45
|
displayName,
|
|
37
46
|
onChange,
|
|
38
|
-
disabled = false
|
|
47
|
+
disabled = false,
|
|
48
|
+
dynamicOptions
|
|
39
49
|
}: Props = $props();
|
|
40
50
|
|
|
41
51
|
const inputId = $derived(`input-${item.paramId}`);
|
|
@@ -68,6 +78,50 @@
|
|
|
68
78
|
|
|
69
79
|
const showRangeInLabel = $derived(isNumberWidget(item) && numberRangeHint !== null);
|
|
70
80
|
|
|
81
|
+
// Dynamic value list: computed options (from the last solve) take precedence over the
|
|
82
|
+
// author's seed list. Empty until the first solve produces options, unless a default is set.
|
|
83
|
+
const dynamicListConfig = $derived(
|
|
84
|
+
isDynamicValueListWidget(item)
|
|
85
|
+
? (item.config as DynamicValueListWidgetConfig | undefined)
|
|
86
|
+
: undefined
|
|
87
|
+
);
|
|
88
|
+
const dynamicListOptions = $derived<Record<string, string>>(
|
|
89
|
+
dynamicOptions && Object.keys(dynamicOptions).length > 0
|
|
90
|
+
? dynamicOptions
|
|
91
|
+
: (Object.fromEntries(
|
|
92
|
+
Object.entries(dynamicListConfig?.defaultOptions ?? {}).filter(
|
|
93
|
+
(entry): entry is [string, string] => entry[1] !== undefined
|
|
94
|
+
)
|
|
95
|
+
) as Record<string, string>)
|
|
96
|
+
);
|
|
97
|
+
const dynamicListHasOptions = $derived(Object.keys(dynamicListOptions).length > 0);
|
|
98
|
+
// As a dropdown config so DropdownInput/ChecklistInput can consume it unchanged.
|
|
99
|
+
const dynamicListAsDropdownConfig = $derived<DropdownWidgetConfig>({
|
|
100
|
+
options: dynamicListOptions,
|
|
101
|
+
displayAs: dynamicListConfig?.displayAs ?? 'dropdown'
|
|
102
|
+
});
|
|
103
|
+
const hideDynamicListWhenEmpty = $derived(
|
|
104
|
+
isDynamicValueListWidget(item) &&
|
|
105
|
+
!dynamicListHasOptions &&
|
|
106
|
+
(dynamicListConfig?.emptyBehavior ?? 'hide') === 'hide'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// When a dynamic value list recomputes, a previously-selected value may no longer be an
|
|
110
|
+
// available option. Prune the stale selection so the control shows a valid option (or empty)
|
|
111
|
+
// instead of rendering the orphaned raw value as its own label.
|
|
112
|
+
$effect(() => {
|
|
113
|
+
if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
|
|
114
|
+
const validValues = new Set(Object.values(dynamicListOptions));
|
|
115
|
+
// Route through onChange (not commit) — value is a one-way prop here, so writing it
|
|
116
|
+
// directly from an effect trips Svelte's binding-ownership check.
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
|
|
119
|
+
if (pruned.length !== value.length) onChange(item.paramId, pruned);
|
|
120
|
+
} else if (typeof value === 'string' && value && !validValues.has(value)) {
|
|
121
|
+
onChange(item.paramId, '');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
71
125
|
function commit(newValue: SupportedTypes) {
|
|
72
126
|
value = newValue;
|
|
73
127
|
onChange(item.paramId, newValue);
|
|
@@ -83,7 +137,7 @@
|
|
|
83
137
|
value
|
|
84
138
|
})}
|
|
85
139
|
{/if}
|
|
86
|
-
{:else}
|
|
140
|
+
{:else if !hideDynamicListWhenEmpty}
|
|
87
141
|
<Field.Field>
|
|
88
142
|
<Field.Label for={inputId} class="gap-2 flex items-center">
|
|
89
143
|
{label}
|
|
@@ -152,6 +206,31 @@
|
|
|
152
206
|
{disabled}
|
|
153
207
|
/>
|
|
154
208
|
{/if}
|
|
209
|
+
{:else if isDynamicValueListWidget(item)}
|
|
210
|
+
{#if dynamicListHasOptions}
|
|
211
|
+
{#if dynamicListAsDropdownConfig.displayAs === 'checklist'}
|
|
212
|
+
<ChecklistInput
|
|
213
|
+
{inputId}
|
|
214
|
+
value={Array.isArray(value)
|
|
215
|
+
? (value as string[])
|
|
216
|
+
: typeof value === 'string' && value
|
|
217
|
+
? [value]
|
|
218
|
+
: []}
|
|
219
|
+
config={dynamicListAsDropdownConfig}
|
|
220
|
+
onChange={commit}
|
|
221
|
+
{disabled}
|
|
222
|
+
/>
|
|
223
|
+
{:else}
|
|
224
|
+
<DropdownInput
|
|
225
|
+
value={typeof value === 'string' ? value : ''}
|
|
226
|
+
config={dynamicListAsDropdownConfig}
|
|
227
|
+
onChange={commit}
|
|
228
|
+
{disabled}
|
|
229
|
+
/>
|
|
230
|
+
{/if}
|
|
231
|
+
{:else}
|
|
232
|
+
<p class="text-sm text-muted-foreground">No options available yet.</p>
|
|
233
|
+
{/if}
|
|
155
234
|
{:else if isFileWidget(item)}
|
|
156
235
|
{@const config = item.config as FileInputWidgetConfig}
|
|
157
236
|
<FileInput
|
|
@@ -288,49 +288,52 @@
|
|
|
288
288
|
{/if}
|
|
289
289
|
{/snippet}
|
|
290
290
|
|
|
291
|
-
|
|
292
|
-
|
|
291
|
+
<!-- Dynamic value list outputs are routing sinks (their options feed an input), not displayed. -->
|
|
292
|
+
{#if item.widgetType !== 'dynamicValueList'}
|
|
293
|
+
<div class="gap-2 flex flex-col">
|
|
294
|
+
{@render fieldHeader()}
|
|
293
295
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
</div>
|
|
296
|
+
{#if item.widgetType === 'chart'}
|
|
297
|
+
<ChartOutput
|
|
298
|
+
{item}
|
|
299
|
+
value={typeof value === 'string' ? value : value != null ? JSON.stringify(value) : ''}
|
|
300
|
+
/>
|
|
301
|
+
{:else if item.widgetType === 'image'}
|
|
302
|
+
<ImageOutput {item} {value} />
|
|
303
|
+
{:else if item.widgetType === 'file'}
|
|
304
|
+
{@render fileDisplay()}
|
|
305
|
+
{:else if item.widgetType === 'number'}
|
|
306
|
+
<div class="{boxClass} flex items-center bg-muted/50 wrap-break-word">
|
|
307
|
+
{#if hasValue}
|
|
308
|
+
<span class="font-bold text-primary">{formattedValue}</span>
|
|
309
|
+
{:else}
|
|
310
|
+
{@render placeholder()}
|
|
311
|
+
{/if}
|
|
312
|
+
</div>
|
|
313
|
+
{:else if item.widgetType === 'text'}
|
|
314
|
+
<div class="group relative">
|
|
315
|
+
{#if isObjectValue}
|
|
316
|
+
<pre
|
|
317
|
+
class="{boxClass} overflow-wrap-anywhere max-h-96 overflow-auto bg-muted/10 text-foreground">{formattedValue}</pre>
|
|
318
|
+
{:else}
|
|
319
|
+
<div
|
|
320
|
+
class="{boxClass} overflow-wrap-anywhere bg-muted/10 wrap-break-word whitespace-pre-wrap text-foreground"
|
|
321
|
+
>
|
|
322
|
+
{#if hasValue}{value}{:else}{@render placeholder()}{/if}
|
|
323
|
+
</div>
|
|
324
|
+
{/if}
|
|
325
|
+
{#if hasValue}
|
|
326
|
+
<Button
|
|
327
|
+
onclick={copyToClipboard}
|
|
328
|
+
class="right-2 top-2 absolute transition-opacity {copied
|
|
329
|
+
? 'opacity-100'
|
|
330
|
+
: 'opacity-0 group-hover:opacity-100'}"
|
|
331
|
+
size="sm"
|
|
332
|
+
>
|
|
333
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
334
|
+
</Button>
|
|
335
|
+
{/if}
|
|
336
|
+
</div>
|
|
337
|
+
{/if}
|
|
338
|
+
</div>
|
|
339
|
+
{/if}
|
|
@@ -23,10 +23,20 @@
|
|
|
23
23
|
onValueChange: (paramId: string, value: SupportedTypes) => void;
|
|
24
24
|
inputs: SchemaInput[];
|
|
25
25
|
outputs: DiscoveredOutput[];
|
|
26
|
+
/** Computed value list options keyed by the target dynamic-value-list input id. */
|
|
27
|
+
dynamicOptions?: Record<string, Record<string, string>>;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
let {
|
|
29
|
-
|
|
30
|
+
let {
|
|
31
|
+
tab,
|
|
32
|
+
values,
|
|
33
|
+
collapsedGroups,
|
|
34
|
+
onToggleGroup,
|
|
35
|
+
onValueChange,
|
|
36
|
+
inputs,
|
|
37
|
+
outputs,
|
|
38
|
+
dynamicOptions = {}
|
|
39
|
+
}: Props = $props();
|
|
30
40
|
|
|
31
41
|
function getInputById(paramId: string): SchemaInput | undefined {
|
|
32
42
|
return inputs.find((i) => i.id === paramId);
|
|
@@ -49,6 +59,7 @@
|
|
|
49
59
|
displayName={layoutItem.displayName}
|
|
50
60
|
onChange={onValueChange}
|
|
51
61
|
disabled={visibility.disabled}
|
|
62
|
+
dynamicOptions={dynamicOptions[input.id]}
|
|
52
63
|
/>
|
|
53
64
|
{/if}
|
|
54
65
|
{/snippet}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import TabBar from './TabBar.svelte';
|
|
6
6
|
import TabContent from './TabContent.svelte';
|
|
7
7
|
import { buildVisibilityMap, itemKey } from '$lib/schema/visibility-rules';
|
|
8
|
+
import { buildDynamicValueListOptions } from '$lib/schema/dynamic-value-list';
|
|
8
9
|
|
|
9
10
|
interface Props {
|
|
10
11
|
schema: UISchema;
|
|
@@ -16,13 +17,7 @@
|
|
|
16
17
|
requestedTabId?: string | null;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
let {
|
|
20
|
-
schema,
|
|
21
|
-
values = $bindable(),
|
|
22
|
-
onValueChange,
|
|
23
|
-
panelFilter,
|
|
24
|
-
requestedTabId = null
|
|
25
|
-
}: Props = $props();
|
|
20
|
+
let { schema, values, onValueChange, panelFilter, requestedTabId = null }: Props = $props();
|
|
26
21
|
|
|
27
22
|
let activeTabId = $state('');
|
|
28
23
|
let collapsedGroups = $state<Record<string, boolean>>({});
|
|
@@ -38,6 +33,9 @@
|
|
|
38
33
|
|
|
39
34
|
const showTabBar = $derived(visibleTabs.length > 1);
|
|
40
35
|
|
|
36
|
+
// Computed value list options keyed by the target input id, derived from solved outputs.
|
|
37
|
+
const dynamicOptions = $derived(buildDynamicValueListOptions(schema, values));
|
|
38
|
+
|
|
41
39
|
// Tab selection
|
|
42
40
|
$effect(() => {
|
|
43
41
|
if (requestedTabId && visibleTabs.some((t) => t.id === requestedTabId)) {
|
|
@@ -104,6 +102,7 @@
|
|
|
104
102
|
{onValueChange}
|
|
105
103
|
inputs={schema.inputs}
|
|
106
104
|
outputs={schema.outputs}
|
|
105
|
+
{dynamicOptions}
|
|
107
106
|
/>
|
|
108
107
|
{/each}
|
|
109
108
|
</Tabs.Root>
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
</script>
|
|
29
29
|
|
|
30
30
|
<div class="divide-y divide-border/60 overflow-hidden rounded-md border border-input">
|
|
31
|
-
{#each Object.entries(options) as [name, expr] (
|
|
31
|
+
{#each Object.entries(options) as [name, expr] (name)}
|
|
32
32
|
{@const optionValue = expr ?? name}
|
|
33
33
|
{@const optionId = `${inputId}-${optionValue}`}
|
|
34
34
|
{@const isSelected = selected.has(optionValue)}
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
{currentLabel || 'Select an option...'}
|
|
34
34
|
</Select.Trigger>
|
|
35
35
|
<Select.Content>
|
|
36
|
-
{#each Object.entries(options) as [name, expr] (
|
|
36
|
+
{#each Object.entries(options) as [name, expr] (name)}
|
|
37
37
|
<Select.Item value={expr ?? name} label={name} />
|
|
38
38
|
{/each}
|
|
39
39
|
</Select.Content>
|
|
@@ -201,6 +201,17 @@
|
|
|
201
201
|
return;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
// Guard the upload size client-side. The file is base64-embedded into the
|
|
205
|
+
// compute request body, so an oversize file would otherwise be rejected
|
|
206
|
+
// server-side with an opaque 413 (see COMPUTE_REQUEST_MAX_BYTES). The URL
|
|
207
|
+
// import path has the same check; keep the two in sync.
|
|
208
|
+
if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
|
|
209
|
+
alert(
|
|
210
|
+
`File too large: ${(file.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB).`
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
204
215
|
uploadedFileName = file.name;
|
|
205
216
|
|
|
206
217
|
const reader = new FileReader();
|
package/src/lib/index.ts
CHANGED
|
@@ -17,9 +17,22 @@ export { default as Viewer } from './components/viewer/Viewer.svelte';
|
|
|
17
17
|
|
|
18
18
|
// Utilities
|
|
19
19
|
export * from './schema/defaults';
|
|
20
|
+
export * from './schema/dynamic-value-list';
|
|
20
21
|
export * from './schema/traversal';
|
|
21
22
|
export * from './compute/solving.svelte';
|
|
22
23
|
|
|
24
|
+
// Solve Session seam (transport-agnostic value/lifecycle state machine + its driver
|
|
25
|
+
// interface). Exported so transports outside this package — e.g. plugin-ui's WebSocket
|
|
26
|
+
// driver — can satisfy SolveDriver and drive a session. See CONTEXT.md.
|
|
27
|
+
export {
|
|
28
|
+
createSolveSession,
|
|
29
|
+
createRequestResponseDriver,
|
|
30
|
+
type SolveSession,
|
|
31
|
+
type SolveSessionArgs,
|
|
32
|
+
type SolveDriver,
|
|
33
|
+
type SolveReporter
|
|
34
|
+
} from './compute/createSolveSession.svelte';
|
|
35
|
+
|
|
23
36
|
// External-input transit storage (used by routes that wire pre-step producers)
|
|
24
37
|
export * from './external/storage';
|
|
25
38
|
|
|
@@ -34,5 +47,5 @@ export { randomId } from './utils/randomId';
|
|
|
34
47
|
|
|
35
48
|
// UI-specific runtime types (not from schema)
|
|
36
49
|
export type { ActionButton } from './types/actionButton';
|
|
37
|
-
export type { SolveFn } from './types/solveFn';
|
|
50
|
+
export type { SolveFn, SolveResult } from './types/solveFn';
|
|
38
51
|
export { DEFAULT_PRESET_LABELS, type PresetLabels } from './types/presetLabels';
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import type { UISchema } from '@selvajs/schemas';
|
|
5
|
+
import { buildDynamicValueListOptions } from './dynamic-value-list';
|
|
6
|
+
|
|
7
|
+
// The collector keys `values` by the ContextBake GUID. A dynamicValueList output can live in
|
|
8
|
+
// schema.outputs[] OR only in the layout (a routing sink). These pin that BOTH are honoured —
|
|
9
|
+
// the layout-only case is the bug where the C# collector sent the payload but the UI threw it away.
|
|
10
|
+
|
|
11
|
+
const BAKE = 'bake-guid';
|
|
12
|
+
const TARGET = 'target-input-guid';
|
|
13
|
+
|
|
14
|
+
function schemaWith(opts: { outputs?: UISchema['outputs']; layoutItems?: unknown[] }): UISchema {
|
|
15
|
+
return {
|
|
16
|
+
outputs: opts.outputs ?? [],
|
|
17
|
+
layout: {
|
|
18
|
+
type: 'tabbed',
|
|
19
|
+
tabs: [
|
|
20
|
+
{ id: 't1', groups: [{ id: 'g1', label: 'g1', order: 0, items: opts.layoutItems ?? [] }] }
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
} as unknown as UISchema;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const layoutItem = (paramId: string, targetInputId: string) =>
|
|
27
|
+
({
|
|
28
|
+
id: 'li1',
|
|
29
|
+
type: 'output',
|
|
30
|
+
widgetType: 'dynamicValueList',
|
|
31
|
+
paramId,
|
|
32
|
+
config: { targetInputId }
|
|
33
|
+
}) as unknown;
|
|
34
|
+
|
|
35
|
+
const payload = (targetInputId: string | null, options: Record<string, string>) => ({
|
|
36
|
+
targetInputId,
|
|
37
|
+
options
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('buildDynamicValueListOptions', () => {
|
|
41
|
+
it('routes options from a schema.outputs[] source', () => {
|
|
42
|
+
const schema = schemaWith({
|
|
43
|
+
outputs: [{ id: BAKE, type: 'dynamicValueList', targetInputId: TARGET }] as never
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const result = buildDynamicValueListOptions(schema, {
|
|
47
|
+
[BAKE]: payload(TARGET, { A: '1', B: '2' })
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result[TARGET]).toEqual({ A: '1', B: '2' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('routes options from a LAYOUT-only source (the dropped-data bug)', () => {
|
|
54
|
+
const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
|
|
55
|
+
|
|
56
|
+
const result = buildDynamicValueListOptions(schema, {
|
|
57
|
+
[BAKE]: payload(TARGET, { Sphere: '0', Box: '1' })
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(result[TARGET]).toEqual({ Sphere: '0', Box: '1' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("prefers the payload's targetInputId over the schema fallback", () => {
|
|
64
|
+
const schema = schemaWith({ layoutItems: [layoutItem(BAKE, 'stale-target')] });
|
|
65
|
+
|
|
66
|
+
const result = buildDynamicValueListOptions(schema, {
|
|
67
|
+
[BAKE]: payload('live-target', { A: '1' })
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result['live-target']).toEqual({ A: '1' });
|
|
71
|
+
expect(result['stale-target']).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('falls back to the schema targetInputId when the payload omits it', () => {
|
|
75
|
+
const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
|
|
76
|
+
|
|
77
|
+
const result = buildDynamicValueListOptions(schema, {
|
|
78
|
+
[BAKE]: payload(null, { A: '1' })
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result[TARGET]).toEqual({ A: '1' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('parses a JSON-string payload (Rhino.Compute path)', () => {
|
|
85
|
+
const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
|
|
86
|
+
|
|
87
|
+
const result = buildDynamicValueListOptions(schema, {
|
|
88
|
+
[BAKE]: JSON.stringify(payload(TARGET, { A: '1' }))
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result[TARGET]).toEqual({ A: '1' });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('dedupes outputs[] over layout for the same id', () => {
|
|
95
|
+
const schema = schemaWith({
|
|
96
|
+
outputs: [{ id: BAKE, type: 'dynamicValueList', targetInputId: 'from-outputs' }] as never,
|
|
97
|
+
layoutItems: [layoutItem(BAKE, 'from-layout')]
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Payload omits targetInputId -> the outputs[] fallback wins (it's set last in the dedupe map).
|
|
101
|
+
const result = buildDynamicValueListOptions(schema, {
|
|
102
|
+
[BAKE]: payload(null, { A: '1' })
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result['from-outputs']).toEqual({ A: '1' });
|
|
106
|
+
expect(result['from-layout']).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('ignores values with no matching source', () => {
|
|
110
|
+
const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
|
|
111
|
+
|
|
112
|
+
const result = buildDynamicValueListOptions(schema, {
|
|
113
|
+
'unrelated-id': payload(TARGET, { A: '1' })
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(Object.keys(result)).toHaveLength(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// The SAME json file the C# DynamicValueListPayload test loads. If C# and TS stop agreeing on
|
|
120
|
+
// this shape, one side's CI goes red — that's the cross-stack drift guard.
|
|
121
|
+
it('routes the shared cross-stack golden fixture', () => {
|
|
122
|
+
const fixturePath = fileURLToPath(
|
|
123
|
+
new URL('../../../../schemas/fixtures/dynamic-value-list-payload.json', import.meta.url)
|
|
124
|
+
);
|
|
125
|
+
const fixture = JSON.parse(readFileSync(fixturePath, 'utf-8'));
|
|
126
|
+
const schema = schemaWith({ layoutItems: [layoutItem(BAKE, fixture.targetInputId)] });
|
|
127
|
+
|
|
128
|
+
const result = buildDynamicValueListOptions(schema, { [BAKE]: fixture });
|
|
129
|
+
|
|
130
|
+
expect(result[fixture.targetInputId]).toEqual(fixture.options);
|
|
131
|
+
expect(result[fixture.targetInputId]).toEqual({ Sphere: '0', Box: '1', Cone: '2' });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { UISchema, OutputDynamicValueListLayoutItem } from '@selvajs/schemas';
|
|
2
|
+
import { getLayoutItems } from '@selvajs/schemas';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The runtime payload a dynamic value list output produces, keyed by the output's id in `values`.
|
|
6
|
+
* Routed back into the dynamic value list input identified by `targetInputId`.
|
|
7
|
+
*/
|
|
8
|
+
export interface DynamicValueListPayload {
|
|
9
|
+
targetInputId?: string | null;
|
|
10
|
+
options?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isDynamicValueListPayload(value: unknown): value is DynamicValueListPayload {
|
|
14
|
+
return (
|
|
15
|
+
typeof value === 'object' && value !== null && ('targetInputId' in value || 'options' in value)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a raw output value into a payload object.
|
|
21
|
+
*
|
|
22
|
+
* The local/WebSocket path delivers a real object; the Rhino.Compute path delivers the
|
|
23
|
+
* component's JSON output as a string (possibly double-encoded by the compute layer), so try
|
|
24
|
+
* to parse strings before giving up.
|
|
25
|
+
*/
|
|
26
|
+
function coercePayload(value: unknown): DynamicValueListPayload | null {
|
|
27
|
+
if (isDynamicValueListPayload(value)) return value;
|
|
28
|
+
|
|
29
|
+
let candidate = value;
|
|
30
|
+
// Unwrap up to two layers of JSON string encoding (compute may quote the string output).
|
|
31
|
+
for (let i = 0; i < 2 && typeof candidate === 'string'; i++) {
|
|
32
|
+
try {
|
|
33
|
+
candidate = JSON.parse(candidate);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (isDynamicValueListPayload(candidate)) return candidate;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One DynVL routing source: the id keying `values`, plus the schema-side target fallback. */
|
|
44
|
+
interface DynamicValueListSource {
|
|
45
|
+
id: string;
|
|
46
|
+
targetInputId?: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every dynamicValueList output reference in the schema.
|
|
51
|
+
*
|
|
52
|
+
* Canonical location is `schema.outputs[]` — the plugin's SchemaSynchronizer enforces that every
|
|
53
|
+
* dynamicValueList layout item is mirrored there (see CanonicalizeDynamicValueListOutputs). We ALSO
|
|
54
|
+
* scan the layout purely as back-compat defense for schemas persisted by an older plugin that lacked
|
|
55
|
+
* that invariant; for current schemas the layout pass finds nothing new.
|
|
56
|
+
* Deduped by id, outputs[] winning so the canonical record's targetInputId takes precedence.
|
|
57
|
+
*/
|
|
58
|
+
function collectDynamicValueListSources(schema: UISchema): DynamicValueListSource[] {
|
|
59
|
+
const byId = new Map<string, DynamicValueListSource>();
|
|
60
|
+
|
|
61
|
+
for (const item of getLayoutItems(schema)) {
|
|
62
|
+
if (item.type !== 'output' || item.widgetType !== 'dynamicValueList') continue;
|
|
63
|
+
const dvl = item as OutputDynamicValueListLayoutItem;
|
|
64
|
+
if (typeof dvl.paramId !== 'string') continue;
|
|
65
|
+
byId.set(dvl.paramId, { id: dvl.paramId, targetInputId: dvl.config?.targetInputId });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const output of schema.outputs ?? []) {
|
|
69
|
+
if (output.type !== 'dynamicValueList') continue;
|
|
70
|
+
byId.set(output.id, { id: output.id, targetInputId: output.targetInputId });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [...byId.values()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build a map of `inputId -> computed options` from the solved output values.
|
|
78
|
+
*
|
|
79
|
+
* Reads each dynamicValueList source's `{ targetInputId, options }` payload from `values` and routes
|
|
80
|
+
* the options to the targeted input. The payload's own `targetInputId` wins; the schema-side
|
|
81
|
+
* `targetInputId` is the fallback when the payload omits it.
|
|
82
|
+
*/
|
|
83
|
+
export function buildDynamicValueListOptions(
|
|
84
|
+
schema: UISchema,
|
|
85
|
+
values: Record<string, unknown>
|
|
86
|
+
): Record<string, Record<string, string>> {
|
|
87
|
+
const result: Record<string, Record<string, string>> = {};
|
|
88
|
+
|
|
89
|
+
for (const source of collectDynamicValueListSources(schema)) {
|
|
90
|
+
const payload = coercePayload(values[source.id]);
|
|
91
|
+
if (!payload) continue;
|
|
92
|
+
|
|
93
|
+
const targetInputId = payload.targetInputId ?? source.targetInputId;
|
|
94
|
+
if (!targetInputId) continue;
|
|
95
|
+
|
|
96
|
+
if (payload.options && typeof payload.options === 'object') {
|
|
97
|
+
result[targetInputId] = payload.options;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result;
|
|
102
|
+
}
|