@weasel-js/labkit 1.4.2 → 1.4.3
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/README.md +20 -2
- package/dist/_dts/{CanvasStackContext-kjILVnPj.d.ts → CanvasStackContext-Dde8_1my.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-CgxUequc.d.ts → PrefsForm.d-DDHFANkc.d.ts} +1 -1
- package/dist/_dts/{frac-A4R6v4ld.d.ts → frac-B5mB8LsJ.d.ts} +7 -4
- package/dist/_dts/{index-CsU9WhjM.d.ts → index-DelFfeV0.d.ts} +15 -7
- package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
- package/dist/_dts/{types-BbXpvQa8.d.ts → types-BP2OCcpg.d.ts} +21 -3
- package/dist/_dts/types-x92Kfeme.d.ts +62 -0
- package/dist/_dts/{useTrialState-DKjqpv20.d.ts → useTrialState-D6Vb3T-g.d.ts} +7 -7
- package/dist/canvas/index.d.ts +9 -8
- package/dist/canvas/index.js +1 -2
- package/dist/chrome/index.d.ts +7 -6
- package/dist/chrome/index.js +5 -5
- package/dist/{chunk-UTOEDPNU.js → chunk-2RQPGOQF.js} +7 -6
- package/dist/chunk-2RQPGOQF.js.map +1 -0
- package/dist/{chunk-ULDW42CR.js → chunk-5TROQVQ2.js} +25 -21
- package/dist/chunk-5TROQVQ2.js.map +1 -0
- package/dist/{chunk-XKENZTNE.js → chunk-ASWKLRKJ.js} +65 -49
- package/dist/chunk-ASWKLRKJ.js.map +1 -0
- package/dist/{chunk-W2FJR5FF.js → chunk-C42T6DJT.js} +74 -76
- package/dist/chunk-C42T6DJT.js.map +1 -0
- package/dist/chunk-E44SU6XS.js +50 -0
- package/dist/chunk-E44SU6XS.js.map +1 -0
- package/dist/{chunk-WS6ZRV75.js → chunk-FQMJUVHJ.js} +3 -3
- package/dist/{chunk-WS6ZRV75.js.map → chunk-FQMJUVHJ.js.map} +1 -1
- package/dist/{chunk-FJG4PHTL.js → chunk-I6JCVE24.js} +3 -3
- package/dist/chunk-I6JCVE24.js.map +1 -0
- package/dist/{chunk-NSOVI3AZ.js → chunk-ISCOFAUO.js} +6 -6
- package/dist/{chunk-NSOVI3AZ.js.map → chunk-ISCOFAUO.js.map} +1 -1
- package/dist/chunk-MHAGC6VD.js +7320 -0
- package/dist/chunk-MHAGC6VD.js.map +1 -0
- package/dist/{chunk-UDXOYZEC.js → chunk-QAGAHJKZ.js} +4 -4
- package/dist/{chunk-UDXOYZEC.js.map → chunk-QAGAHJKZ.js.map} +1 -1
- package/dist/{chunk-TN7YSJVU.js → chunk-RS3HRQWU.js} +3 -3
- package/dist/{chunk-TN7YSJVU.js.map → chunk-RS3HRQWU.js.map} +1 -1
- package/dist/{chunk-D5KQ5OY6.js → chunk-TJ7QY3OC.js} +3 -3
- package/dist/{chunk-D5KQ5OY6.js.map → chunk-TJ7QY3OC.js.map} +1 -1
- package/dist/controls/index.d.ts +5 -4
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +6 -5
- package/dist/dragdrop/index.js +1 -2
- package/dist/index.d.ts +90 -23
- package/dist/index.js +199 -117
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +7 -6
- package/dist/layers/index.js +2 -3
- package/dist/loupe/index.d.ts +8 -7
- package/dist/loupe/index.js +1 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -3
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-canvas.js.map +1 -1
- package/dist/passthrough/weasel-ui.d.ts +88 -20
- package/dist/passthrough/weasel-ui.js +1 -2
- package/dist/primitives/index.js +3 -4
- package/dist/state/index.d.ts +6 -3
- package/dist/state/index.js +3 -2
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +2 -2
- package/dist/surface/index.js +1 -2
- package/dist/ui/layers/index.js +1 -2
- package/dist/undo/index.d.ts +6 -6
- package/package.json +7 -7
- package/src/annotations/ExportMenu.test.tsx +16 -0
- package/src/annotations/ExportMenu.tsx +0 -5
- package/src/config/builder.test.ts +56 -2
- package/src/config/builder.ts +73 -13
- package/src/config/index.ts +16 -1
- package/src/config/path.test.ts +109 -0
- package/src/config/path.ts +76 -0
- package/src/config/resolve.test.ts +93 -2
- package/src/config/resolve.ts +72 -29
- package/src/config/types.ts +99 -9
- package/src/config/visible.ts +6 -4
- package/src/controls/ControlPanel.stories.tsx +38 -4
- package/src/controls/ControlPanel.test.tsx +114 -0
- package/src/controls/ControlPanel.tsx +99 -50
- package/src/index.ts +16 -1
- package/src/instrument/serializers.ts +36 -0
- package/src/instrument/types.ts +4 -2
- package/src/lab/Lab.test.tsx +29 -1
- package/src/lab/Lab.tsx +10 -1
- package/src/lab/LabShell.test.tsx +8 -0
- package/src/lab/LabShell.tsx +3 -1
- package/src/primitives/ZoomControl.less +6 -2
- package/src/primitives/ZoomControl.tsx +1 -0
- package/src/state/helpers.ts +1 -1
- package/src/state/store.test.ts +155 -1
- package/src/state/store.ts +30 -24
- package/src/state/types.ts +24 -3
- package/src/state/useTrialState.ts +1 -1
- package/src/trial/Trial.config.test.tsx +44 -0
- package/src/trial/Trial.tsx +3 -3
- package/src/trial/TrialChrome.tsx +10 -5
- package/src/trial/trialOps.ts +4 -8
- package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
- package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
- package/dist/chunk-54ZWZ5FQ.js +0 -29997
- package/dist/chunk-54ZWZ5FQ.js.map +0 -1
- package/dist/chunk-FJG4PHTL.js.map +0 -1
- package/dist/chunk-ISSVF5PT.js +0 -7263
- package/dist/chunk-ISSVF5PT.js.map +0 -1
- package/dist/chunk-ULDW42CR.js.map +0 -1
- package/dist/chunk-UTOEDPNU.js.map +0 -1
- package/dist/chunk-W2FJR5FF.js.map +0 -1
- package/dist/chunk-XKENZTNE.js.map +0 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { InstrumentSerializers } from '../state/types';
|
|
2
|
+
import type { InstrumentList } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Each instrument's default config, keyed by name — what `createLabStore`
|
|
6
|
+
* fills a stored config's gaps from, so one saved before its schema grew a
|
|
7
|
+
* branch still loads.
|
|
8
|
+
*/
|
|
9
|
+
export function configDefaultsOf(instruments: InstrumentList): Record<string, () => unknown> {
|
|
10
|
+
const out: Record<string, () => unknown> = {};
|
|
11
|
+
for (const instrument of instruments) out[instrument.name] = () => instrument.defaultConfig();
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `serialize` / `deserialize` an instrument list declares, keyed by
|
|
17
|
+
* instrument name — what `createLabStore` needs to rebuild a trial's state
|
|
18
|
+
* from storage.
|
|
19
|
+
*
|
|
20
|
+
* An instrument declaring neither gets no entry, which is what leaves a
|
|
21
|
+
* JSON-safe state to pass through untouched.
|
|
22
|
+
*/
|
|
23
|
+
export function serializersOf(instruments: InstrumentList): InstrumentSerializers {
|
|
24
|
+
const out: InstrumentSerializers = {};
|
|
25
|
+
for (const instrument of instruments) {
|
|
26
|
+
const { name, serialize, deserialize } = instrument;
|
|
27
|
+
if (!serialize && !deserialize) continue;
|
|
28
|
+
out[name] = {
|
|
29
|
+
...(serialize ? { serialize: (state: unknown) => serialize(state) } : {}),
|
|
30
|
+
...(deserialize
|
|
31
|
+
? { deserialize: (data: unknown, config: unknown) => deserialize(data, config) }
|
|
32
|
+
: {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
package/src/instrument/types.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
|
|
2
2
|
import type { AnnotationsCapability } from '../annotations/types';
|
|
3
3
|
import type { ViewportSize, WorldSpec } from '../canvas/worldSpec';
|
|
4
4
|
import type { TrialContribution } from '../chrome/types';
|
|
5
|
-
import type { ConfigSchema } from '../config/types';
|
|
5
|
+
import type { ConfigPath, ConfigSchema } from '../config/types';
|
|
6
6
|
import type { ConfigField } from '../controls/types';
|
|
7
7
|
import type { JobCapability, JobHandle } from '../job/types';
|
|
8
8
|
import type { LoupeDeclaration } from '../loupe/types';
|
|
@@ -15,7 +15,9 @@ export interface RenderContext<TS = unknown, TC = unknown> {
|
|
|
15
15
|
state: TS;
|
|
16
16
|
config: TC;
|
|
17
17
|
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
18
|
-
|
|
18
|
+
/** Write one config value, by dotted path — `'grid.size'` for a leaf under
|
|
19
|
+
* an `f.group`, `'cellSize'` for one at the root. */
|
|
20
|
+
setConfig: (path: ConfigPath<TC>, value: unknown) => void;
|
|
19
21
|
/** labkit persists `view` and restores it on Reset without ever reading
|
|
20
22
|
* into it. */
|
|
21
23
|
trial: TrialInfo & {
|
package/src/lab/Lab.test.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { act, render, screen } from '@testing-library/react';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import type { Instrument } from '../instrument/types';
|
|
5
|
+
import { createMemoryAdapter } from '../state/adapters';
|
|
6
|
+
import { labDocumentKey } from '../state/document';
|
|
5
7
|
import { Lab } from './Lab';
|
|
6
8
|
import { LabContext, type LabContextValue } from './LabContext';
|
|
7
9
|
|
|
@@ -135,4 +137,30 @@ describe('<Lab>', () => {
|
|
|
135
137
|
/Unknown instrument/,
|
|
136
138
|
);
|
|
137
139
|
});
|
|
140
|
+
|
|
141
|
+
// The store reads its serializers while it is being built, so `<Lab>` is
|
|
142
|
+
// the only thing positioned to collect them off the instruments. They went
|
|
143
|
+
// uncollected for months, and an instrument holding anything JSON drops
|
|
144
|
+
// lost it with no error.
|
|
145
|
+
it('gives the store the serializers its instruments declare', () => {
|
|
146
|
+
vi.useFakeTimers();
|
|
147
|
+
const storage = createMemoryAdapter();
|
|
148
|
+
const mapped: Instrument = {
|
|
149
|
+
...stub,
|
|
150
|
+
name: 'Mapped',
|
|
151
|
+
initialState: () => ({ seen: new Set(['a']) }),
|
|
152
|
+
serialize: (state) => ({ seen: [...(state as { seen: Set<string> }).seen] }),
|
|
153
|
+
deserialize: (data) => ({ seen: new Set((data as { seen: string[] }).seen) }),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
render(
|
|
157
|
+
<Lab instruments={[mapped]} defaultInstrument="Mapped" storage={storage} storageKey="s" />,
|
|
158
|
+
);
|
|
159
|
+
act(() => vi.advanceTimersByTime(500));
|
|
160
|
+
expect(storage.read(labDocumentKey('s')) ?? '').toContain('"seen":["a"]');
|
|
161
|
+
vi.useRealTimers();
|
|
162
|
+
|
|
163
|
+
mountLab({ instruments: [mapped], defaultInstrument: 'Mapped', storage, storageKey: 's' });
|
|
164
|
+
expect((labRef?.trials[0]?.state as { seen: Set<string> }).seen).toEqual(new Set(['a']));
|
|
165
|
+
});
|
|
138
166
|
});
|
package/src/lab/Lab.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { useStore } from 'zustand/react';
|
|
12
12
|
import type { TrialContribution } from '../chrome/types';
|
|
13
13
|
import type { ConfigRule, ControlRenderer } from '../config/types';
|
|
14
|
+
import { configDefaultsOf, serializersOf } from '../instrument/serializers';
|
|
14
15
|
import type { InstrumentList } from '../instrument/types';
|
|
15
16
|
import { noneAdapter } from '../state/adapters';
|
|
16
17
|
import { LabStoreContext } from '../state/context';
|
|
@@ -78,7 +79,15 @@ function buildStore(
|
|
|
78
79
|
storageKey: string,
|
|
79
80
|
initialMode: LabMode,
|
|
80
81
|
): LabStore {
|
|
81
|
-
|
|
82
|
+
// Hydration reads the serializers, so they go in with the store rather than
|
|
83
|
+
// being registered onto it afterwards.
|
|
84
|
+
const store = createLabStore({
|
|
85
|
+
storageKey,
|
|
86
|
+
storage,
|
|
87
|
+
initialMode,
|
|
88
|
+
serializers: serializersOf(instruments),
|
|
89
|
+
configDefaults: configDefaultsOf(instruments),
|
|
90
|
+
});
|
|
82
91
|
if (store.getState().trials.length === 0) {
|
|
83
92
|
const seeded = addTrialOp([], instruments, defaultInstrument);
|
|
84
93
|
const record = seeded[0];
|
|
@@ -39,6 +39,14 @@ describe('LabShell', () => {
|
|
|
39
39
|
expect(root.getAttribute('data-wzl-mode')).toMatch(/^(light|dark)$/);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
// The overlays weasel-ui ships default to the nearest portal host, and
|
|
43
|
+
// `.lk-root` is where labkit's element defaults live — not the themed
|
|
44
|
+
// wrapper above it.
|
|
45
|
+
test('marks lk-root as the portal host for overlays', () => {
|
|
46
|
+
const { container } = render(<LabShell title="t">x</LabShell>);
|
|
47
|
+
expect(container.querySelector('.lk-root')?.hasAttribute('data-wzl-portal-host')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
42
50
|
test('always applies lk-root class', () => {
|
|
43
51
|
const { container } = render(<LabShell title="t">x</LabShell>);
|
|
44
52
|
expect(container.querySelector('.lk-root')).not.toBeNull();
|
package/src/lab/LabShell.tsx
CHANGED
|
@@ -24,7 +24,9 @@ export function LabShell({ title, children, header, footer, mode = 'auto' }: Lab
|
|
|
24
24
|
const outer = useThemeOptional();
|
|
25
25
|
|
|
26
26
|
const shell = (
|
|
27
|
-
|
|
27
|
+
// Overlays portal here rather than to the themed wrapper above: `.lk-root`
|
|
28
|
+
// carries the element defaults a lab's bare markup is styled by.
|
|
29
|
+
<div className="lk-root lk-shell" data-wzl-portal-host="">
|
|
28
30
|
<header className="lk-shell-header">
|
|
29
31
|
<h1 className="lk-shell-title">{title}</h1>
|
|
30
32
|
{header && <div className="lk-shell-header-actions">{header}</div>}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
//
|
|
16
|
+
// The slider is a weasel-ui component whose module class sets `width`.
|
|
17
17
|
// See the `.lk-root` prefix convention in theme/base.less.
|
|
18
18
|
.lk-root {
|
|
19
19
|
.lk-zoom {
|
|
@@ -24,8 +24,12 @@
|
|
|
24
24
|
min-width: 64px;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// `width: fit` sizes the field from this rather than from the input's
|
|
28
|
+
// 20-character intrinsic width. "800%" measures 33px against the 35px a
|
|
29
|
+
// 5ch box gives, which is no margin at all in another UI font; 6ch also
|
|
30
|
+
// holds the "1600%" a consumer raising `max` can reach.
|
|
27
31
|
&__field {
|
|
28
|
-
width:
|
|
32
|
+
--wzl-number-field-width: 6ch;
|
|
29
33
|
}
|
|
30
34
|
}
|
|
31
35
|
}
|
package/src/state/helpers.ts
CHANGED
|
@@ -58,7 +58,7 @@ export function deserializeTrials(
|
|
|
58
58
|
const d = deserializers[r.instrumentName];
|
|
59
59
|
return {
|
|
60
60
|
...r,
|
|
61
|
-
state: d?.deserialize ? d.deserialize(r.state) : r.state,
|
|
61
|
+
state: d?.deserialize ? d.deserialize(r.state, r.config) : r.state,
|
|
62
62
|
undoStack: emptyUndoStack(),
|
|
63
63
|
};
|
|
64
64
|
});
|
package/src/state/store.test.ts
CHANGED
|
@@ -100,10 +100,114 @@ describe('updateTrialConfig', () => {
|
|
|
100
100
|
state: {},
|
|
101
101
|
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
102
102
|
});
|
|
103
|
-
s.getState().updateTrialConfig('w1', 'x'
|
|
103
|
+
s.getState().updateTrialConfig('w1', 'x', 99);
|
|
104
104
|
expect((s.getState().trials[0]?.config as { x: number }).x).toBe(99);
|
|
105
105
|
expect((s.getState().trials[0]?.config as { y: number }).y).toBe(2);
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
it('writes down a dotted path without disturbing its siblings', () => {
|
|
109
|
+
const s = makeStore();
|
|
110
|
+
const config = { grid: { size: 20, color: '#fff' }, showGrid: true };
|
|
111
|
+
s.getState().addTrial({
|
|
112
|
+
id: 'w1',
|
|
113
|
+
instrumentName: 'T',
|
|
114
|
+
config,
|
|
115
|
+
state: {},
|
|
116
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
117
|
+
});
|
|
118
|
+
s.getState().updateTrialConfig('w1', 'grid.size', 40);
|
|
119
|
+
expect(s.getState().trials[0]?.config).toEqual({
|
|
120
|
+
grid: { size: 40, color: '#fff' },
|
|
121
|
+
showGrid: true,
|
|
122
|
+
});
|
|
123
|
+
// The record handed in is never mutated: a trial re-renders on identity.
|
|
124
|
+
expect(config.grid.size).toBe(20);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('createLabStore - config defaults', () => {
|
|
129
|
+
const stored = (config: unknown) =>
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
version: CURRENT_DOCUMENT_VERSION,
|
|
132
|
+
trials: [
|
|
133
|
+
{
|
|
134
|
+
id: 'w1',
|
|
135
|
+
instrumentName: 'T',
|
|
136
|
+
config,
|
|
137
|
+
state: {},
|
|
138
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
saves: [
|
|
142
|
+
{
|
|
143
|
+
id: 's1',
|
|
144
|
+
name: 'saved',
|
|
145
|
+
trialId: 'w1',
|
|
146
|
+
instrumentName: 'T',
|
|
147
|
+
config,
|
|
148
|
+
state: {},
|
|
149
|
+
savedAt: 1,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
layout: {},
|
|
153
|
+
undockedPanels: {},
|
|
154
|
+
mode: 'auto',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('fills a branch a config stored before the schema nested it never had', () => {
|
|
158
|
+
const storage = createMemoryAdapter();
|
|
159
|
+
storage.write(labDocumentKey('test'), stored({ showGrid: false, gridSize: 40 }));
|
|
160
|
+
const s = createLabStore({
|
|
161
|
+
storageKey: 'test',
|
|
162
|
+
storage,
|
|
163
|
+
configDefaults: { T: () => ({ showGrid: true, grid: { size: 20, color: '#fff' } }) },
|
|
164
|
+
});
|
|
165
|
+
expect(s.getState().trials[0]?.config).toEqual({
|
|
166
|
+
showGrid: false,
|
|
167
|
+
gridSize: 40,
|
|
168
|
+
grid: { size: 20, color: '#fff' },
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('fills the config on a saved snapshot the same way', () => {
|
|
173
|
+
const storage = createMemoryAdapter();
|
|
174
|
+
storage.write(labDocumentKey('test'), stored({ grid: { size: 40 } }));
|
|
175
|
+
const s = createLabStore({
|
|
176
|
+
storageKey: 'test',
|
|
177
|
+
storage,
|
|
178
|
+
configDefaults: { T: () => ({ grid: { size: 20, color: '#fff' } }) },
|
|
179
|
+
});
|
|
180
|
+
expect(s.getState().savedSnapshots[0]?.config).toEqual({
|
|
181
|
+
grid: { size: 40, color: '#fff' },
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('hands a deserializer the filled config, not the stored one', () => {
|
|
186
|
+
const storage = createMemoryAdapter();
|
|
187
|
+
storage.write(labDocumentKey('test'), stored({ grid: { size: 40 } }));
|
|
188
|
+
const seen: unknown[] = [];
|
|
189
|
+
createLabStore({
|
|
190
|
+
storageKey: 'test',
|
|
191
|
+
storage,
|
|
192
|
+
configDefaults: { T: () => ({ grid: { size: 20, color: '#fff' } }) },
|
|
193
|
+
serializers: {
|
|
194
|
+
T: {
|
|
195
|
+
deserialize: (state, config) => {
|
|
196
|
+
seen.push(config);
|
|
197
|
+
return state;
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
expect(seen).toEqual([{ grid: { size: 40, color: '#fff' } }]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('leaves a config alone when no defaults are registered for its instrument', () => {
|
|
206
|
+
const storage = createMemoryAdapter();
|
|
207
|
+
storage.write(labDocumentKey('test'), stored({ only: 1 }));
|
|
208
|
+
const s = createLabStore({ storageKey: 'test', storage });
|
|
209
|
+
expect(s.getState().trials[0]?.config).toEqual({ only: 1 });
|
|
210
|
+
});
|
|
107
211
|
});
|
|
108
212
|
|
|
109
213
|
describe('setMode', () => {
|
|
@@ -222,6 +326,56 @@ describe('persistence — hydration', () => {
|
|
|
222
326
|
expect(hydrated.getState().trials).toHaveLength(1);
|
|
223
327
|
expect((hydrated.getState().trials[0]?.state as { n: number }).n).toBe(7);
|
|
224
328
|
});
|
|
329
|
+
|
|
330
|
+
// An instrument holding a Map, a Set or anything else JSON drops needs these
|
|
331
|
+
// to run at both ends. They are read while the store is being built, which
|
|
332
|
+
// is why they arrive as an option rather than being registered afterwards.
|
|
333
|
+
it("runs an instrument's serializers at flush and at hydrate", () => {
|
|
334
|
+
vi.useFakeTimers();
|
|
335
|
+
const mem = createMemoryAdapter();
|
|
336
|
+
const serializers = {
|
|
337
|
+
T: {
|
|
338
|
+
serialize: (state: unknown) => [...(state as Map<string, number>)],
|
|
339
|
+
deserialize: (data: unknown) => new Map(data as [string, number][]),
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const seed = createLabStore({ storageKey: 'test', storage: mem, serializers });
|
|
344
|
+
seed.getState().addTrial({
|
|
345
|
+
id: 'w1',
|
|
346
|
+
instrumentName: 'T',
|
|
347
|
+
config: {},
|
|
348
|
+
state: new Map([['a', 1]]),
|
|
349
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
350
|
+
});
|
|
351
|
+
vi.advanceTimersByTime(500);
|
|
352
|
+
vi.useRealTimers();
|
|
353
|
+
|
|
354
|
+
const written = mem.read(labDocumentKey('test')) ?? '';
|
|
355
|
+
expect(written).toContain('[["a",1]]');
|
|
356
|
+
|
|
357
|
+
const hydrated = createLabStore({ storageKey: 'test', storage: mem, serializers });
|
|
358
|
+
expect(hydrated.getState().trials[0]?.state).toEqual(new Map([['a', 1]]));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('hands `deserialize` the config the state was saved against', () => {
|
|
362
|
+
vi.useFakeTimers();
|
|
363
|
+
const mem = createMemoryAdapter();
|
|
364
|
+
const deserialize = vi.fn((data: unknown) => data);
|
|
365
|
+
const seed = createLabStore({ storageKey: 'test', storage: mem });
|
|
366
|
+
seed.getState().addTrial({
|
|
367
|
+
id: 'w1',
|
|
368
|
+
instrumentName: 'T',
|
|
369
|
+
config: { scale: 4 },
|
|
370
|
+
state: { n: 1 },
|
|
371
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
372
|
+
});
|
|
373
|
+
vi.advanceTimersByTime(500);
|
|
374
|
+
vi.useRealTimers();
|
|
375
|
+
|
|
376
|
+
createLabStore({ storageKey: 'test', storage: mem, serializers: { T: { deserialize } } });
|
|
377
|
+
expect(deserialize).toHaveBeenCalledWith({ n: 1 }, { scale: 4 });
|
|
378
|
+
});
|
|
225
379
|
});
|
|
226
380
|
|
|
227
381
|
describe('persistence — debounced writes', () => {
|
package/src/state/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createStore, type StoreApi } from 'zustand/vanilla';
|
|
2
|
+
import { fillConfigDefaults, withValueAtPath } from '../config/path';
|
|
2
3
|
import {
|
|
3
4
|
CURRENT_DOCUMENT_VERSION,
|
|
4
5
|
deleteLegacyKeys,
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
import { deserializeTrials, emptyUndoStack, serializeTrials } from './helpers';
|
|
14
15
|
import type {
|
|
15
16
|
CreateLabStoreOptions,
|
|
16
|
-
InstrumentSerializers,
|
|
17
17
|
LabDocument,
|
|
18
18
|
LabMode,
|
|
19
19
|
LabStoreState,
|
|
@@ -32,7 +32,9 @@ export interface LabStoreActions {
|
|
|
32
32
|
addTrial: (record: Omit<TrialRecord, 'undoStack'>) => void;
|
|
33
33
|
removeTrial: (id: string) => void;
|
|
34
34
|
updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
|
|
35
|
-
|
|
35
|
+
/** Write one config value. `path` is dotted for a value nested under an
|
|
36
|
+
* `f.group`, and the bare key for one at the root. */
|
|
37
|
+
updateTrialConfig: (id: string, path: string, value: unknown) => void;
|
|
36
38
|
updateTrialView: (id: string, view: unknown) => void;
|
|
37
39
|
updateTrialSidebarWidth: (id: string, width: number) => void;
|
|
38
40
|
/** Retitle a trial. `null` returns it to its instrument's name. */
|
|
@@ -59,16 +61,14 @@ export interface LabStoreActions {
|
|
|
59
61
|
dockPanel: (trialId: string, sectionId?: string) => void;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
/** A lab's store: its state and actions
|
|
63
|
-
*
|
|
64
|
-
export type LabStore = StoreApi<LabStoreState & LabStoreActions
|
|
65
|
-
registerSerializers: (s: InstrumentSerializers) => void;
|
|
66
|
-
};
|
|
64
|
+
/** A lab's store: its state and its actions. How each instrument's state is
|
|
65
|
+
* serialized comes in through `CreateLabStoreOptions.serializers`. */
|
|
66
|
+
export type LabStore = StoreApi<LabStoreState & LabStoreActions>;
|
|
67
67
|
|
|
68
68
|
/** Build a lab store, hydrating from storage if anything was saved under the
|
|
69
69
|
* same key. Writes back are debounced. */
|
|
70
70
|
export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
71
|
-
|
|
71
|
+
const serializers = options.serializers ?? {};
|
|
72
72
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
73
73
|
|
|
74
74
|
const hydration = hydrateDocument(options);
|
|
@@ -77,8 +77,20 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
77
77
|
// Cleared once the legacy keys are actually gone; see the flush.
|
|
78
78
|
let foldedFromLegacy = hydration.foldedFromLegacy;
|
|
79
79
|
|
|
80
|
-
const
|
|
81
|
-
const
|
|
80
|
+
const configDefaults = options.configDefaults ?? {};
|
|
81
|
+
const filled = (instrumentName: string, config: unknown): unknown => {
|
|
82
|
+
const defaults = configDefaults[instrumentName];
|
|
83
|
+
return defaults ? fillConfigDefaults(config, defaults()) : config;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const hydratedTrials = deserializeTrials(
|
|
87
|
+
hydrated.trials.map((t) => ({ ...t, config: filled(t.instrumentName, t.config) })),
|
|
88
|
+
serializers,
|
|
89
|
+
);
|
|
90
|
+
const hydratedSnapshots = hydrated.saves.map((sn) => ({
|
|
91
|
+
...sn,
|
|
92
|
+
config: filled(sn.instrumentName, sn.config),
|
|
93
|
+
}));
|
|
82
94
|
const hydratedLayout = hydrated.layout;
|
|
83
95
|
const hydratedMode = hydrated.mode;
|
|
84
96
|
|
|
@@ -121,15 +133,11 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
121
133
|
scheduleFlush();
|
|
122
134
|
},
|
|
123
135
|
|
|
124
|
-
updateTrialConfig: (id,
|
|
136
|
+
updateTrialConfig: (id, path, value) => {
|
|
125
137
|
set((s) => ({
|
|
126
|
-
trials: s.trials.map((w) =>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
...w,
|
|
130
|
-
config: { ...(w.config as Record<string, unknown>), [key as string]: value },
|
|
131
|
-
};
|
|
132
|
-
}),
|
|
138
|
+
trials: s.trials.map((w) =>
|
|
139
|
+
w.id === id ? { ...w, config: withValueAtPath(w.config, path, value) } : w,
|
|
140
|
+
),
|
|
133
141
|
}));
|
|
134
142
|
scheduleFlush();
|
|
135
143
|
},
|
|
@@ -237,7 +245,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
237
245
|
return;
|
|
238
246
|
}
|
|
239
247
|
const reg = serializers[snapshot.instrumentName];
|
|
240
|
-
const restoredState = reg?.deserialize
|
|
248
|
+
const restoredState = reg?.deserialize
|
|
249
|
+
? reg.deserialize(snapshot.state, snapshot.config)
|
|
250
|
+
: snapshot.state;
|
|
241
251
|
set((s) => ({
|
|
242
252
|
trials: s.trials.map((w) =>
|
|
243
253
|
w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
|
|
@@ -313,11 +323,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
313
323
|
// fold; the flush is what removes the legacy keys.
|
|
314
324
|
if (foldedFromLegacy) scheduleFlush();
|
|
315
325
|
|
|
316
|
-
return
|
|
317
|
-
registerSerializers(s: InstrumentSerializers) {
|
|
318
|
-
serializers = s;
|
|
319
|
-
},
|
|
320
|
-
});
|
|
326
|
+
return store;
|
|
321
327
|
}
|
|
322
328
|
|
|
323
329
|
interface HydrateResult {
|
package/src/state/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConfigPath, ValueAtPath } from '../config/types';
|
|
1
2
|
import type { UndockedPanels } from './undock';
|
|
2
3
|
/** A trial's undo history, as snapshots of its state either side of the
|
|
3
4
|
* present. */
|
|
@@ -95,7 +96,9 @@ export interface TrialStateHandle<TS, TC> {
|
|
|
95
96
|
state: TS;
|
|
96
97
|
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
97
98
|
config: TC;
|
|
98
|
-
|
|
99
|
+
/** Write one config value, by dotted path — `'grid.size'` for a leaf under
|
|
100
|
+
* an `f.group`, `'cellSize'` for one at the root. */
|
|
101
|
+
setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P>) => void;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
/** Options for `createLabStore`. `storageKey` namespaces the keys written, so
|
|
@@ -104,13 +107,31 @@ export interface CreateLabStoreOptions {
|
|
|
104
107
|
storageKey: string;
|
|
105
108
|
storage: StorageAdapter;
|
|
106
109
|
initialMode?: LabMode;
|
|
110
|
+
/** Each instrument's default config, keyed by instrument name, used to fill
|
|
111
|
+
* the gaps in a stored one. A config saved before its schema grew a branch
|
|
112
|
+
* arrives holding that branch's defaults rather than `undefined`, and keeps
|
|
113
|
+
* whatever keys the schema has since stopped naming. `<Lab>` collects these
|
|
114
|
+
* off its `instruments`. */
|
|
115
|
+
configDefaults?: Record<string, () => unknown>;
|
|
116
|
+
/** How each instrument's state survives a reload. Hydration is the first
|
|
117
|
+
* thing `createLabStore` does, so these have to arrive with the store —
|
|
118
|
+
* anything registered afterwards is already too late to read the document
|
|
119
|
+
* it was built from. `<Lab>` collects them off its `instruments`. */
|
|
120
|
+
serializers?: InstrumentSerializers;
|
|
107
121
|
}
|
|
108
122
|
|
|
109
123
|
/** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
|
|
110
|
-
* instrument whose state is already JSON-safe needs no entry.
|
|
124
|
+
* instrument whose state is already JSON-safe needs no entry. `deserialize`
|
|
125
|
+
* is handed the config the state was saved against — a trial's own for a
|
|
126
|
+
* reload, the snapshot's for a load — since a state rebuilt without it can
|
|
127
|
+
* disagree with the settings sitting next to it. */
|
|
111
128
|
export type InstrumentSerializers = Record<
|
|
112
129
|
string,
|
|
113
|
-
|
|
130
|
+
| {
|
|
131
|
+
serialize?: (state: unknown) => unknown;
|
|
132
|
+
deserialize?: (data: unknown, config: unknown) => unknown;
|
|
133
|
+
}
|
|
134
|
+
| undefined
|
|
114
135
|
>;
|
|
115
136
|
|
|
116
137
|
/** A trial as it is persisted: everything but the undo history, which is
|
|
@@ -24,6 +24,6 @@ export function useTrialState<TS = unknown, TC = unknown>(): TrialStateHandle<TS
|
|
|
24
24
|
state: record.state as TS,
|
|
25
25
|
config: record.config as TC,
|
|
26
26
|
setState: (next) => updateTrialState(trialId, next as Parameters<typeof updateTrialState>[1]),
|
|
27
|
-
setConfig: (
|
|
27
|
+
setConfig: (path, value) => updateTrialConfig(trialId, path, value),
|
|
28
28
|
};
|
|
29
29
|
}
|
|
@@ -106,3 +106,47 @@ describe('lab-wide config seams', () => {
|
|
|
106
106
|
expect(screen.queryByText('Settings')).not.toBeInTheDocument();
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
describe('a nested config value, end to end', () => {
|
|
111
|
+
const nestedInstrument = () => {
|
|
112
|
+
const seen: { config: unknown; prev: unknown }[] = [];
|
|
113
|
+
const instrument = defineInstrument({
|
|
114
|
+
name: 'Nested',
|
|
115
|
+
config: f.schema({
|
|
116
|
+
showGrid: f.boolean(true),
|
|
117
|
+
grid: f.group({ size: f.number(20).label('Cell size'), color: f.color('#ffffff') }),
|
|
118
|
+
}),
|
|
119
|
+
initialState: () => ({}),
|
|
120
|
+
onConfigChange: (config, prev, state) => {
|
|
121
|
+
seen.push({ config, prev });
|
|
122
|
+
return state;
|
|
123
|
+
},
|
|
124
|
+
render: (ctx) => (
|
|
125
|
+
<output>{String((ctx.config as { grid: { size: number } }).grid.size)}</output>
|
|
126
|
+
),
|
|
127
|
+
});
|
|
128
|
+
return { instrument, seen };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
it('renders a nested leaf and writes the change back into the tree', () => {
|
|
132
|
+
const { instrument } = nestedInstrument();
|
|
133
|
+
render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
|
|
134
|
+
expect(screen.getByText('20')).toBeInTheDocument();
|
|
135
|
+
fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
|
|
136
|
+
expect(screen.getByText('40')).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('hands onConfigChange the whole tree, with the sibling branch intact', () => {
|
|
140
|
+
const { instrument, seen } = nestedInstrument();
|
|
141
|
+
render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
|
|
142
|
+
fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
|
|
143
|
+
expect(seen.at(-1)?.config).toEqual({
|
|
144
|
+
showGrid: true,
|
|
145
|
+
grid: { size: 40, color: '#ffffff' },
|
|
146
|
+
});
|
|
147
|
+
expect(seen.at(-1)?.prev).toEqual({
|
|
148
|
+
showGrid: true,
|
|
149
|
+
grid: { size: 20, color: '#ffffff' },
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
package/src/trial/Trial.tsx
CHANGED
|
@@ -239,11 +239,11 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
|
|
|
239
239
|
updateTrialState(record.id, next);
|
|
240
240
|
bus.emit('state.change');
|
|
241
241
|
},
|
|
242
|
-
setConfig: (
|
|
243
|
-
const evt = `config.change:${
|
|
242
|
+
setConfig: (path, value) => {
|
|
243
|
+
const evt = `config.change:${path}`;
|
|
244
244
|
snapshotIfNeeded('config.change');
|
|
245
245
|
snapshotIfNeeded(evt);
|
|
246
|
-
updateTrialConfig(record.id,
|
|
246
|
+
updateTrialConfig(record.id, path, value);
|
|
247
247
|
bus.emit('config.change');
|
|
248
248
|
bus.emit(evt);
|
|
249
249
|
},
|
|
@@ -9,6 +9,7 @@ import { TitleBarRegion } from '../chrome/regions/TitleBarRegion';
|
|
|
9
9
|
import { ToolbarRegion } from '../chrome/regions/ToolbarRegion';
|
|
10
10
|
import { ViewportRegion } from '../chrome/regions/ViewportRegion';
|
|
11
11
|
import type { TrialChromeContext, TrialContribution, TrialRegion } from '../chrome/types';
|
|
12
|
+
import { hasConfigPath, schemaNodeAtPath, withValueAtPath } from '../config/path';
|
|
12
13
|
import { useConfigSchema } from '../config/useConfigSchema';
|
|
13
14
|
import type { Instrument } from '../instrument/types';
|
|
14
15
|
import type { JobHandle } from '../job/types';
|
|
@@ -135,16 +136,20 @@ export function TrialChrome({
|
|
|
135
136
|
configSchema,
|
|
136
137
|
configFields: instrument.config ? [] : (instrument.configSchema?.() ?? []),
|
|
137
138
|
config: record.config,
|
|
138
|
-
setConfig: (
|
|
139
|
+
setConfig: (path, value) => {
|
|
139
140
|
const prevConfig = record.config as Record<string, unknown>;
|
|
140
|
-
if (
|
|
141
|
+
if (
|
|
142
|
+
process.env.NODE_ENV !== 'production' &&
|
|
143
|
+
!hasConfigPath(prevConfig, path) &&
|
|
144
|
+
schemaNodeAtPath(configSchema.group, path) === undefined
|
|
145
|
+
) {
|
|
141
146
|
console.warn(
|
|
142
|
-
`[labkit] setConfig: unknown
|
|
147
|
+
`[labkit] setConfig: unknown path "${path}" for instrument "${record.instrumentName}"`,
|
|
143
148
|
);
|
|
144
149
|
}
|
|
145
|
-
updateTrialConfig(trialId,
|
|
150
|
+
updateTrialConfig(trialId, path, value);
|
|
146
151
|
if (instrument.onConfigChange) {
|
|
147
|
-
const nextConfig =
|
|
152
|
+
const nextConfig = withValueAtPath(prevConfig, path, value);
|
|
148
153
|
const nextState = instrument.onConfigChange(nextConfig, prevConfig, record.state);
|
|
149
154
|
updateTrialState(trialId, nextState as never);
|
|
150
155
|
}
|
package/src/trial/trialOps.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fillConfigDefaults } from '../config/path';
|
|
1
2
|
import type { Instrument, InstrumentList } from '../instrument/types';
|
|
2
3
|
import type { TrialRecord } from '../state/types';
|
|
3
4
|
|
|
@@ -27,15 +28,10 @@ export interface AddTrialOptions<TC = Record<string, unknown>> {
|
|
|
27
28
|
config?: Partial<TC>;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/** `defaultConfig()` with `seed` written over it
|
|
31
|
-
*
|
|
31
|
+
/** `defaultConfig()` with `seed` written over it, down the tree: a seed naming
|
|
32
|
+
* one leaf of a group leaves that group's other leaves at their defaults. */
|
|
32
33
|
function seedConfig<TC>(defaults: TC, seed: Partial<TC> | undefined): TC {
|
|
33
|
-
|
|
34
|
-
const out = { ...defaults };
|
|
35
|
-
for (const [key, value] of Object.entries(seed)) {
|
|
36
|
-
if (value !== undefined) (out as Record<string, unknown>)[key] = value;
|
|
37
|
-
}
|
|
38
|
-
return out;
|
|
34
|
+
return seed ? fillConfigDefaults(seed, defaults) : defaults;
|
|
39
35
|
}
|
|
40
36
|
|
|
41
37
|
/** Append a new trial running `instrumentName`, at that instrument's default
|