@buoy-gg/zustand 7.0.30 → 7.0.31
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/lib/commonjs/zustand/components/ZustandActionButton.js +3 -1
- package/lib/commonjs/zustand/components/ZustandDetailViewToggle.js +7 -0
- package/lib/commonjs/zustand/components/ZustandEventFilterView.js +19 -4
- package/lib/commonjs/zustand/components/ZustandModal.js +173 -32
- package/lib/commonjs/zustand/components/ZustandStateChangeItem.js +36 -37
- package/lib/commonjs/zustand/components/ZustandStateDetailContent.js +22 -5
- package/lib/commonjs/zustand/components/ZustandStateInfoView.js +30 -7
- package/lib/commonjs/zustand/components/ZustandStoreBrowser.js +23 -126
- package/lib/commonjs/zustand/components/ZustandStoreDetailContent.js +471 -0
- package/lib/commonjs/zustand/components/ZustandStoreEditorScreen.js +393 -0
- package/lib/commonjs/zustand/sync/zustandSyncAdapter.js +54 -0
- package/lib/commonjs/zustand/utils/buoyZustandMiddleware.js +71 -7
- package/lib/commonjs/zustand/utils/editableState.js +139 -0
- package/lib/commonjs/zustand/utils/snapshotProvider.js +10 -37
- package/lib/commonjs/zustand/utils/testIDs.js +82 -0
- package/lib/commonjs/zustand/utils/zustandStateStore.js +16 -3
- package/lib/module/zustand/components/ZustandActionButton.js +3 -1
- package/lib/module/zustand/components/ZustandDetailViewToggle.js +7 -0
- package/lib/module/zustand/components/ZustandEventFilterView.js +19 -4
- package/lib/module/zustand/components/ZustandModal.js +173 -32
- package/lib/module/zustand/components/ZustandStateChangeItem.js +36 -37
- package/lib/module/zustand/components/ZustandStateDetailContent.js +22 -5
- package/lib/module/zustand/components/ZustandStateInfoView.js +30 -7
- package/lib/module/zustand/components/ZustandStoreBrowser.js +26 -129
- package/lib/module/zustand/components/ZustandStoreDetailContent.js +469 -0
- package/lib/module/zustand/components/ZustandStoreEditorScreen.js +389 -0
- package/lib/module/zustand/sync/zustandSyncAdapter.js +54 -0
- package/lib/module/zustand/utils/buoyZustandMiddleware.js +71 -7
- package/lib/module/zustand/utils/editableState.js +131 -0
- package/lib/module/zustand/utils/snapshotProvider.js +4 -30
- package/lib/module/zustand/utils/testIDs.js +74 -0
- package/lib/module/zustand/utils/zustandStateStore.js +16 -3
- package/lib/typescript/zustand/components/ZustandActionButton.d.ts +2 -1
- package/lib/typescript/zustand/components/ZustandActionButton.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandDetailViewToggle.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandEventFilterView.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandModal.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandStateChangeItem.d.ts +6 -0
- package/lib/typescript/zustand/components/ZustandStateChangeItem.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandStateDetailContent.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandStateInfoView.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts +7 -9
- package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandStoreDetailContent.d.ts +40 -0
- package/lib/typescript/zustand/components/ZustandStoreDetailContent.d.ts.map +1 -0
- package/lib/typescript/zustand/components/ZustandStoreEditorScreen.d.ts +42 -0
- package/lib/typescript/zustand/components/ZustandStoreEditorScreen.d.ts.map +1 -0
- package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts +35 -0
- package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts.map +1 -1
- package/lib/typescript/zustand/types/index.d.ts +13 -0
- package/lib/typescript/zustand/types/index.d.ts.map +1 -1
- package/lib/typescript/zustand/utils/buoyZustandMiddleware.d.ts +31 -3
- package/lib/typescript/zustand/utils/buoyZustandMiddleware.d.ts.map +1 -1
- package/lib/typescript/zustand/utils/editableState.d.ts +65 -0
- package/lib/typescript/zustand/utils/editableState.d.ts.map +1 -0
- package/lib/typescript/zustand/utils/testIDs.d.ts +54 -0
- package/lib/typescript/zustand/utils/testIDs.d.ts.map +1 -0
- package/lib/typescript/zustand/utils/zustandStateStore.d.ts +1 -0
- package/lib/typescript/zustand/utils/zustandStateStore.d.ts.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.STRIP_MAX_DEPTH = void 0;
|
|
7
|
+
exports.computeMergePartial = computeMergePartial;
|
|
8
|
+
exports.editableStateOf = editableStateOf;
|
|
9
|
+
exports.isPlainObjectLike = isPlainObjectLike;
|
|
10
|
+
exports.stripFunctions = stripFunctions;
|
|
11
|
+
/**
|
|
12
|
+
* What of a zustand store's state can be EDITED, and how an edit is written.
|
|
13
|
+
*
|
|
14
|
+
* The two zustand facts every rule here follows from:
|
|
15
|
+
*
|
|
16
|
+
* 1. Stores conventionally keep their action functions IN state
|
|
17
|
+
* (`increment: () => …`). Functions can't be typed into a tree editor and
|
|
18
|
+
* don't survive serialization, so the editor works on a function-free view
|
|
19
|
+
* of state.
|
|
20
|
+
* 2. `setState` is either a TOP-LEVEL SHALLOW MERGE (`Object.assign({}, state,
|
|
21
|
+
* partial)`) or a full replace that deletes every key not present — actions
|
|
22
|
+
* included. So the editor writes merge-only, always: every edit, however
|
|
23
|
+
* deep, is expressed as "replace the top-level key(s) that contain it"
|
|
24
|
+
* ({@link computeMergePartial}), and the two edits a merge cannot express —
|
|
25
|
+
* removing or renaming a top-level key — are refused rather than silently
|
|
26
|
+
* not happening.
|
|
27
|
+
*
|
|
28
|
+
* `stripFunctions` lives here (the Time Machine snapshot provider imports it)
|
|
29
|
+
* because capture and editing need the identical answer to "what is this
|
|
30
|
+
* store's data".
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Bounds every recursive walk over store state. */
|
|
34
|
+
const STRIP_MAX_DEPTH = exports.STRIP_MAX_DEPTH = 8;
|
|
35
|
+
function isPlainObjectLike(v) {
|
|
36
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Clone `value` without functions, recording their paths. */
|
|
40
|
+
function stripFunctions(value, path, fnPaths, seen, depth) {
|
|
41
|
+
if (typeof value === "function") {
|
|
42
|
+
fnPaths.push(path);
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
if (typeof value !== "object" || value === null || depth >= STRIP_MAX_DEPTH) {
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
if (seen.has(value)) return undefined; // cycle — drop the back-reference
|
|
49
|
+
seen.add(value);
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
return value.map((item, i) => typeof item === "function" ? (fnPaths.push(`${path}[${i}]`), null) : stripFunctions(item, `${path}[${i}]`, fnPaths, seen, depth + 1));
|
|
52
|
+
}
|
|
53
|
+
if (!isPlainObjectLike(value)) return value; // Date/Map/Set/etc. — superjson handles
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const [k, v] of Object.entries(value)) {
|
|
56
|
+
if (typeof v === "function") {
|
|
57
|
+
fnPaths.push(path ? `${path}.${k}` : k);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
out[k] = stripFunctions(v, path ? `${path}.${k}` : k, fnPaths, seen, depth + 1);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The editor's view of one store's state, with the reasons it may refuse.
|
|
66
|
+
*
|
|
67
|
+
* - A remote mirror that hasn't hydrated yet holds a marker, not state — the
|
|
68
|
+
* real value is one `getStoreState` round trip away, so "wait" rather than
|
|
69
|
+
* "can't".
|
|
70
|
+
* - Non-object state (`create(() => 5)`) has no top-level keys, so a merge
|
|
71
|
+
* write cannot address any part of it. Editing it would require a replace,
|
|
72
|
+
* which is the destructive path this editor exists to avoid.
|
|
73
|
+
* - A function nested BELOW the top level (rare — the convention keeps actions
|
|
74
|
+
* at the top) sits inside the very key an edit would rewrite, and a merge
|
|
75
|
+
* write of that key would silently drop it. Refusing the whole store is
|
|
76
|
+
* blunt but honest; per-key locks can come later if this ever bites.
|
|
77
|
+
*/
|
|
78
|
+
function editableStateOf(state) {
|
|
79
|
+
if (isPlainObjectLike(state) && state.__buoyStateOnDevice === true) {
|
|
80
|
+
return {
|
|
81
|
+
data: null,
|
|
82
|
+
fnPaths: [],
|
|
83
|
+
blockedReason: "Store state hasn't synced from the device yet."
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (!isPlainObjectLike(state)) {
|
|
87
|
+
return {
|
|
88
|
+
data: null,
|
|
89
|
+
fnPaths: [],
|
|
90
|
+
blockedReason: "Only stores whose state is a plain object can be edited — " + "a merge write has nothing to address in " + (Array.isArray(state) ? "an array" : `a ${typeof state}`) + "."
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const fnPaths = [];
|
|
94
|
+
const data = stripFunctions(state, "", fnPaths, new WeakSet(), 0);
|
|
95
|
+
const nested = fnPaths.filter(p => p.includes(".") || p.includes("["));
|
|
96
|
+
if (nested.length > 0) {
|
|
97
|
+
return {
|
|
98
|
+
data: null,
|
|
99
|
+
fnPaths,
|
|
100
|
+
blockedReason: `State keeps a function inside "${nested[0]}" — a merge write of ` + "that key would drop it, so this store can't be edited here."
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
data,
|
|
105
|
+
fnPaths,
|
|
106
|
+
blockedReason: null
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Diff an edited draft against the state it was seeded from, as a `setState`
|
|
112
|
+
* MERGE partial: only the top-level keys whose contents changed.
|
|
113
|
+
*
|
|
114
|
+
* Diffed against the SEED, not the live state at save time, on purpose — the
|
|
115
|
+
* partial then carries exactly what the user changed, so a key the app wrote
|
|
116
|
+
* while the editor was open and the user never touched keeps the app's value
|
|
117
|
+
* (per-key last-write-wins, the same contract the draft's own dirtiness uses).
|
|
118
|
+
*
|
|
119
|
+
* Throws on the two edits a merge cannot express, naming the key: a draft
|
|
120
|
+
* that dropped a top-level key (which is also what a raw-JSON rename produces
|
|
121
|
+
* — the old key dropped, a new one added), because `setState(partial, false)`
|
|
122
|
+
* would leave it exactly as it was while the editor showed it gone.
|
|
123
|
+
*/
|
|
124
|
+
function computeMergePartial(seed, draft) {
|
|
125
|
+
if (!isPlainObjectLike(draft)) {
|
|
126
|
+
throw new Error("The store's state must stay an object.");
|
|
127
|
+
}
|
|
128
|
+
const dropped = Object.keys(seed).filter(k => !(k in draft));
|
|
129
|
+
if (dropped.length > 0) {
|
|
130
|
+
throw new Error(`Can't remove top-level ${dropped.length === 1 ? "key" : "keys"} ` + `${dropped.map(k => `"${k}"`).join(", ")} — zustand merge writes ` + "can't delete. Edit the value instead.");
|
|
131
|
+
}
|
|
132
|
+
const partial = {};
|
|
133
|
+
for (const key of Object.keys(draft)) {
|
|
134
|
+
if (!(key in seed) || JSON.stringify(draft[key]) !== JSON.stringify(seed[key])) {
|
|
135
|
+
partial[key] = draft[key];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return partial;
|
|
139
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
var _sharedUi = require("@buoy-gg/shared-ui");
|
|
4
4
|
var _dataViewer = require("@buoy-gg/shared-ui/dataViewer");
|
|
5
5
|
var _zustandStateStore = require("./zustandStateStore");
|
|
6
|
+
var _editableState = require("./editableState");
|
|
6
7
|
/**
|
|
7
8
|
* Time Machine snapshot provider for Zustand.
|
|
8
9
|
*
|
|
@@ -17,51 +18,23 @@ var _zustandStateStore = require("./zustandStateStore");
|
|
|
17
18
|
* LIVE store's functions onto the snapshot before replacing.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Clone `value` without functions, recording their paths. */
|
|
26
|
-
function stripFunctions(value, path, fnPaths, seen, depth) {
|
|
27
|
-
if (typeof value === "function") {
|
|
28
|
-
fnPaths.push(path);
|
|
29
|
-
return undefined;
|
|
30
|
-
}
|
|
31
|
-
if (typeof value !== "object" || value === null || depth >= MAX_DEPTH) {
|
|
32
|
-
return value;
|
|
33
|
-
}
|
|
34
|
-
if (seen.has(value)) return undefined; // cycle — drop the back-reference
|
|
35
|
-
seen.add(value);
|
|
36
|
-
if (Array.isArray(value)) {
|
|
37
|
-
return value.map((item, i) => typeof item === "function" ? (fnPaths.push(`${path}[${i}]`), null) : stripFunctions(item, `${path}[${i}]`, fnPaths, seen, depth + 1));
|
|
38
|
-
}
|
|
39
|
-
if (!isPlainObjectLike(value)) return value; // Date/Map/Set/etc. — superjson handles
|
|
40
|
-
const out = {};
|
|
41
|
-
for (const [k, v] of Object.entries(value)) {
|
|
42
|
-
if (typeof v === "function") {
|
|
43
|
-
fnPaths.push(path ? `${path}.${k}` : k);
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
out[k] = stripFunctions(v, path ? `${path}.${k}` : k, fnPaths, seen, depth + 1);
|
|
47
|
-
}
|
|
48
|
-
return out;
|
|
49
|
-
}
|
|
50
|
-
|
|
21
|
+
// The function-stripping walk is shared with the store editor (editableState
|
|
22
|
+
// .ts) — capture and editing must agree on what a store's "data" is.
|
|
23
|
+
const MAX_DEPTH = _editableState.STRIP_MAX_DEPTH;
|
|
51
24
|
/**
|
|
52
25
|
* Copy every function-valued property from the LIVE state onto the restored
|
|
53
26
|
* snapshot (creating containers as needed), so store actions keep working
|
|
54
27
|
* after a full replace.
|
|
55
28
|
*/
|
|
56
29
|
function graftFunctions(target, live, seen, depth) {
|
|
57
|
-
if (!isPlainObjectLike(live) || !isPlainObjectLike(target)) return;
|
|
30
|
+
if (!(0, _editableState.isPlainObjectLike)(live) || !(0, _editableState.isPlainObjectLike)(target)) return;
|
|
58
31
|
if (depth >= MAX_DEPTH || seen.has(live)) return;
|
|
59
32
|
seen.add(live);
|
|
60
33
|
for (const [k, v] of Object.entries(live)) {
|
|
61
34
|
if (typeof v === "function") {
|
|
62
35
|
target[k] = v;
|
|
63
|
-
} else if (isPlainObjectLike(v)) {
|
|
64
|
-
if (!isPlainObjectLike(target[k])) {
|
|
36
|
+
} else if ((0, _editableState.isPlainObjectLike)(v)) {
|
|
37
|
+
if (!(0, _editableState.isPlainObjectLike)(target[k])) {
|
|
65
38
|
// Branch missing from the snapshot — only recreate it if it holds
|
|
66
39
|
// functions somewhere; otherwise the snapshot's absence wins.
|
|
67
40
|
if (!containsFunction(v, new WeakSet(), depth + 1)) continue;
|
|
@@ -76,7 +49,7 @@ function containsFunction(value, seen, depth) {
|
|
|
76
49
|
seen.add(value);
|
|
77
50
|
for (const v of Object.values(value)) {
|
|
78
51
|
if (typeof v === "function") return true;
|
|
79
|
-
if (isPlainObjectLike(v) && containsFunction(v, seen, depth + 1)) return true;
|
|
52
|
+
if ((0, _editableState.isPlainObjectLike)(v) && containsFunction(v, seen, depth + 1)) return true;
|
|
80
53
|
}
|
|
81
54
|
return false;
|
|
82
55
|
}
|
|
@@ -101,7 +74,7 @@ async function capture() {
|
|
|
101
74
|
const fnPaths = [];
|
|
102
75
|
let state;
|
|
103
76
|
try {
|
|
104
|
-
state = stripFunctions(info.api.getState(), "", fnPaths, new WeakSet(), 0);
|
|
77
|
+
state = (0, _editableState.stripFunctions)(info.api.getState(), "", fnPaths, new WeakSet(), 0);
|
|
105
78
|
} catch (e) {
|
|
106
79
|
warnings.push(`store "${info.name}": capture failed (${String(e)})`);
|
|
107
80
|
continue;
|
|
@@ -190,7 +163,7 @@ async function previewItems(target, base) {
|
|
|
190
163
|
const fnPaths = [];
|
|
191
164
|
try {
|
|
192
165
|
baseStores[info.name] = {
|
|
193
|
-
state: stripFunctions(info.api.getState(), "", fnPaths, new WeakSet(), 0),
|
|
166
|
+
state: (0, _editableState.stripFunctions)(info.api.getState(), "", fnPaths, new WeakSet(), 0),
|
|
194
167
|
fnPaths
|
|
195
168
|
};
|
|
196
169
|
} catch {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.ZUSTAND_TEST_ID = void 0;
|
|
7
|
+
exports.buildChangeRowTestIDs = buildChangeRowTestIDs;
|
|
8
|
+
exports.changeRowTestID = changeRowTestID;
|
|
9
|
+
exports.slugForTestID = slugForTestID;
|
|
10
|
+
exports.storeRowTestID = storeRowTestID;
|
|
11
|
+
/**
|
|
12
|
+
* Zustand tool test handles.
|
|
13
|
+
*
|
|
14
|
+
* Every tappable and every value the e2e suite reads back is addressed
|
|
15
|
+
* `buoy-zustand-<thing>`. Two rules in here need explaining.
|
|
16
|
+
*
|
|
17
|
+
* **A change row's handle carries an ORDINAL, and it counts per STORE.** The
|
|
18
|
+
* Events tool can address a row by what it is about, because an event is a
|
|
19
|
+
* thing that happened to a named subject — one key, one URL, one atom. A
|
|
20
|
+
* zustand state change is not: a store that a screen writes to fifty times
|
|
21
|
+
* produces fifty separate, equally real rows. Addressing them by store name
|
|
22
|
+
* alone would give fifty views one handle, and Detox fails a *matcher* that
|
|
23
|
+
* hits more than one element — so a suite would break on `toExist()`, before
|
|
24
|
+
* any assertion, with an error that says nothing about duplicate changes.
|
|
25
|
+
*
|
|
26
|
+
* So the handle is `buoy-zustand-row-<slug(storeName)>-<n>`, where n counts
|
|
27
|
+
* back from the newest row of that store (1 = newest). Deliberately relative:
|
|
28
|
+
* a state change has no identity a host could know in advance — `id` is a
|
|
29
|
+
* timestamp plus a per-process counter whose value depends on how many
|
|
30
|
+
* changes the app happened to make before the test looked. What a test CAN
|
|
31
|
+
* know is "the set I just fired is the newest change to that store". Read and
|
|
32
|
+
* tap in one beat: a row's ordinal moves the instant another change lands.
|
|
33
|
+
*
|
|
34
|
+
* **A store row's handle does NOT.** A store is registered once under a name
|
|
35
|
+
* that is unique by construction (`zustandStateStore.registerStore` returns
|
|
36
|
+
* early on a name it already holds), so `buoy-zustand-store-<slug(name)>` is
|
|
37
|
+
* as stable as the store itself — and unlike a change, it is a thing the host
|
|
38
|
+
* knows the name of before the app has booted.
|
|
39
|
+
*
|
|
40
|
+
* The suite mirrors these rules in CommonJS (`example-dev-build/utils/
|
|
41
|
+
* zustandBench.js`) and asserts the two agree — Detox runs in Node on the host
|
|
42
|
+
* and cannot import TypeScript.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** Prefix every handle in this tool shares. */
|
|
46
|
+
const ZUSTAND_TEST_ID = exports.ZUSTAND_TEST_ID = "buoy-zustand";
|
|
47
|
+
|
|
48
|
+
/** `zbenchWatch` → `zbenchwatch`. Shared with every Buoy tool. */
|
|
49
|
+
function slugForTestID(value) {
|
|
50
|
+
return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Handle for one change row: `buoy-zustand-row-zbenchwatch-1`.
|
|
55
|
+
*
|
|
56
|
+
* `ordinal` is 1-based and counts back from the newest row of this store.
|
|
57
|
+
*/
|
|
58
|
+
function changeRowTestID(storeName, ordinal) {
|
|
59
|
+
return `${ZUSTAND_TEST_ID}-row-${slugForTestID(storeName)}-${ordinal}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Handle for one store row in the browser: `buoy-zustand-store-zbenchwatch`. */
|
|
63
|
+
function storeRowTestID(storeName) {
|
|
64
|
+
return `${ZUSTAND_TEST_ID}-store-${slugForTestID(storeName)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Row handle per change id for one rendered list, newest-first.
|
|
69
|
+
*
|
|
70
|
+
* Built once for the whole list rather than per row, because an ordinal is a
|
|
71
|
+
* fact about the list and a row cannot compute its own.
|
|
72
|
+
*/
|
|
73
|
+
function buildChangeRowTestIDs(changes) {
|
|
74
|
+
const seen = new Map();
|
|
75
|
+
const handles = new Map();
|
|
76
|
+
for (const change of changes) {
|
|
77
|
+
const ordinal = (seen.get(change.storeName) ?? 0) + 1;
|
|
78
|
+
seen.set(change.storeName, ordinal);
|
|
79
|
+
handles.set(change.id, changeRowTestID(change.storeName, ordinal));
|
|
80
|
+
}
|
|
81
|
+
return handles;
|
|
82
|
+
}
|
|
@@ -128,7 +128,14 @@ function formatPartialPreview(partial, maxLength = 40) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
* Calculate state diff summary
|
|
131
|
+
* Calculate state diff summary.
|
|
132
|
+
*
|
|
133
|
+
* `changedCount` counts the keys in `changedKeys` and nothing else. The two
|
|
134
|
+
* used to disagree: a state object that was REPLACED without any top-level
|
|
135
|
+
* value moving — which is exactly what zustand's persist middleware does when
|
|
136
|
+
* it rehydrates — reported one changed key and named none, so the row said
|
|
137
|
+
* "1 key changed" and the detail page it opened drew no CHANGED KEYS card at
|
|
138
|
+
* all. `summary` is where a change with no nameable keys is described.
|
|
132
139
|
*/
|
|
133
140
|
function getStateDiffSummary(prevState, nextState) {
|
|
134
141
|
if (prevState === nextState) {
|
|
@@ -142,7 +149,7 @@ function getStateDiffSummary(prevState, nextState) {
|
|
|
142
149
|
return {
|
|
143
150
|
summary: "changed",
|
|
144
151
|
changedKeys: [],
|
|
145
|
-
changedCount:
|
|
152
|
+
changedCount: 0
|
|
146
153
|
};
|
|
147
154
|
}
|
|
148
155
|
const prevKeys = Object.keys(prevState);
|
|
@@ -165,7 +172,7 @@ function getStateDiffSummary(prevState, nextState) {
|
|
|
165
172
|
return {
|
|
166
173
|
summary: "nested change",
|
|
167
174
|
changedKeys: [],
|
|
168
|
-
changedCount:
|
|
175
|
+
changedCount: 0
|
|
169
176
|
};
|
|
170
177
|
}
|
|
171
178
|
return {
|
|
@@ -329,6 +336,11 @@ class ZustandStateStore {
|
|
|
329
336
|
stateChangeCount: store.stateChangeCount,
|
|
330
337
|
isPersisted: store.isPersisted,
|
|
331
338
|
persistName: store.persistName,
|
|
339
|
+
// Persisted is not the same as re-readable: a store registered before
|
|
340
|
+
// its persist middleware attached has a name but no handle. The
|
|
341
|
+
// Storage tool needs the difference to decide between offering "apply"
|
|
342
|
+
// and telling the user to restart.
|
|
343
|
+
canRehydrate: typeof store.rehydrate === "function",
|
|
332
344
|
color: store.color,
|
|
333
345
|
currentState
|
|
334
346
|
};
|
|
@@ -450,6 +462,7 @@ class ZustandStateStore {
|
|
|
450
462
|
stateChangeCount: 0,
|
|
451
463
|
isPersisted: options?.isPersisted ?? false,
|
|
452
464
|
persistName: options?.persistName,
|
|
465
|
+
rehydrate: options?.rehydrate,
|
|
453
466
|
color: getStoreColor(name)
|
|
454
467
|
};
|
|
455
468
|
this.stores.set(name, storeInfo);
|
|
@@ -40,7 +40,8 @@ export function ZustandActionButton({
|
|
|
40
40
|
onPress,
|
|
41
41
|
text,
|
|
42
42
|
type,
|
|
43
|
-
disabled = false
|
|
43
|
+
disabled = false,
|
|
44
|
+
testID
|
|
44
45
|
}) {
|
|
45
46
|
const config = buttonConfigs[type];
|
|
46
47
|
return /*#__PURE__*/_jsxs(TouchableOpacity, {
|
|
@@ -52,6 +53,7 @@ export function ZustandActionButton({
|
|
|
52
53
|
opacity: disabled ? 0.5 : 1
|
|
53
54
|
}],
|
|
54
55
|
activeOpacity: 0.7,
|
|
56
|
+
testID: testID,
|
|
55
57
|
accessibilityRole: "button",
|
|
56
58
|
accessibilityLabel: text,
|
|
57
59
|
accessibilityState: {
|
|
@@ -48,6 +48,13 @@ export const ZustandDetailViewToggle = /*#__PURE__*/memo(function ZustandDetailV
|
|
|
48
48
|
onPress: () => !isDisabled && onViewChange(config.key),
|
|
49
49
|
activeOpacity: isDisabled ? 1 : 0.8,
|
|
50
50
|
disabled: isDisabled,
|
|
51
|
+
testID: `buoy-zustand-view-${config.key}`,
|
|
52
|
+
accessibilityRole: "button",
|
|
53
|
+
accessibilityLabel: config.label,
|
|
54
|
+
accessibilityState: {
|
|
55
|
+
disabled: isDisabled,
|
|
56
|
+
selected: isActive
|
|
57
|
+
},
|
|
51
58
|
children: [/*#__PURE__*/_jsxs(View, {
|
|
52
59
|
style: styles.cardContent,
|
|
53
60
|
children: [/*#__PURE__*/_jsx(IconComponent, {
|
|
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|
|
4
4
|
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from "react-native";
|
|
5
5
|
import { Filter, X, SectionHeader, AddFilterButton, AddFilterInput, night } from "@buoy-gg/shared-ui";
|
|
6
6
|
import { zustandStateStore } from "../utils/zustandStateStore";
|
|
7
|
+
import { ZUSTAND_TEST_ID, slugForTestID } from "../utils/testIDs";
|
|
7
8
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
8
9
|
/** Returns the store color for a given pattern if it exactly matches a registered store name */
|
|
9
10
|
function getStoreColorForPattern(pattern, stores) {
|
|
@@ -35,6 +36,7 @@ export function ZustandEventFilterView({
|
|
|
35
36
|
style: styles.container,
|
|
36
37
|
contentContainerStyle: styles.scrollContent,
|
|
37
38
|
showsVerticalScrollIndicator: false,
|
|
39
|
+
testID: `${ZUSTAND_TEST_ID}-filters-scroll`,
|
|
38
40
|
children: [stores.length > 0 && /*#__PURE__*/_jsxs(View, {
|
|
39
41
|
style: styles.section,
|
|
40
42
|
children: [/*#__PURE__*/_jsxs(SectionHeader, {
|
|
@@ -42,7 +44,8 @@ export function ZustandEventFilterView({
|
|
|
42
44
|
children: "REGISTERED STORES"
|
|
43
45
|
}), /*#__PURE__*/_jsx(SectionHeader.Badge, {
|
|
44
46
|
count: stores.length,
|
|
45
|
-
color: night.color.accent
|
|
47
|
+
color: night.color.accent,
|
|
48
|
+
testID: `${ZUSTAND_TEST_ID}-filters-store-count`
|
|
46
49
|
})]
|
|
47
50
|
}), /*#__PURE__*/_jsx(ScrollView, {
|
|
48
51
|
style: styles.storeList,
|
|
@@ -56,6 +59,12 @@ export function ZustandEventFilterView({
|
|
|
56
59
|
style: [styles.storeRow, filtered && styles.storeRowFiltered],
|
|
57
60
|
onPress: () => onAddPattern(store.name),
|
|
58
61
|
disabled: filtered,
|
|
62
|
+
testID: `${ZUSTAND_TEST_ID}-filters-store-${slugForTestID(store.name)}`,
|
|
63
|
+
accessibilityRole: "button",
|
|
64
|
+
accessibilityLabel: `Hide ${store.name}`,
|
|
65
|
+
accessibilityState: {
|
|
66
|
+
disabled: filtered
|
|
67
|
+
},
|
|
59
68
|
children: [/*#__PURE__*/_jsx(View, {
|
|
60
69
|
style: [styles.storeDot, {
|
|
61
70
|
backgroundColor: filtered ? night.color.textTertiary : storeColor
|
|
@@ -89,14 +98,16 @@ export function ZustandEventFilterView({
|
|
|
89
98
|
children: "STORE NAME FILTERS"
|
|
90
99
|
}), ignoredPatterns.size > 0 && /*#__PURE__*/_jsx(SectionHeader.Badge, {
|
|
91
100
|
count: ignoredPatterns.size,
|
|
92
|
-
color: night.color.accent
|
|
101
|
+
color: night.color.accent,
|
|
102
|
+
testID: `${ZUSTAND_TEST_ID}-filters-count`
|
|
93
103
|
})]
|
|
94
104
|
}), /*#__PURE__*/_jsx(View, {
|
|
95
105
|
style: styles.addInputWrapper,
|
|
96
106
|
children: !showAddInput ? /*#__PURE__*/_jsx(AddFilterButton, {
|
|
97
107
|
onPress: () => setShowAddInput(true),
|
|
98
108
|
color: night.color.accent,
|
|
99
|
-
label: "Add store name pattern"
|
|
109
|
+
label: "Add store name pattern",
|
|
110
|
+
testID: `${ZUSTAND_TEST_ID}-filters-add-open`
|
|
100
111
|
}) : /*#__PURE__*/_jsx(AddFilterInput, {
|
|
101
112
|
value: inputValue,
|
|
102
113
|
onChange: setInputValue,
|
|
@@ -106,7 +117,8 @@ export function ZustandEventFilterView({
|
|
|
106
117
|
setInputValue("");
|
|
107
118
|
},
|
|
108
119
|
placeholder: "Enter store name pattern to hide...",
|
|
109
|
-
color: night.color.accent
|
|
120
|
+
color: night.color.accent,
|
|
121
|
+
testID: `${ZUSTAND_TEST_ID}-filters-add-input`
|
|
110
122
|
})
|
|
111
123
|
}), ignoredPatterns.size > 0 && /*#__PURE__*/_jsx(View, {
|
|
112
124
|
style: styles.patternList,
|
|
@@ -116,6 +128,9 @@ export function ZustandEventFilterView({
|
|
|
116
128
|
style: styles.patternRow,
|
|
117
129
|
onPress: () => onTogglePattern(pattern),
|
|
118
130
|
activeOpacity: 0.8,
|
|
131
|
+
testID: `${ZUSTAND_TEST_ID}-filters-chip-${slugForTestID(pattern)}`,
|
|
132
|
+
accessibilityRole: "button",
|
|
133
|
+
accessibilityLabel: `Remove filter ${pattern}`,
|
|
119
134
|
children: [storeColor ? /*#__PURE__*/_jsx(View, {
|
|
120
135
|
style: [styles.storeDot, {
|
|
121
136
|
backgroundColor: storeColor
|