@selvajs/ui 4.12.0 → 4.12.2
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/preview/InputControl.svelte +22 -10
- package/dist/compute/createSolveSession.svelte.js +4 -2
- package/dist/compute/solve-session-core.d.ts +9 -0
- package/dist/compute/solve-session-core.js +18 -0
- package/package.json +4 -4
- package/src/lib/components/preview/InputControl.svelte +22 -10
- package/src/lib/compute/createSolveSession.svelte.ts +4 -1
- package/src/lib/compute/createSolveSession.test.ts +15 -0
- package/src/lib/compute/solve-session-core.ts +20 -0
|
@@ -115,28 +115,40 @@
|
|
|
115
115
|
// change (the user didn't pick the new option), so force a solve — otherwise manual-solve
|
|
116
116
|
// schemas would keep the prior output on screen, making it look like the auto-picked option
|
|
117
117
|
// produced it.
|
|
118
|
+
// Distinguishes "never selected" from "user deliberately cleared": the empty→
|
|
119
|
+
// first-option fallback below must not fight a real user action (e.g. unchecking
|
|
120
|
+
// every checklist entry), only fill the initial void.
|
|
121
|
+
let userTouched = false;
|
|
122
|
+
|
|
118
123
|
$effect(() => {
|
|
119
124
|
if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
|
|
120
125
|
const validValues = new Set(Object.values(dynamicListOptions));
|
|
126
|
+
const firstOption = Object.values(dynamicListOptions)[0];
|
|
121
127
|
// Route through onChange (not commit) — value is a one-way prop here, so writing it
|
|
122
128
|
// directly from an effect trips Svelte's binding-ownership check.
|
|
129
|
+
// A NEVER-made selection (empty string/array, e.g. no default on a fresh page)
|
|
130
|
+
// gets the same first-option fallback as a stale one: an empty selection solves
|
|
131
|
+
// as an empty string, which cascades through the definition as null-data errors
|
|
132
|
+
// ("File not found: .dmf", Text→Number conversion failures, …) and the empty
|
|
133
|
+
// result then gets replayed by the solve caches.
|
|
123
134
|
if (Array.isArray(value)) {
|
|
124
135
|
const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
|
|
125
|
-
if (pruned.length !== value.length) {
|
|
126
|
-
// Fall back to first option when checklist
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
pruned.length > 0 ? pruned : [Object.values(dynamicListOptions)[0]],
|
|
130
|
-
true
|
|
131
|
-
);
|
|
136
|
+
if (pruned.length !== value.length || (value.length === 0 && !userTouched)) {
|
|
137
|
+
// Fall back to first option when the checklist is fully pruned or was
|
|
138
|
+
// never selected; a user-cleared checklist stays empty.
|
|
139
|
+
onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
|
|
132
140
|
}
|
|
133
|
-
} else if (
|
|
134
|
-
|
|
135
|
-
|
|
141
|
+
} else if (
|
|
142
|
+
(typeof value === 'string' && value && !validValues.has(value)) ||
|
|
143
|
+
((value == null || value === '') && !userTouched)
|
|
144
|
+
) {
|
|
145
|
+
// Stale single value, or never-selected — fall back to the first option.
|
|
146
|
+
onChange(item.paramId, firstOption, true);
|
|
136
147
|
}
|
|
137
148
|
});
|
|
138
149
|
|
|
139
150
|
function commit(newValue: SupportedTypes) {
|
|
151
|
+
userTouched = true;
|
|
140
152
|
value = newValue;
|
|
141
153
|
onChange(item.paramId, newValue);
|
|
142
154
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// SolveDriver. A completed solve re-enters via report().
|
|
5
5
|
import { readExternalValue } from '../external/storage';
|
|
6
6
|
import { createComputeThrottle } from './computeThrottle.svelte';
|
|
7
|
-
import { buildInitialValues, makeInitialFlags, applyValueChange, applySolveResult } from './solve-session-core';
|
|
7
|
+
import { buildInitialValues, makeInitialFlags, applyValueChange, applySolveResult, pickInputValues } from './solve-session-core';
|
|
8
8
|
export function createSolveSession(args) {
|
|
9
9
|
let currentSchema = args.schema;
|
|
10
10
|
const flags = makeInitialFlags(currentSchema?.instanceSolve);
|
|
@@ -19,7 +19,9 @@ export function createSolveSession(args) {
|
|
|
19
19
|
hasNeverSolved: flags.hasNeverSolved
|
|
20
20
|
});
|
|
21
21
|
function dispatch() {
|
|
22
|
-
|
|
22
|
+
// Input values only: outputs merged into state.values (for widgets that read
|
|
23
|
+
// them, e.g. dynamic value lists) must not be echoed back to the transport.
|
|
24
|
+
args.driver.solve(pickInputValues(currentSchema, $state.snapshot(state.values)));
|
|
23
25
|
}
|
|
24
26
|
return {
|
|
25
27
|
get values() {
|
|
@@ -38,6 +38,15 @@ export declare function applyValueChange(state: SolveSessionState, id: string, v
|
|
|
38
38
|
state: SolveSessionState;
|
|
39
39
|
shouldSolve: boolean;
|
|
40
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* Projects the session's live values down to solve INPUTS. Solve outputs are merged
|
|
43
|
+
* into the same values map after each solve (applySolveResult) so widgets like
|
|
44
|
+
* dynamic value lists can read them — but they are not solve inputs, and echoing
|
|
45
|
+
* them back to the driver re-uploads potentially MB-sized payloads (a measured
|
|
46
|
+
* 6.4 MB options list) that no backend reads. Every transport gets this projection
|
|
47
|
+
* for free by going through the session's dispatch.
|
|
48
|
+
*/
|
|
49
|
+
export declare function pickInputValues(schema: UISchema | undefined, values: Record<string, unknown>): Record<string, unknown>;
|
|
41
50
|
/**
|
|
42
51
|
* Merges a reported solve result into the state and clears the post-solve lifecycle
|
|
43
52
|
* flags. Missing result arrays are treated as empty.
|
|
@@ -52,6 +52,24 @@ export function applyValueChange(state, id, value, instanceSolve) {
|
|
|
52
52
|
}
|
|
53
53
|
return { state, shouldSolve: true };
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Projects the session's live values down to solve INPUTS. Solve outputs are merged
|
|
57
|
+
* into the same values map after each solve (applySolveResult) so widgets like
|
|
58
|
+
* dynamic value lists can read them — but they are not solve inputs, and echoing
|
|
59
|
+
* them back to the driver re-uploads potentially MB-sized payloads (a measured
|
|
60
|
+
* 6.4 MB options list) that no backend reads. Every transport gets this projection
|
|
61
|
+
* for free by going through the session's dispatch.
|
|
62
|
+
*/
|
|
63
|
+
export function pickInputValues(schema, values) {
|
|
64
|
+
if (!schema?.inputs)
|
|
65
|
+
return values;
|
|
66
|
+
const picked = {};
|
|
67
|
+
for (const input of schema.inputs) {
|
|
68
|
+
if (input.id in values)
|
|
69
|
+
picked[input.id] = values[input.id];
|
|
70
|
+
}
|
|
71
|
+
return picked;
|
|
72
|
+
}
|
|
55
73
|
/**
|
|
56
74
|
* Merges a reported solve result into the state and clears the post-solve lifecycle
|
|
57
75
|
* flags. Missing result arrays are treated as empty.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "4.12.
|
|
3
|
+
"version": "4.12.2",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"**/*.css"
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@selvajs/compute": "^3.0.
|
|
40
|
+
"@selvajs/compute": "^3.0.1",
|
|
41
41
|
"@sveltejs/kit": "^2",
|
|
42
42
|
"bits-ui": "^2.18.0",
|
|
43
43
|
"svelte": "^5",
|
|
@@ -72,8 +72,8 @@
|
|
|
72
72
|
"svelte": "5.55.5",
|
|
73
73
|
"tailwind-variants": "^3.2.2",
|
|
74
74
|
"vitest": "^3.2.6",
|
|
75
|
-
"@selvajs/
|
|
76
|
-
"@selvajs/
|
|
75
|
+
"@selvajs/config": "0.0.2",
|
|
76
|
+
"@selvajs/schemas": "4.6.1"
|
|
77
77
|
},
|
|
78
78
|
"scripts": {
|
|
79
79
|
"dev": "vite dev",
|
|
@@ -115,28 +115,40 @@
|
|
|
115
115
|
// change (the user didn't pick the new option), so force a solve — otherwise manual-solve
|
|
116
116
|
// schemas would keep the prior output on screen, making it look like the auto-picked option
|
|
117
117
|
// produced it.
|
|
118
|
+
// Distinguishes "never selected" from "user deliberately cleared": the empty→
|
|
119
|
+
// first-option fallback below must not fight a real user action (e.g. unchecking
|
|
120
|
+
// every checklist entry), only fill the initial void.
|
|
121
|
+
let userTouched = false;
|
|
122
|
+
|
|
118
123
|
$effect(() => {
|
|
119
124
|
if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
|
|
120
125
|
const validValues = new Set(Object.values(dynamicListOptions));
|
|
126
|
+
const firstOption = Object.values(dynamicListOptions)[0];
|
|
121
127
|
// Route through onChange (not commit) — value is a one-way prop here, so writing it
|
|
122
128
|
// directly from an effect trips Svelte's binding-ownership check.
|
|
129
|
+
// A NEVER-made selection (empty string/array, e.g. no default on a fresh page)
|
|
130
|
+
// gets the same first-option fallback as a stale one: an empty selection solves
|
|
131
|
+
// as an empty string, which cascades through the definition as null-data errors
|
|
132
|
+
// ("File not found: .dmf", Text→Number conversion failures, …) and the empty
|
|
133
|
+
// result then gets replayed by the solve caches.
|
|
123
134
|
if (Array.isArray(value)) {
|
|
124
135
|
const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
|
|
125
|
-
if (pruned.length !== value.length) {
|
|
126
|
-
// Fall back to first option when checklist
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
pruned.length > 0 ? pruned : [Object.values(dynamicListOptions)[0]],
|
|
130
|
-
true
|
|
131
|
-
);
|
|
136
|
+
if (pruned.length !== value.length || (value.length === 0 && !userTouched)) {
|
|
137
|
+
// Fall back to first option when the checklist is fully pruned or was
|
|
138
|
+
// never selected; a user-cleared checklist stays empty.
|
|
139
|
+
onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
|
|
132
140
|
}
|
|
133
|
-
} else if (
|
|
134
|
-
|
|
135
|
-
|
|
141
|
+
} else if (
|
|
142
|
+
(typeof value === 'string' && value && !validValues.has(value)) ||
|
|
143
|
+
((value == null || value === '') && !userTouched)
|
|
144
|
+
) {
|
|
145
|
+
// Stale single value, or never-selected — fall back to the first option.
|
|
146
|
+
onChange(item.paramId, firstOption, true);
|
|
136
147
|
}
|
|
137
148
|
});
|
|
138
149
|
|
|
139
150
|
function commit(newValue: SupportedTypes) {
|
|
151
|
+
userTouched = true;
|
|
140
152
|
value = newValue;
|
|
141
153
|
onChange(item.paramId, newValue);
|
|
142
154
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
makeInitialFlags,
|
|
13
13
|
applyValueChange,
|
|
14
14
|
applySolveResult,
|
|
15
|
+
pickInputValues,
|
|
15
16
|
type SolveSessionState
|
|
16
17
|
} from './solve-session-core';
|
|
17
18
|
|
|
@@ -75,7 +76,9 @@ export function createSolveSession(args: SolveSessionArgs): SolveSession {
|
|
|
75
76
|
});
|
|
76
77
|
|
|
77
78
|
function dispatch() {
|
|
78
|
-
|
|
79
|
+
// Input values only: outputs merged into state.values (for widgets that read
|
|
80
|
+
// them, e.g. dynamic value lists) must not be echoed back to the transport.
|
|
81
|
+
args.driver.solve(pickInputValues(currentSchema, $state.snapshot(state.values)));
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
return {
|
|
@@ -67,4 +67,19 @@ describe('createSolveSession.setValue', () => {
|
|
|
67
67
|
session.setValue('a', 'y', true);
|
|
68
68
|
expect(driver.solves.length).toBe(1);
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
it('never echoes output-keyed values back to the driver', () => {
|
|
72
|
+
const driver = recordingDriver();
|
|
73
|
+
const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
|
|
74
|
+
// A solve result merges outputs into the session's values map (how widgets
|
|
75
|
+
// like dynamic value lists read them) — e.g. a multi-MB options payload.
|
|
76
|
+
session.report({ outputs: { out: { options: { huge: 'payload' } } } });
|
|
77
|
+
session.setValue('a', 'y');
|
|
78
|
+
expect(driver.solves.length).toBe(1);
|
|
79
|
+
expect(driver.solves[0].a).toBe('y');
|
|
80
|
+
// The output entry lives in session.values for widgets…
|
|
81
|
+
expect(session.values.out).toBeDefined();
|
|
82
|
+
// …but must not travel back through the transport.
|
|
83
|
+
expect(driver.solves[0]).not.toHaveProperty('out');
|
|
84
|
+
});
|
|
70
85
|
});
|
|
@@ -85,6 +85,26 @@ export function applyValueChange(
|
|
|
85
85
|
return { state, shouldSolve: true };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Projects the session's live values down to solve INPUTS. Solve outputs are merged
|
|
90
|
+
* into the same values map after each solve (applySolveResult) so widgets like
|
|
91
|
+
* dynamic value lists can read them — but they are not solve inputs, and echoing
|
|
92
|
+
* them back to the driver re-uploads potentially MB-sized payloads (a measured
|
|
93
|
+
* 6.4 MB options list) that no backend reads. Every transport gets this projection
|
|
94
|
+
* for free by going through the session's dispatch.
|
|
95
|
+
*/
|
|
96
|
+
export function pickInputValues(
|
|
97
|
+
schema: UISchema | undefined,
|
|
98
|
+
values: Record<string, unknown>
|
|
99
|
+
): Record<string, unknown> {
|
|
100
|
+
if (!schema?.inputs) return values;
|
|
101
|
+
const picked: Record<string, unknown> = {};
|
|
102
|
+
for (const input of schema.inputs) {
|
|
103
|
+
if (input.id in values) picked[input.id] = values[input.id];
|
|
104
|
+
}
|
|
105
|
+
return picked;
|
|
106
|
+
}
|
|
107
|
+
|
|
88
108
|
/**
|
|
89
109
|
* Merges a reported solve result into the state and clears the post-solve lifecycle
|
|
90
110
|
* flags. Missing result arrays are treated as empty.
|