@selvajs/ui 4.2.0 → 4.4.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 +1 -1
- package/dist/components/compute/AppLayout.svelte.d.ts +1 -1
- package/dist/components/compute/ComputeApp.svelte +54 -133
- package/dist/components/compute/ComputeApp.svelte.d.ts +2 -9
- package/dist/components/compute/ComputeMessagesDialog.svelte +125 -0
- package/dist/components/compute/ComputeMessagesDialog.svelte.d.ts +10 -0
- package/dist/components/compute/ParameterPresetManager.svelte +25 -33
- package/dist/components/layout/PageFooter.svelte +22 -144
- package/dist/components/preview/Group.svelte +6 -3
- package/dist/components/preview/ImageOutput.svelte +1 -13
- package/dist/components/preview/InputControl.svelte +96 -72
- package/dist/components/preview/OutputDisplay.svelte +6 -6
- package/dist/components/preview/TabLayout.svelte +6 -5
- package/dist/components/preview/inputs/FileInput.svelte +10 -19
- package/dist/composables/useFooterItem.svelte.d.ts +10 -12
- package/dist/composables/useFooterItem.svelte.js +9 -16
- package/dist/compute/computeThrottle.svelte.js +5 -2
- package/dist/compute/createSolveSession.svelte.d.ts +56 -0
- package/dist/compute/createSolveSession.svelte.js +122 -0
- package/dist/compute/solve-session-core.d.ts +45 -0
- package/dist/compute/solve-session-core.js +69 -0
- package/dist/constants.d.ts +0 -2
- package/dist/constants.js +0 -2
- package/dist/contexts/clientSlotContext.svelte.d.ts +13 -0
- package/dist/contexts/clientSlotContext.svelte.js +8 -0
- package/dist/contexts/footerContext.svelte.d.ts +17 -5
- package/dist/contexts/footerContext.svelte.js +10 -2
- package/dist/external/storage.js +7 -23
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/schema/param-exporter.d.ts +25 -1
- package/dist/schema/param-exporter.js +30 -27
- package/dist/schema/traversal.d.ts +1 -0
- package/dist/schema/traversal.js +5 -0
- package/dist/schema/visibility-rules.d.ts +12 -0
- package/dist/schema/visibility-rules.js +20 -0
- package/dist/types/solveFn.d.ts +1 -2
- package/dist/utils/file-download.d.ts +5 -17
- package/dist/utils/file-download.js +12 -19
- package/dist/utils/randomId.js +3 -19
- package/package.json +10 -26
- package/src/lib/components/compute/AppLayout.svelte +1 -1
- package/src/lib/components/compute/ComputeApp.svelte +54 -133
- package/src/lib/components/compute/ComputeMessagesDialog.svelte +125 -0
- package/src/lib/components/compute/ParameterPresetManager.svelte +25 -33
- package/src/lib/components/layout/PageFooter.svelte +22 -144
- package/src/lib/components/preview/Group.svelte +6 -3
- package/src/lib/components/preview/ImageOutput.svelte +1 -13
- package/src/lib/components/preview/InputControl.svelte +96 -72
- package/src/lib/components/preview/OutputDisplay.svelte +6 -6
- package/src/lib/components/preview/TabLayout.svelte +6 -5
- package/src/lib/components/preview/inputs/FileInput.svelte +10 -19
- package/src/lib/composables/useFooterItem.svelte.ts +25 -25
- package/src/lib/compute/computeThrottle.svelte.ts +5 -2
- package/src/lib/compute/computeThrottle.test.ts +106 -0
- package/src/lib/compute/createSolveSession.svelte.ts +188 -0
- package/src/lib/compute/solve-session-core.test.ts +153 -0
- package/src/lib/compute/solve-session-core.ts +102 -0
- package/src/lib/constants.ts +0 -2
- package/src/lib/contexts/clientSlotContext.svelte.ts +33 -0
- package/src/lib/contexts/footerContext.svelte.ts +29 -14
- package/src/lib/external/storage.test.ts +99 -0
- package/src/lib/external/storage.ts +6 -22
- package/src/lib/index.ts +2 -0
- package/src/lib/schema/param-exporter.test.ts +136 -0
- package/src/lib/schema/param-exporter.ts +44 -30
- package/src/lib/schema/traversal.test.ts +92 -0
- package/src/lib/schema/traversal.ts +5 -0
- package/src/lib/schema/visibility-rules.test.ts +173 -0
- package/src/lib/schema/visibility-rules.ts +25 -0
- package/src/lib/types/solveFn.ts +1 -1
- package/src/lib/utils/file-download.ts +13 -20
- package/src/lib/utils/randomId.ts +3 -19
- package/dist/components/compute/ComputeMessages.svelte +0 -169
- package/dist/components/compute/ComputeMessages.svelte.d.ts +0 -7
- package/dist/components/compute/index.d.ts +0 -5
- package/dist/components/compute/index.js +0 -5
- package/dist/components/preview/index.d.ts +0 -3
- package/dist/components/preview/index.js +0 -4
- package/dist/components/viewer/index.d.ts +0 -3
- package/dist/components/viewer/index.js +0 -3
- package/src/lib/components/compute/ComputeMessages.svelte +0 -169
- package/src/lib/components/compute/index.ts +0 -5
- package/src/lib/components/preview/index.ts +0 -4
- package/src/lib/components/viewer/index.ts +0 -3
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Pure, framework-free transition logic for a Solve Session (see CONTEXT.md).
|
|
2
|
+
//
|
|
3
|
+
// This module holds the value/lifecycle state machine with no Svelte runes and no
|
|
4
|
+
// transport: how values are seeded (including client-sourced hydration), when a value
|
|
5
|
+
// change should dispatch a solve vs. defer it, and what a reported solve result does to
|
|
6
|
+
// the flags. The reactive wrapper in createSolveSession.svelte.ts is a thin shell over
|
|
7
|
+
// these functions; everything testable lives here.
|
|
8
|
+
|
|
9
|
+
import type { UISchema } from '@selvajs/schemas';
|
|
10
|
+
import { getDefaultValue } from '../schema/defaults';
|
|
11
|
+
import { getExternalInputs, type ExternalValueRef } from '../external/storage';
|
|
12
|
+
import type { SolveResult } from '../types/solveFn';
|
|
13
|
+
|
|
14
|
+
export interface SolveSessionState {
|
|
15
|
+
values: Record<string, unknown>;
|
|
16
|
+
error: string;
|
|
17
|
+
computeErrors: string[];
|
|
18
|
+
computeWarnings: string[];
|
|
19
|
+
meshes: unknown[];
|
|
20
|
+
/** Values changed since the last solve, in manual (instanceSolve === false) mode. */
|
|
21
|
+
pendingValues: Record<string, unknown>;
|
|
22
|
+
hasPendingChanges: boolean;
|
|
23
|
+
hasNeverSolved: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Reads a previously produced client-sourced value, or undefined if absent. */
|
|
27
|
+
export type ExternalReader = (ref: ExternalValueRef) => unknown | undefined;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Builds the initial `values` map for a schema. Non-client inputs get their declared
|
|
31
|
+
* default (falling back to the paramType default); client-sourced inputs are hydrated
|
|
32
|
+
* from `read` and left absent when no stored value exists so the missing-inputs panel
|
|
33
|
+
* can detect them. Outputs are seeded to null.
|
|
34
|
+
*/
|
|
35
|
+
export function buildInitialValues(
|
|
36
|
+
schema: UISchema,
|
|
37
|
+
scopeKey: string,
|
|
38
|
+
read: ExternalReader
|
|
39
|
+
): Record<string, unknown> {
|
|
40
|
+
const clientSet = new Set(getExternalInputs(schema).map((e) => e.paramId));
|
|
41
|
+
const values: Record<string, unknown> = {};
|
|
42
|
+
for (const input of schema.inputs) {
|
|
43
|
+
if (clientSet.has(input.id)) {
|
|
44
|
+
const stored = read({ scopeKey, inputId: input.id });
|
|
45
|
+
if (stored !== undefined) values[input.id] = stored;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
values[input.id] = input.default ?? getDefaultValue(input.paramType);
|
|
49
|
+
}
|
|
50
|
+
for (const output of schema.outputs) {
|
|
51
|
+
values[output.id] = null;
|
|
52
|
+
}
|
|
53
|
+
return values;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Initial lifecycle flags. Manual-solve schemas (instanceSolve === false) start dirty
|
|
58
|
+
* so the user must explicitly calculate; auto-solve schemas start clean.
|
|
59
|
+
*/
|
|
60
|
+
export function makeInitialFlags(instanceSolve: boolean | undefined): {
|
|
61
|
+
hasPendingChanges: boolean;
|
|
62
|
+
hasNeverSolved: boolean;
|
|
63
|
+
} {
|
|
64
|
+
const manual = instanceSolve === false;
|
|
65
|
+
return { hasPendingChanges: manual, hasNeverSolved: manual };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Records a single value change and decides whether it should dispatch a solve now.
|
|
70
|
+
* Auto-solve mode dispatches immediately; manual mode defers (records the pending value
|
|
71
|
+
* and raises the dirty flag) and never dispatches.
|
|
72
|
+
*/
|
|
73
|
+
export function applyValueChange(
|
|
74
|
+
state: SolveSessionState,
|
|
75
|
+
id: string,
|
|
76
|
+
value: unknown,
|
|
77
|
+
instanceSolve: boolean | undefined
|
|
78
|
+
): { state: SolveSessionState; shouldSolve: boolean } {
|
|
79
|
+
state.values[id] = value;
|
|
80
|
+
if (instanceSolve === false) {
|
|
81
|
+
state.pendingValues[id] = value;
|
|
82
|
+
state.hasPendingChanges = true;
|
|
83
|
+
return { state, shouldSolve: false };
|
|
84
|
+
}
|
|
85
|
+
return { state, shouldSolve: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Merges a reported solve result into the state and clears the post-solve lifecycle
|
|
90
|
+
* flags. Missing result arrays are treated as empty.
|
|
91
|
+
*/
|
|
92
|
+
export function applySolveResult(state: SolveSessionState, result: SolveResult): SolveSessionState {
|
|
93
|
+
state.error = '';
|
|
94
|
+
state.computeErrors = result.errors ?? [];
|
|
95
|
+
state.computeWarnings = result.warnings ?? [];
|
|
96
|
+
state.meshes = result.meshes ?? [];
|
|
97
|
+
Object.assign(state.values, result.outputs);
|
|
98
|
+
state.pendingValues = {};
|
|
99
|
+
state.hasPendingChanges = false;
|
|
100
|
+
state.hasNeverSolved = false;
|
|
101
|
+
return state;
|
|
102
|
+
}
|
package/src/lib/constants.ts
CHANGED
|
@@ -9,8 +9,6 @@ export const APP_DEFAULTS = {
|
|
|
9
9
|
TIMEOUTS: {
|
|
10
10
|
/** Duration to show solving state (ms) */
|
|
11
11
|
SOLVE_STATE_DURATION: 3500,
|
|
12
|
-
/** Parameter export callback delay (ms) */
|
|
13
|
-
PARAM_EXPORT_DELAY: 100,
|
|
14
12
|
/** Drawer open/close animation duration (ms) — matches the CSS transition. */
|
|
15
13
|
DRAWER_ANIMATION_MS: 350
|
|
16
14
|
},
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
// Carries the host app's slot renderer down to InputControl without threading a
|
|
5
|
+
// prop through every layout layer (ComputeApp → AppLayout → TabLayout →
|
|
6
|
+
// TabContent → Group → InputControl).
|
|
7
|
+
//
|
|
8
|
+
// An input with source.kind === 'client' and source.client.presentation === 'slot'
|
|
9
|
+
// reserves its cell but renders nothing itself. Instead Selva invokes this snippet
|
|
10
|
+
// so the host can render its own element (e.g. an "Edit JSON" button). Selva never
|
|
11
|
+
// interprets what the host renders; `slotLabel` is passed through untouched.
|
|
12
|
+
|
|
13
|
+
export interface ClientSlotArgs {
|
|
14
|
+
/** Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id). */
|
|
15
|
+
inputId: string;
|
|
16
|
+
displayName: string;
|
|
17
|
+
/** Author-set label from the schema, passed through untouched. May be undefined. */
|
|
18
|
+
slotLabel?: string;
|
|
19
|
+
/** The current value held for this input (e.g. the prefilled JSON), if any. */
|
|
20
|
+
value: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ClientSlot = Snippet<[ClientSlotArgs]>;
|
|
24
|
+
|
|
25
|
+
const CLIENT_SLOT_CONTEXT_KEY = Symbol('client-slot-context');
|
|
26
|
+
|
|
27
|
+
export function setClientSlot(slot: ClientSlot | undefined): void {
|
|
28
|
+
setContext(CLIENT_SLOT_CONTEXT_KEY, slot);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getClientSlot(): ClientSlot | undefined {
|
|
32
|
+
return getContext<ClientSlot | undefined>(CLIENT_SLOT_CONTEXT_KEY);
|
|
33
|
+
}
|
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
import { getContext, setContext } from 'svelte';
|
|
1
|
+
import { getContext, setContext, type Component } from 'svelte';
|
|
2
2
|
import { SvelteMap } from 'svelte/reactivity';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* A footer item registered by a descendant of the root layout. `component` + `position`
|
|
6
|
+
* are fixed at registration; the renderer re-invokes `getProps` reactively, so return
|
|
7
|
+
* reactive state from it (e.g. `() => ({ status: myState.status })`) to keep the footer
|
|
8
|
+
* in sync.
|
|
9
|
+
*
|
|
10
|
+
* Generic over the component's props `P` so registration is type-checked at the call
|
|
11
|
+
* site. The store holds a heterogeneous mix of items, so its map widens `P` to
|
|
12
|
+
* `Record<string, unknown>` at the boundary.
|
|
13
|
+
*/
|
|
14
|
+
export interface FooterItem<P extends Record<string, unknown> = Record<string, unknown>> {
|
|
5
15
|
id: string;
|
|
6
|
-
component:
|
|
7
|
-
/** Called on every render — return reactive state to keep footer in sync */
|
|
8
|
-
getProps: () =>
|
|
16
|
+
component: Component<P>;
|
|
17
|
+
/** Called on every render — return reactive state to keep footer in sync. */
|
|
18
|
+
getProps: () => P;
|
|
9
19
|
position: 'left' | 'right';
|
|
10
20
|
priority: number;
|
|
11
21
|
onClick?: () => void;
|
|
@@ -13,13 +23,10 @@ export interface FooterItem {
|
|
|
13
23
|
|
|
14
24
|
export interface FooterStore {
|
|
15
25
|
items: SvelteMap<string, FooterItem>;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
position?: 'left' | 'right',
|
|
21
|
-
priority?: number,
|
|
22
|
-
onClick?: () => void
|
|
26
|
+
/** Register (or replace) a footer item. `position`/`priority` default to 'left'/0. */
|
|
27
|
+
register<P extends Record<string, unknown>>(
|
|
28
|
+
item: Omit<FooterItem<P>, 'position' | 'priority'> &
|
|
29
|
+
Partial<Pick<FooterItem<P>, 'position' | 'priority'>>
|
|
23
30
|
): void;
|
|
24
31
|
unregister(id: string): void;
|
|
25
32
|
}
|
|
@@ -31,8 +38,16 @@ export function initializeFooterContext(): FooterStore {
|
|
|
31
38
|
|
|
32
39
|
const store: FooterStore = {
|
|
33
40
|
items,
|
|
34
|
-
register(
|
|
35
|
-
|
|
41
|
+
register(item) {
|
|
42
|
+
const { id, component, getProps, position = 'left', priority = 0, onClick } = item;
|
|
43
|
+
items.set(id, {
|
|
44
|
+
id,
|
|
45
|
+
component: component as Component<Record<string, unknown>>,
|
|
46
|
+
getProps: getProps as () => Record<string, unknown>,
|
|
47
|
+
position,
|
|
48
|
+
priority,
|
|
49
|
+
onClick
|
|
50
|
+
});
|
|
36
51
|
},
|
|
37
52
|
unregister(id) {
|
|
38
53
|
items.delete(id);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getExternalInputs,
|
|
4
|
+
writeExternalValue,
|
|
5
|
+
readExternalValue,
|
|
6
|
+
clearExternalValue
|
|
7
|
+
} from './storage';
|
|
8
|
+
import type { UISchema } from '@selvajs/schemas';
|
|
9
|
+
|
|
10
|
+
// getExternalInputs is the gate the whole client-input feature hangs on: it decides
|
|
11
|
+
// which inputs a producer route must fill. These pin the source.kind === 'client'
|
|
12
|
+
// filter and the displayName fallback. The read/write helpers are also covered with a
|
|
13
|
+
// sessionStorage stub, including the no-storage guard (SSR / node).
|
|
14
|
+
|
|
15
|
+
const input = (paramId: string, opts: { displayName?: string; source?: { kind: string } } = {}) =>
|
|
16
|
+
({ type: 'input', paramId, ...opts }) as never;
|
|
17
|
+
|
|
18
|
+
function schema(items: unknown[]): UISchema {
|
|
19
|
+
return {
|
|
20
|
+
layout: { type: 'flat', groups: [{ id: 'g', label: 'g', items }] }
|
|
21
|
+
} as unknown as UISchema;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('getExternalInputs', () => {
|
|
25
|
+
it('keeps only inputs with source.kind === client', () => {
|
|
26
|
+
const s = schema([
|
|
27
|
+
input('a', { source: { kind: 'client' } }),
|
|
28
|
+
input('b', { source: { kind: 'user' } }),
|
|
29
|
+
input('c') // no source
|
|
30
|
+
]);
|
|
31
|
+
expect(getExternalInputs(s).map((e) => e.paramId)).toEqual(['a']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('falls back to paramId when displayName is absent', () => {
|
|
35
|
+
const s = schema([input('a', { source: { kind: 'client' } })]);
|
|
36
|
+
expect(getExternalInputs(s)[0].displayName).toBe('a');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('uses displayName when present', () => {
|
|
40
|
+
const s = schema([input('a', { displayName: 'Width', source: { kind: 'client' } })]);
|
|
41
|
+
expect(getExternalInputs(s)[0].displayName).toBe('Width');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('read/write/clear with a sessionStorage stub', () => {
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.unstubAllGlobals();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function stubStorage() {
|
|
51
|
+
const store = new Map<string, string>();
|
|
52
|
+
vi.stubGlobal('sessionStorage', {
|
|
53
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
54
|
+
setItem: (k: string, v: string) => store.set(k, v),
|
|
55
|
+
removeItem: (k: string) => store.delete(k)
|
|
56
|
+
});
|
|
57
|
+
return store;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it('round-trips a value scoped by (scopeKey, inputId)', () => {
|
|
61
|
+
const store = stubStorage();
|
|
62
|
+
writeExternalValue({ scopeKey: 's1', inputId: 'p1', value: { x: 1 } });
|
|
63
|
+
expect(readExternalValue({ scopeKey: 's1', inputId: 'p1' })).toEqual({ x: 1 });
|
|
64
|
+
expect(store.has('external:s1:p1')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('scopes values so they do not bleed across scopeKey', () => {
|
|
68
|
+
stubStorage();
|
|
69
|
+
writeExternalValue({ scopeKey: 's1', inputId: 'p1', value: 'a' });
|
|
70
|
+
expect(readExternalValue({ scopeKey: 's2', inputId: 'p1' })).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('clear removes the value', () => {
|
|
74
|
+
stubStorage();
|
|
75
|
+
writeExternalValue({ scopeKey: 's1', inputId: 'p1', value: 'a' });
|
|
76
|
+
clearExternalValue({ scopeKey: 's1', inputId: 'p1' });
|
|
77
|
+
expect(readExternalValue({ scopeKey: 's1', inputId: 'p1' })).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns undefined for malformed JSON', () => {
|
|
81
|
+
const store = stubStorage();
|
|
82
|
+
store.set('external:s1:p1', '{not json');
|
|
83
|
+
expect(readExternalValue({ scopeKey: 's1', inputId: 'p1' })).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('no-storage guard (SSR / node)', () => {
|
|
88
|
+
it('read returns undefined and write/clear no-op when sessionStorage is absent', () => {
|
|
89
|
+
// node env: sessionStorage is undefined by default
|
|
90
|
+
expect(readExternalValue({ scopeKey: 's1', inputId: 'p1' })).toBeUndefined();
|
|
91
|
+
expect(() => writeExternalValue({ scopeKey: 's1', inputId: 'p1', value: 1 })).not.toThrow();
|
|
92
|
+
expect(() => clearExternalValue({ scopeKey: 's1', inputId: 'p1' })).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('ignores empty scopeKey or inputId', () => {
|
|
96
|
+
expect(readExternalValue({ scopeKey: '', inputId: 'p1' })).toBeUndefined();
|
|
97
|
+
expect(readExternalValue({ scopeKey: 's1', inputId: '' })).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
//
|
|
9
9
|
// inputId is the Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id).
|
|
10
10
|
|
|
11
|
-
import type { UISchema
|
|
11
|
+
import type { UISchema } from '@selvajs/schemas';
|
|
12
|
+
import { getInputItems } from '@selvajs/schemas';
|
|
12
13
|
|
|
13
14
|
const STORAGE_PREFIX = 'external';
|
|
14
15
|
|
|
@@ -53,28 +54,11 @@ export interface ExternalInput {
|
|
|
53
54
|
displayName: string;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function* walkLayoutItems(schema: UISchema): Generator<LayoutItem> {
|
|
57
|
-
const groups: GroupConfig[] =
|
|
58
|
-
schema.layout.type === 'tabbed'
|
|
59
|
-
? schema.layout.tabs.flatMap((t) => t.groups)
|
|
60
|
-
: schema.layout.groups;
|
|
61
|
-
for (const group of groups) {
|
|
62
|
-
for (const item of group.items) {
|
|
63
|
-
yield item;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
57
|
export function getExternalInputs(schema: UISchema): ExternalInput[] {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const source = (item as { source?: InputSource }).source;
|
|
73
|
-
if (source?.kind !== 'client') continue;
|
|
74
|
-
result.push({
|
|
58
|
+
return getInputItems(schema)
|
|
59
|
+
.filter((item) => item.source?.kind === 'client')
|
|
60
|
+
.map((item) => ({
|
|
75
61
|
paramId: item.paramId,
|
|
76
62
|
displayName: item.displayName ?? item.paramId
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
return result;
|
|
63
|
+
}));
|
|
80
64
|
}
|
package/src/lib/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { default as Viewer } from './components/viewer/Viewer.svelte';
|
|
|
17
17
|
|
|
18
18
|
// Utilities
|
|
19
19
|
export * from './schema/defaults';
|
|
20
|
+
export * from './schema/traversal';
|
|
20
21
|
export * from './compute/solving.svelte';
|
|
21
22
|
|
|
22
23
|
// External-input transit storage (used by routes that wire pre-step producers)
|
|
@@ -24,6 +25,7 @@ export * from './external/storage';
|
|
|
24
25
|
|
|
25
26
|
// Contexts & Composables
|
|
26
27
|
export * from './contexts/footerContext.svelte';
|
|
28
|
+
export * from './contexts/clientSlotContext.svelte';
|
|
27
29
|
export * from './composables/useFooterItem.svelte';
|
|
28
30
|
|
|
29
31
|
// Utils (cn function)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { validateSavedState, extractLoadableValues, loadPreset } from './param-exporter';
|
|
3
|
+
import type { UISchema, ParameterPreset } from '@selvajs/schemas';
|
|
4
|
+
|
|
5
|
+
// These tests cover the load path a user hits when restoring a preset against a
|
|
6
|
+
// schema that has drifted. The severity of each mismatch decides whether the
|
|
7
|
+
// preset can load at all (error blocks, warning allows), so those are the cases
|
|
8
|
+
// worth locking in — not the happy path.
|
|
9
|
+
|
|
10
|
+
function schema(over: Partial<UISchema> = {}): UISchema {
|
|
11
|
+
return {
|
|
12
|
+
id: 'schema-1',
|
|
13
|
+
documentId: 'doc-1',
|
|
14
|
+
inputs: [
|
|
15
|
+
{ id: 'a', nickname: 'A', paramType: 'number' },
|
|
16
|
+
{ id: 'b', nickname: 'B', paramType: 'number' }
|
|
17
|
+
],
|
|
18
|
+
...over
|
|
19
|
+
} as unknown as UISchema;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function preset(over: Partial<ParameterPreset> = {}): ParameterPreset {
|
|
23
|
+
return {
|
|
24
|
+
id: 'p1',
|
|
25
|
+
name: 'My Preset',
|
|
26
|
+
schemaId: 'schema-1',
|
|
27
|
+
documentId: 'doc-1',
|
|
28
|
+
parameters: [
|
|
29
|
+
{ paramId: 'a', nickname: 'A', value: 1 },
|
|
30
|
+
{ paramId: 'b', nickname: 'B', value: 2 }
|
|
31
|
+
],
|
|
32
|
+
...over
|
|
33
|
+
} as unknown as ParameterPreset;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('validateSavedState', () => {
|
|
37
|
+
it('document-ID mismatch is a blocking error', () => {
|
|
38
|
+
const result = validateSavedState(preset({ documentId: 'other-doc' }), schema());
|
|
39
|
+
expect(result.canLoad).toBe(false);
|
|
40
|
+
expect(result.issues.some((i) => i.severity === 'error' && i.paramId === '__document__')).toBe(
|
|
41
|
+
true
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('schema-ID change is a warning that still allows loading', () => {
|
|
46
|
+
const result = validateSavedState(preset({ schemaId: 'old-schema' }), schema());
|
|
47
|
+
expect(result.canLoad).toBe(true);
|
|
48
|
+
expect(result.isValid).toBe(false);
|
|
49
|
+
expect(result.issues.some((i) => i.severity === 'warning' && i.paramId === '__schema__')).toBe(
|
|
50
|
+
true
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('a parameter missing from the schema is a blocking error', () => {
|
|
55
|
+
const p = preset({
|
|
56
|
+
parameters: [
|
|
57
|
+
{ paramId: 'a', nickname: 'A', value: 1 },
|
|
58
|
+
{ paramId: 'gone', nickname: 'Gone', value: 9 }
|
|
59
|
+
] as ParameterPreset['parameters']
|
|
60
|
+
});
|
|
61
|
+
const result = validateSavedState(p, schema());
|
|
62
|
+
expect(result.canLoad).toBe(false);
|
|
63
|
+
expect(result.issues.some((i) => i.severity === 'error' && i.paramId === 'gone')).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('a renamed nickname is a warning, not a block', () => {
|
|
67
|
+
const renamed = schema({
|
|
68
|
+
inputs: [
|
|
69
|
+
{ id: 'a', nickname: 'A renamed', paramType: 'number' },
|
|
70
|
+
{ id: 'b', nickname: 'B', paramType: 'number' }
|
|
71
|
+
]
|
|
72
|
+
} as unknown as Partial<UISchema>);
|
|
73
|
+
const result = validateSavedState(preset(), renamed);
|
|
74
|
+
expect(result.canLoad).toBe(true);
|
|
75
|
+
expect(result.issues.some((i) => i.severity === 'warning' && i.paramId === 'a')).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('a fully matching preset is valid with no issues', () => {
|
|
79
|
+
const result = validateSavedState(preset(), schema());
|
|
80
|
+
expect(result).toEqual({ isValid: true, issues: [], canLoad: true });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('extractLoadableValues', () => {
|
|
85
|
+
it('drops params that no longer exist in the schema, keeps the rest', () => {
|
|
86
|
+
const p = preset({
|
|
87
|
+
parameters: [
|
|
88
|
+
{ paramId: 'a', nickname: 'A', value: 1 },
|
|
89
|
+
{ paramId: 'gone', nickname: 'Gone', value: 9 } // not in schema
|
|
90
|
+
] as ParameterPreset['parameters']
|
|
91
|
+
});
|
|
92
|
+
expect(extractLoadableValues(p, schema())).toEqual({ a: 1 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('keeps a warned-but-valid param (nickname drift) on load', () => {
|
|
96
|
+
const renamed = schema({
|
|
97
|
+
inputs: [
|
|
98
|
+
{ id: 'a', nickname: 'A renamed', paramType: 'number' },
|
|
99
|
+
{ id: 'b', nickname: 'B', paramType: 'number' }
|
|
100
|
+
]
|
|
101
|
+
} as unknown as Partial<UISchema>);
|
|
102
|
+
expect(extractLoadableValues(preset(), renamed)).toEqual({ a: 1, b: 2 });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('loadPreset', () => {
|
|
107
|
+
it('fuses validation and extraction: valid preset loads cleanly', () => {
|
|
108
|
+
const result = loadPreset(preset(), schema());
|
|
109
|
+
expect(result.isValid).toBe(true);
|
|
110
|
+
expect(result.canLoad).toBe(true);
|
|
111
|
+
expect(result.issues).toEqual([]);
|
|
112
|
+
expect(result.values).toEqual({ a: 1, b: 2 });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('blocks on a document mismatch but still returns loadable values', () => {
|
|
116
|
+
const result = loadPreset(preset({ documentId: 'other-doc' }), schema());
|
|
117
|
+
expect(result.canLoad).toBe(false);
|
|
118
|
+
expect(result.issues.some((i) => i.severity === 'error')).toBe(true);
|
|
119
|
+
// values are still computed (the caller decides whether to apply them)
|
|
120
|
+
expect(result.values).toEqual({ a: 1, b: 2 });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('allows load with warnings (schema drift) and drops missing params', () => {
|
|
124
|
+
const p = preset({
|
|
125
|
+
parameters: [
|
|
126
|
+
{ paramId: 'a', nickname: 'A', value: 1 },
|
|
127
|
+
{ paramId: 'gone', nickname: 'Gone', value: 9 }
|
|
128
|
+
] as ParameterPreset['parameters'],
|
|
129
|
+
schemaId: 'old-schema'
|
|
130
|
+
});
|
|
131
|
+
const result = loadPreset(p, schema());
|
|
132
|
+
expect(result.isValid).toBe(false);
|
|
133
|
+
expect(result.canLoad).toBe(false); // missing param is an error
|
|
134
|
+
expect(result.values).toEqual({ a: 1 });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { APP_DEFAULTS } from '../constants';
|
|
2
1
|
import { randomId } from '../utils/randomId';
|
|
2
|
+
import { getGroups } from '@selvajs/schemas';
|
|
3
3
|
import type {
|
|
4
4
|
UISchema,
|
|
5
5
|
ParameterPreset,
|
|
@@ -7,13 +7,6 @@ import type {
|
|
|
7
7
|
ValidationIssueMessage
|
|
8
8
|
} from '@selvajs/schemas';
|
|
9
9
|
|
|
10
|
-
function getGroups(schema: UISchema) {
|
|
11
|
-
if (!schema.layout) return [];
|
|
12
|
-
if (schema.layout.type === 'tabbed') return schema.layout.tabs.flatMap((t) => t.groups ?? []);
|
|
13
|
-
if (schema.layout.type === 'flat') return schema.layout.groups ?? [];
|
|
14
|
-
return [];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
10
|
export function createSavedState(
|
|
18
11
|
schema: UISchema,
|
|
19
12
|
currentValues: Record<string, unknown>,
|
|
@@ -113,43 +106,64 @@ export function validateSavedState(
|
|
|
113
106
|
return { isValid: issues.length === 0, issues, canLoad: !hasErrors };
|
|
114
107
|
}
|
|
115
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The subset of a preset's parameters that can safely be applied to the current schema:
|
|
111
|
+
* every param that still exists as an input. A param that no longer exists is dropped
|
|
112
|
+
* (it's the same condition `validateSavedState` flags as an error), so this derives its
|
|
113
|
+
* own "loadable" rule from the schema rather than re-reading validation issues — no
|
|
114
|
+
* coupling to how sentinel ids are encoded.
|
|
115
|
+
*/
|
|
116
116
|
export function extractLoadableValues(
|
|
117
117
|
savedState: ParameterPreset,
|
|
118
|
-
currentSchema: UISchema
|
|
119
|
-
validation: ReturnType<typeof validateSavedState>
|
|
118
|
+
currentSchema: UISchema
|
|
120
119
|
): Record<string, unknown> {
|
|
121
|
-
const errorParamIds = new Set(
|
|
122
|
-
validation.issues
|
|
123
|
-
.filter((i) => i.severity === 'error' && !i.paramId.startsWith('__'))
|
|
124
|
-
.map((i) => i.paramId)
|
|
125
|
-
);
|
|
126
|
-
|
|
127
120
|
const values: Record<string, unknown> = {};
|
|
128
121
|
for (const paramState of savedState.parameters) {
|
|
129
122
|
const exists = currentSchema.inputs.some((i) => i.id === paramState.paramId);
|
|
130
|
-
if (
|
|
123
|
+
if (exists) {
|
|
131
124
|
values[paramState.paramId] = paramState.value;
|
|
132
125
|
}
|
|
133
126
|
}
|
|
134
127
|
return values;
|
|
135
128
|
}
|
|
136
129
|
|
|
130
|
+
/** The full outcome of loading a preset: what to apply, plus the diagnostics to surface. */
|
|
131
|
+
export interface PresetLoadResult {
|
|
132
|
+
/** Values safe to apply now (params that still exist). */
|
|
133
|
+
values: Record<string, unknown>;
|
|
134
|
+
/** All validation issues (errors + warnings) for the load dialog. */
|
|
135
|
+
issues: ValidationIssueMessage[];
|
|
136
|
+
/** No issues at all — load silently. */
|
|
137
|
+
isValid: boolean;
|
|
138
|
+
/** No blocking errors — load is allowed (warnings are fine). */
|
|
139
|
+
canLoad: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Single entry point for applying a preset: validates against the current schema and, in
|
|
144
|
+
* the same pass, computes the loadable values. Callers get one object instead of
|
|
145
|
+
* threading a validation result back into a separate extraction step.
|
|
146
|
+
*/
|
|
147
|
+
export function loadPreset(savedState: ParameterPreset, currentSchema: UISchema): PresetLoadResult {
|
|
148
|
+
const validation = validateSavedState(savedState, currentSchema);
|
|
149
|
+
return {
|
|
150
|
+
values: extractLoadableValues(savedState, currentSchema),
|
|
151
|
+
issues: validation.issues,
|
|
152
|
+
isValid: validation.isValid,
|
|
153
|
+
canLoad: validation.canLoad
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
137
157
|
export function exportStateAsJson(savedState: ParameterPreset): void {
|
|
138
158
|
const blob = new Blob([JSON.stringify(savedState, null, 2)], { type: 'application/json' });
|
|
139
|
-
const
|
|
159
|
+
const safeName = savedState.name.replace(/[^a-z0-9]/gi, '_');
|
|
160
|
+
const date = savedState.timestamp.split('T')[0].replace(/-/g, '_');
|
|
161
|
+
|
|
140
162
|
const link = document.createElement('a');
|
|
141
|
-
link.href =
|
|
142
|
-
link.download = `${
|
|
143
|
-
link.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
setTimeout(() => {
|
|
147
|
-
link.click();
|
|
148
|
-
setTimeout(() => {
|
|
149
|
-
document.body.removeChild(link);
|
|
150
|
-
URL.revokeObjectURL(url);
|
|
151
|
-
}, APP_DEFAULTS.TIMEOUTS.PARAM_EXPORT_DELAY);
|
|
152
|
-
}, 0);
|
|
163
|
+
link.href = URL.createObjectURL(blob);
|
|
164
|
+
link.download = `${safeName}_${date}.sps`;
|
|
165
|
+
link.click();
|
|
166
|
+
URL.revokeObjectURL(link.href);
|
|
153
167
|
}
|
|
154
168
|
|
|
155
169
|
export async function importStateFromJson(file: File): Promise<ParameterPreset> {
|