@multiplatform.one/frappe 7.7.0 → 7.7.1
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/cjs/fixtureDb.cjs +8 -0
- package/dist/cjs/fixtureDb.native.js +10 -0
- package/dist/cjs/fixtureDb.native.js.map +1 -1
- package/dist/cjs/sync/virtualPageManager.cjs +2 -1
- package/dist/cjs/sync/virtualPageManager.native.js +4 -1
- package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
- package/dist/cjs/useLiveQuery.cjs +2 -8
- package/dist/cjs/useLiveQuery.native.js +2 -9
- package/dist/cjs/useLiveQuery.native.js.map +1 -1
- package/dist/esm/fixtureDb.mjs +8 -0
- package/dist/esm/fixtureDb.mjs.map +1 -1
- package/dist/esm/fixtureDb.native.js +10 -0
- package/dist/esm/fixtureDb.native.js.map +1 -1
- package/dist/esm/sync/virtualPageManager.mjs +2 -1
- package/dist/esm/sync/virtualPageManager.mjs.map +1 -1
- package/dist/esm/sync/virtualPageManager.native.js +4 -1
- package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
- package/dist/esm/useLiveQuery.mjs +2 -7
- package/dist/esm/useLiveQuery.mjs.map +1 -1
- package/dist/esm/useLiveQuery.native.js +2 -8
- package/dist/esm/useLiveQuery.native.js.map +1 -1
- package/dist/jsx/fixtureDb.mjs +8 -0
- package/dist/jsx/fixtureDb.mjs.map +1 -1
- package/dist/jsx/fixtureDb.native.js +10 -0
- package/dist/jsx/fixtureDb.native.js.map +1 -1
- package/dist/jsx/sync/virtualPageManager.mjs +2 -1
- package/dist/jsx/sync/virtualPageManager.mjs.map +1 -1
- package/dist/jsx/sync/virtualPageManager.native.js +4 -1
- package/dist/jsx/sync/virtualPageManager.native.js.map +1 -1
- package/dist/jsx/useLiveQuery.mjs +2 -7
- package/dist/jsx/useLiveQuery.mjs.map +1 -1
- package/dist/jsx/useLiveQuery.native.js +2 -9
- package/dist/jsx/useLiveQuery.native.js.map +1 -1
- package/package.json +7 -6
- package/src/devtools/FrappeDevtoolsPanel.spec.tsx +270 -0
- package/src/devtools/FrappeDevtoolsPanel.stories.tsx +233 -0
- package/src/devtools/plugin.spec.tsx +91 -0
- package/src/devtools/plugin.stories.tsx +77 -0
- package/src/fixtureDb.spec.ts +126 -0
- package/src/fixtureDb.ts +16 -0
- package/src/sync/virtualPageManager.spec.ts +62 -0
- package/src/sync/virtualPageManager.ts +8 -2
- package/src/useFrappeCollection.stories.tsx +166 -0
- package/src/useFrappeInfiniteList.stories.tsx +104 -0
- package/src/useFrappePendingMutations.spec.ts +241 -0
- package/src/useFrappePendingMutations.stories.tsx +102 -0
- package/src/useLiveQuery.spec.ts +43 -0
- package/src/useLiveQuery.ts +16 -15
- package/types/fixtureDb.d.ts.map +1 -1
- package/types/sync/virtualPageManager.d.ts.map +1 -1
- package/types/useLiveQuery.d.ts.map +1 -1
|
@@ -27,8 +27,8 @@ __export(useLiveQuery_exports, {
|
|
|
27
27
|
useLiveQuery: () => useLiveQuery
|
|
28
28
|
});
|
|
29
29
|
module.exports = __toCommonJS(useLiveQuery_exports);
|
|
30
|
+
var import_platform = require("@multiplatform.one/platform");
|
|
30
31
|
var import_react_db = require("@tanstack/react-db");
|
|
31
|
-
const import_meta = {};
|
|
32
32
|
var SSR_LIVE_QUERY_RESULT = Object.freeze({
|
|
33
33
|
state: void 0,
|
|
34
34
|
data: void 0,
|
|
@@ -41,14 +41,7 @@ var SSR_LIVE_QUERY_RESULT = Object.freeze({
|
|
|
41
41
|
isCleanedUp: false,
|
|
42
42
|
isEnabled: true
|
|
43
43
|
});
|
|
44
|
-
var IS_SSR =
|
|
45
|
-
try {
|
|
46
|
-
var _env;
|
|
47
|
-
return ((_env = import_meta.env) === null || _env === void 0 ? void 0 : _env.SSR) === true;
|
|
48
|
-
} catch (unused) {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}();
|
|
44
|
+
var IS_SSR = import_platform.isServer;
|
|
52
45
|
var useLiveQuery = function useLiveQuery2() {
|
|
53
46
|
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
|
|
54
47
|
args[_key] = arguments[_key];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["__defProp","Object","defineProperty","__getOwnPropDesc","getOwnPropertyDescriptor","__getOwnPropNames","getOwnPropertyNames","__hasOwnProp","prototype","hasOwnProperty","__export","target","all","name","get","enumerable","__copyProps","to","from","except","desc","key","call","__toCommonJS","mod","value","useLiveQuery_exports"
|
|
1
|
+
{"version":3,"names":["__defProp","Object","defineProperty","__getOwnPropDesc","getOwnPropertyDescriptor","__getOwnPropNames","getOwnPropertyNames","__hasOwnProp","prototype","hasOwnProperty","__export","target","all","name","get","enumerable","__copyProps","to","from","except","desc","key","call","__toCommonJS","mod","value","useLiveQuery_exports"],"sources":["../../src/useLiveQuery.ts"],"sourcesContent":[null],"mappings":"AAAA,YAAS;;AACT,IAAAA,SAAS,GAAAC,MAAA,CAAAC,cAAgB;AAOzB,IAAAC,gBAAM,GAAAF,MAAA,CAAwBG,wBAAc;AAAA,IAC1CC,iBAAO,GAAAJ,MAAA,CAAAK,mBAAA;AAAA,IACPC,YAAM,GAAAN,MAAA,CAAAO,SAAA,CAAAC,cAAA;AAAA,IACNC,QAAA,GAAYA,CAAAC,MAAA,EAAAC,GAAA;EACZ,SAAQC,IAAA,IAAAD,GAAA,EACRZ,SAAW,CAAAW,MAAA,EAAAE,IAAA;IAAAC,GAAA,EAAAF,GAAA,CAAAC,IAAA;IAAAE,UAAA;EAAA;AAAA;AACF,IACTC,WAAQ,GAAAA,CAAAC,EAAA,EAAAC,IAAA,EAAAC,MAAA,EAAAC,IAAA;EACR,IAAAF,IAAA,IAAS,OAAAA,IAAA,wBAAAA,IAAA;IACT,SAAAG,GAAa,IAAAhB,iBAAA,CAAAa,IAAA,GACb,KAAAX,YAAW,CAAAe,IAAA,CAAAL,EAAA,EAAAI,GAAA,KAAAA,GAAA,KAAAF,MAAA,EACZnB,SAAA,CAAAiB,EAAA,EAAAI,GAAA;MAAAP,GAAA,EAAAA,CAAA,KAAAI,IAAA,CAAAG,GAAA;MAAAN,UAAA,IAAAK,IAAA,GAAAjB,gBAAA,CAAAe,IAAA,EAAAG,GAAA,MAAAD,IAAA,CAAAL;IAAA;EAoBD;EAeO,OAAME,EAAA;AACX;AAEA,IAAAM,YAAQ,GAAAC,GAAA,IAAqDR,WAAO,CAAAhB,SAAA;EAAAyB,KAAA;AAAA,IAAAD,GAAA;AACtE,IAAAE,oBAAA","ignoreList":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/frappe",
|
|
3
|
-
"version": "7.7.
|
|
3
|
+
"version": "7.7.1",
|
|
4
4
|
"description": "frappe client for multiplatform.one ecosystem",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"auth",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@tanstack/react-pacer": "0.22.0",
|
|
67
67
|
"broadcast-channel": "^7.3.0",
|
|
68
68
|
"socket.io-client": "^4.8.3",
|
|
69
|
-
"@multiplatform.one/store": "7.7.
|
|
69
|
+
"@multiplatform.one/store": "7.7.1"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@tamagui/build": "2.7.6",
|
|
@@ -76,15 +76,16 @@
|
|
|
76
76
|
"@types/lodash.startcase": "^4.4.9",
|
|
77
77
|
"@vitejs/plugin-react": "^6.0.1",
|
|
78
78
|
"react": "19.2.5",
|
|
79
|
+
"tamagui": "2.7.6",
|
|
79
80
|
"typescript": "~5.9.3",
|
|
80
81
|
"vitest": "^4.1.5",
|
|
81
|
-
"@multiplatform.one/
|
|
82
|
-
"@multiplatform.one/
|
|
83
|
-
"@multiplatform.one/
|
|
82
|
+
"@multiplatform.one/platform": "7.7.1",
|
|
83
|
+
"@multiplatform.one/test-utils": "7.7.1",
|
|
84
|
+
"@multiplatform.one/config": "7.7.1"
|
|
84
85
|
},
|
|
85
86
|
"peerDependencies": {
|
|
86
87
|
"react": "^19.1.0",
|
|
87
|
-
"@multiplatform.one/platform": "^7.7.
|
|
88
|
+
"@multiplatform.one/platform": "^7.7.1"
|
|
88
89
|
},
|
|
89
90
|
"engines": {
|
|
90
91
|
"node": ">=6.0.0"
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FrappeDevtoolsPanel contract specs.
|
|
3
|
+
*
|
|
4
|
+
* The panel is plain DOM inside the TanStack Devtools shell, so its whole
|
|
5
|
+
* contract is: read SyncModule.__debug(), repaint on every subscribeStore
|
|
6
|
+
* emit, and paint the palette the shell's theme prop names. Nothing here
|
|
7
|
+
* touches the sync engine — a stub SyncModule exposing just those two members
|
|
8
|
+
* is the entire surface the panel consumes, which keeps the spec hermetic and
|
|
9
|
+
* lets a test drive the panel into states a live engine reaches only by
|
|
10
|
+
* timing (a stale checkpoint, a failed mutation, a lost socket).
|
|
11
|
+
*/
|
|
12
|
+
import { cleanup, fireEvent, render, screen, act } from "@testing-library/react";
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
import type { SyncModule } from "../sync";
|
|
15
|
+
import type { FrappeDebugState } from "../sync/types";
|
|
16
|
+
import { Operation, Status } from "../sync/types";
|
|
17
|
+
import { themes } from "./theme";
|
|
18
|
+
import { FrappeDevtoolsPanel } from "./FrappeDevtoolsPanel";
|
|
19
|
+
|
|
20
|
+
afterEach(cleanup);
|
|
21
|
+
|
|
22
|
+
function emptyDebugState(): FrappeDebugState {
|
|
23
|
+
return {
|
|
24
|
+
subscriptions: [],
|
|
25
|
+
normalizedCache: { size: 0, doctypes: [] },
|
|
26
|
+
mutationJournal: { pending: [], confirmed: [], failed: [] },
|
|
27
|
+
connection: { socketConnected: false, isLeader: false, isOnline: true },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function populatedDebugState(): FrappeDebugState {
|
|
32
|
+
return {
|
|
33
|
+
subscriptions: [
|
|
34
|
+
{
|
|
35
|
+
id: "sub-todo",
|
|
36
|
+
query: { doctype: "ToDo" } as FrappeDebugState["subscriptions"][number]["query"],
|
|
37
|
+
docCount: 8,
|
|
38
|
+
tombstoneCount: 1,
|
|
39
|
+
checkpoint: new Date().toISOString(),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "sub-note",
|
|
43
|
+
query: { doctype: "Note" } as FrappeDebugState["subscriptions"][number]["query"],
|
|
44
|
+
docCount: 3,
|
|
45
|
+
tombstoneCount: 0,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
normalizedCache: {
|
|
49
|
+
size: 11,
|
|
50
|
+
doctypes: [
|
|
51
|
+
{ doctype: "ToDo", count: 8 },
|
|
52
|
+
{ doctype: "Note", count: 3 },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
mutationJournal: {
|
|
56
|
+
pending: [
|
|
57
|
+
{
|
|
58
|
+
id: "m-pending",
|
|
59
|
+
operation: Operation.Update,
|
|
60
|
+
doctype: "ToDo",
|
|
61
|
+
docname: "TODO-HANGS",
|
|
62
|
+
status: Status.Pending,
|
|
63
|
+
createdAt: Date.now(),
|
|
64
|
+
retryCount: 0,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
confirmed: [
|
|
68
|
+
{
|
|
69
|
+
id: "m-confirmed",
|
|
70
|
+
operation: Operation.Update,
|
|
71
|
+
doctype: "ToDo",
|
|
72
|
+
docname: "TODO-0001",
|
|
73
|
+
status: Status.Confirmed,
|
|
74
|
+
createdAt: Date.now(),
|
|
75
|
+
retryCount: 0,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
failed: [
|
|
79
|
+
{
|
|
80
|
+
id: "m-failed",
|
|
81
|
+
operation: Operation.Update,
|
|
82
|
+
doctype: "ToDo",
|
|
83
|
+
docname: "TODO-REJECTS",
|
|
84
|
+
status: Status.Failed,
|
|
85
|
+
createdAt: Date.now(),
|
|
86
|
+
retryCount: 2,
|
|
87
|
+
error: "ValidationError: TODO-REJECTS is locked",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
connection: { socketConnected: true, isLeader: true, isOnline: true },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A SyncModule stub with only what the panel calls. `emit()` drives the
|
|
97
|
+
* subscribeStore listener the way the real store does on every write.
|
|
98
|
+
*/
|
|
99
|
+
function stubSyncModule(initial: FrappeDebugState) {
|
|
100
|
+
let state = initial;
|
|
101
|
+
const listeners = new Set<() => void>();
|
|
102
|
+
const unsubscribe = vi.fn();
|
|
103
|
+
const module = {
|
|
104
|
+
__debug: vi.fn(() => state),
|
|
105
|
+
subscribeStore: vi.fn((listener: () => void) => {
|
|
106
|
+
listeners.add(listener);
|
|
107
|
+
return () => {
|
|
108
|
+
listeners.delete(listener);
|
|
109
|
+
unsubscribe();
|
|
110
|
+
};
|
|
111
|
+
}),
|
|
112
|
+
} as unknown as SyncModule;
|
|
113
|
+
return {
|
|
114
|
+
module,
|
|
115
|
+
unsubscribe,
|
|
116
|
+
set(next: FrappeDebugState) {
|
|
117
|
+
state = next;
|
|
118
|
+
act(() => {
|
|
119
|
+
for (const listener of listeners) listener();
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
listenerCount: () => listeners.size,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe("FrappeDevtoolsPanel reads __debug()", () => {
|
|
127
|
+
it("paints the subscriptions the debug snapshot names", () => {
|
|
128
|
+
const stub = stubSyncModule(populatedDebugState());
|
|
129
|
+
const { container } = render(
|
|
130
|
+
<FrappeDevtoolsPanel syncModule={stub.module} devtoolsTheme="light" />,
|
|
131
|
+
);
|
|
132
|
+
expect(stub.module.__debug).toHaveBeenCalled();
|
|
133
|
+
expect(container.textContent).toContain("ToDo");
|
|
134
|
+
expect(container.textContent).toContain("Note");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("an empty snapshot still paints the shell, not a blank div", () => {
|
|
138
|
+
const stub = stubSyncModule(emptyDebugState());
|
|
139
|
+
const { container } = render(
|
|
140
|
+
<FrappeDevtoolsPanel syncModule={stub.module} devtoolsTheme="light" />,
|
|
141
|
+
);
|
|
142
|
+
expect(container.firstElementChild).toBeTruthy();
|
|
143
|
+
expect(container.textContent).toContain("Pages");
|
|
144
|
+
expect(container.textContent).toContain("Cache");
|
|
145
|
+
expect(container.textContent).toContain("Mutations");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("with no subscription selected the Pages tab asks for one", () => {
|
|
149
|
+
const stub = stubSyncModule(populatedDebugState());
|
|
150
|
+
const { container } = render(
|
|
151
|
+
<FrappeDevtoolsPanel syncModule={stub.module} devtoolsTheme="light" />,
|
|
152
|
+
);
|
|
153
|
+
expect(container.textContent).toContain("Select a subscription");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("FrappeDevtoolsPanel repaints on store emits", () => {
|
|
158
|
+
it("subscribes once and re-reads __debug() on every emit", () => {
|
|
159
|
+
const stub = stubSyncModule(emptyDebugState());
|
|
160
|
+
render(<FrappeDevtoolsPanel syncModule={stub.module} devtoolsTheme="light" />);
|
|
161
|
+
expect(stub.listenerCount()).toBe(1);
|
|
162
|
+
const before = (stub.module.__debug as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
163
|
+
stub.set(populatedDebugState());
|
|
164
|
+
expect((stub.module.__debug as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(
|
|
165
|
+
before,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("a subscription that appears after mount reaches the sidebar", () => {
|
|
170
|
+
const stub = stubSyncModule(emptyDebugState());
|
|
171
|
+
const { container } = render(
|
|
172
|
+
<FrappeDevtoolsPanel syncModule={stub.module} devtoolsTheme="light" />,
|
|
173
|
+
);
|
|
174
|
+
expect(container.textContent).not.toContain("Note");
|
|
175
|
+
stub.set(populatedDebugState());
|
|
176
|
+
expect(container.textContent).toContain("Note");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("unsubscribes from the store on unmount", () => {
|
|
180
|
+
const stub = stubSyncModule(emptyDebugState());
|
|
181
|
+
const { unmount } = render(
|
|
182
|
+
<FrappeDevtoolsPanel syncModule={stub.module} devtoolsTheme="light" />,
|
|
183
|
+
);
|
|
184
|
+
expect(stub.listenerCount()).toBe(1);
|
|
185
|
+
unmount();
|
|
186
|
+
expect(stub.listenerCount()).toBe(0);
|
|
187
|
+
expect(stub.unsubscribe).toHaveBeenCalledTimes(1);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("FrappeDevtoolsPanel tabs", () => {
|
|
192
|
+
it("the Cache tab lists the cached doctypes and their counts", () => {
|
|
193
|
+
const stub = stubSyncModule(populatedDebugState());
|
|
194
|
+
const { container } = render(
|
|
195
|
+
<FrappeDevtoolsPanel syncModule={stub.module} devtoolsTheme="light" />,
|
|
196
|
+
);
|
|
197
|
+
fireEvent.click(screen.getByText("Cache"));
|
|
198
|
+
expect(container.textContent).toContain("ToDo");
|
|
199
|
+
expect(container.textContent).toContain("8");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("the Mutations tab lists every journal state under All", () => {
|
|
203
|
+
const stub = stubSyncModule(populatedDebugState());
|
|
204
|
+
const { container } = render(
|
|
205
|
+
<FrappeDevtoolsPanel syncModule={stub.module} devtoolsTheme="light" />,
|
|
206
|
+
);
|
|
207
|
+
fireEvent.click(screen.getByText("Mutations"));
|
|
208
|
+
expect(container.textContent).toContain("TODO-HANGS");
|
|
209
|
+
expect(container.textContent).toContain("TODO-0001");
|
|
210
|
+
expect(container.textContent).toContain("TODO-REJECTS");
|
|
211
|
+
expect(container.textContent).toContain("Failed (1)");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("a failed entry keeps its error message collapsed until the row is clicked", () => {
|
|
215
|
+
const stub = stubSyncModule(populatedDebugState());
|
|
216
|
+
const { container } = render(
|
|
217
|
+
<FrappeDevtoolsPanel syncModule={stub.module} devtoolsTheme="light" />,
|
|
218
|
+
);
|
|
219
|
+
fireEvent.click(screen.getByText("Mutations"));
|
|
220
|
+
expect(container.textContent).not.toContain("locked");
|
|
221
|
+
fireEvent.click(screen.getByText("TODO-REJECTS"));
|
|
222
|
+
expect(container.textContent).toContain("locked");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("the Failed filter drops the pending and confirmed rows", () => {
|
|
226
|
+
const stub = stubSyncModule(populatedDebugState());
|
|
227
|
+
const { container } = render(
|
|
228
|
+
<FrappeDevtoolsPanel syncModule={stub.module} devtoolsTheme="light" />,
|
|
229
|
+
);
|
|
230
|
+
fireEvent.click(screen.getByText("Mutations"));
|
|
231
|
+
fireEvent.click(screen.getByText("Failed (1)"));
|
|
232
|
+
expect(container.textContent).toContain("TODO-REJECTS");
|
|
233
|
+
expect(container.textContent).not.toContain("TODO-HANGS");
|
|
234
|
+
expect(container.textContent).not.toContain("TODO-0001");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("selecting a subscription moves the Pages tab off the placeholder", () => {
|
|
238
|
+
const stub = stubSyncModule(populatedDebugState());
|
|
239
|
+
const { container } = render(
|
|
240
|
+
<FrappeDevtoolsPanel syncModule={stub.module} devtoolsTheme="light" />,
|
|
241
|
+
);
|
|
242
|
+
fireEvent.click(screen.getByText("ToDo"));
|
|
243
|
+
expect(container.textContent).not.toContain("Select a subscription");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("FrappeDevtoolsPanel theme prop", () => {
|
|
248
|
+
it("light and dark paint different backgrounds, and each is its palette's bg", () => {
|
|
249
|
+
const light = render(
|
|
250
|
+
<FrappeDevtoolsPanel
|
|
251
|
+
syncModule={stubSyncModule(emptyDebugState()).module}
|
|
252
|
+
devtoolsTheme="light"
|
|
253
|
+
/>,
|
|
254
|
+
);
|
|
255
|
+
const lightBg = (light.container.firstElementChild as HTMLElement).style.backgroundColor;
|
|
256
|
+
cleanup();
|
|
257
|
+
const dark = render(
|
|
258
|
+
<FrappeDevtoolsPanel
|
|
259
|
+
syncModule={stubSyncModule(emptyDebugState()).module}
|
|
260
|
+
devtoolsTheme="dark"
|
|
261
|
+
/>,
|
|
262
|
+
);
|
|
263
|
+
const darkBg = (dark.container.firstElementChild as HTMLElement).style.backgroundColor;
|
|
264
|
+
expect(lightBg).not.toBe(darkBg);
|
|
265
|
+
expect(lightBg).toBeTruthy();
|
|
266
|
+
expect(darkBg).toBeTruthy();
|
|
267
|
+
// The panel owns its own palette (devtools/theme.ts); it never reads the app knobs.
|
|
268
|
+
expect(Object.keys(themes)).toEqual(["light", "dark"]);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Button, isWeb, Text, XStack, YStack } from "tamagui";
|
|
3
|
+
import {
|
|
4
|
+
HANGING_TODO,
|
|
5
|
+
REJECTED_TODO,
|
|
6
|
+
type ToDo,
|
|
7
|
+
type ToDoStatus,
|
|
8
|
+
toDoFixtureData,
|
|
9
|
+
useSeededJournal,
|
|
10
|
+
useStoryConfig,
|
|
11
|
+
} from "../../tests/storyFixtures";
|
|
12
|
+
import { useFrappeCollection } from "../frappeReact";
|
|
13
|
+
import type { SyncModule } from "../sync";
|
|
14
|
+
import type { FrappeDebugState } from "../sync/types";
|
|
15
|
+
import { useLiveQuery } from "../useLiveQuery";
|
|
16
|
+
import { FrappeDevtoolsPanel } from "./FrappeDevtoolsPanel";
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
title: "Frappe/FrappeDevtoolsPanel",
|
|
20
|
+
component: FrappeDevtoolsPanel,
|
|
21
|
+
tags: ["!test"],
|
|
22
|
+
parameters: {
|
|
23
|
+
status: { type: "stable" },
|
|
24
|
+
docs: {
|
|
25
|
+
description: {
|
|
26
|
+
component:
|
|
27
|
+
"The Frappe tab of the TanStack Devtools shell: subscriptions in the sidebar, Pages / Cache / Mutations tabs and a stats bar, all read from SyncModule.__debug() and refreshed on every store emit. Plain DOM with its own light and dark palette (devtools/theme.ts), so it follows the shell's theme prop, never the app knobs. Stories are network-hermetic (G-15/D-05): the SyncModule runs over an InMemoryFixtureProvider, one ToDo subscription is opened through useFrappeCollection, and two scripted documents put a Pending and a Failed entry in the journal next to a Confirmed one. Nothing is fetched. On native the panel has no DOM to render into, so the story prints the same debug counts as text.",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
argTypes: {
|
|
32
|
+
devtoolsTheme: { control: "radio", options: ["light", "dark"] },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type DevtoolsTheme = "light" | "dark";
|
|
37
|
+
|
|
38
|
+
function useDebugState(syncModule: SyncModule | undefined): FrappeDebugState | undefined {
|
|
39
|
+
const [state, setState] = useState<FrappeDebugState | undefined>(undefined);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!syncModule) return;
|
|
42
|
+
setState(syncModule.__debug());
|
|
43
|
+
return syncModule.subscribeStore(() => setState(syncModule.__debug()));
|
|
44
|
+
}, [syncModule]);
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the panel would show, as text, for platforms without a DOM. */
|
|
49
|
+
function DebugSummary({ syncModule }: { syncModule: SyncModule }) {
|
|
50
|
+
const state = useDebugState(syncModule);
|
|
51
|
+
if (!state) return null;
|
|
52
|
+
const { subscriptions, normalizedCache, mutationJournal } = state;
|
|
53
|
+
return (
|
|
54
|
+
<YStack gap="$1" data-testid="devtools-summary">
|
|
55
|
+
<Text fontSize="$2">
|
|
56
|
+
subscriptions {subscriptions.length}
|
|
57
|
+
{subscriptions.map((sub) => ` · ${sub.query.doctype} (${sub.docCount} docs)`).join("")}
|
|
58
|
+
</Text>
|
|
59
|
+
<Text fontSize="$2">
|
|
60
|
+
cache {normalizedCache.size} docs
|
|
61
|
+
{normalizedCache.doctypes.map((d) => ` · ${d.doctype} ${d.count}`).join("")}
|
|
62
|
+
</Text>
|
|
63
|
+
<Text fontSize="$2">
|
|
64
|
+
mutations pending {mutationJournal.pending.length} · confirmed{" "}
|
|
65
|
+
{mutationJournal.confirmed.length} · failed {mutationJournal.failed.length}
|
|
66
|
+
</Text>
|
|
67
|
+
</YStack>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function PanelFrame({
|
|
72
|
+
syncModule,
|
|
73
|
+
devtoolsTheme,
|
|
74
|
+
height = 440,
|
|
75
|
+
}: {
|
|
76
|
+
syncModule: SyncModule;
|
|
77
|
+
devtoolsTheme: DevtoolsTheme;
|
|
78
|
+
height?: number;
|
|
79
|
+
}) {
|
|
80
|
+
if (!isWeb) {
|
|
81
|
+
return (
|
|
82
|
+
<YStack gap="$2">
|
|
83
|
+
<Text fontSize="$2" color="$color10">
|
|
84
|
+
FrappeDevtoolsPanel is plain DOM inside the TanStack Devtools shell, which only exists on
|
|
85
|
+
web. The SyncModule behind it is live here too:
|
|
86
|
+
</Text>
|
|
87
|
+
<DebugSummary syncModule={syncModule} />
|
|
88
|
+
</YStack>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
data-testid="devtools-panel-host"
|
|
94
|
+
style={{
|
|
95
|
+
height,
|
|
96
|
+
borderRadius: 6,
|
|
97
|
+
overflow: "hidden",
|
|
98
|
+
border: "1px solid rgba(128, 128, 128, 0.35)",
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<FrappeDevtoolsPanel syncModule={syncModule} devtoolsTheme={devtoolsTheme} />
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface HostProps {
|
|
107
|
+
devtoolsTheme: DevtoolsTheme;
|
|
108
|
+
/** Open the ToDo subscription (a sidebar row and cached documents). */
|
|
109
|
+
subscribe?: boolean;
|
|
110
|
+
/** Seed one Confirmed, one Pending and one Failed journal entry once the subscription is ready. */
|
|
111
|
+
seed?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function PanelHost({ devtoolsTheme, subscribe = true, seed = true }: HostProps) {
|
|
115
|
+
const config = useStoryConfig(toDoFixtureData());
|
|
116
|
+
const collection = useFrappeCollection<ToDo>(subscribe ? config : undefined, "ToDo");
|
|
117
|
+
const { isReady } = useLiveQuery(
|
|
118
|
+
(q) => (collection ? q.from({ todo: collection }) : null),
|
|
119
|
+
[collection],
|
|
120
|
+
);
|
|
121
|
+
const syncModule = useSeededJournal(config, Boolean(isReady), seed);
|
|
122
|
+
if (!syncModule) {
|
|
123
|
+
return (
|
|
124
|
+
<Text fontSize="$2">
|
|
125
|
+
No SyncModule: getFrappeSyncModule() returns undefined during server rendering.
|
|
126
|
+
</Text>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return <PanelFrame syncModule={syncModule} devtoolsTheme={devtoolsTheme} />;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const nextStatus: Record<ToDoStatus, ToDoStatus> = {
|
|
133
|
+
Open: "Working",
|
|
134
|
+
Working: "Completed",
|
|
135
|
+
Completed: "Open",
|
|
136
|
+
Cancelled: "Open",
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function swallow(tx: { isPersisted: { promise: Promise<unknown> } }) {
|
|
140
|
+
// A rejected write is reported by the journal, not by an unhandled rejection.
|
|
141
|
+
tx.isPersisted.promise.catch(() => {});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Drives the collection from buttons so the panel's tabs move while you watch. */
|
|
145
|
+
function InteractiveHost() {
|
|
146
|
+
const config = useStoryConfig(toDoFixtureData(5));
|
|
147
|
+
const collection = useFrappeCollection<ToDo>(config, "ToDo");
|
|
148
|
+
const { data, isReady } = useLiveQuery(
|
|
149
|
+
(q) => (collection ? q.from({ todo: collection }) : null),
|
|
150
|
+
[collection],
|
|
151
|
+
);
|
|
152
|
+
const syncModule = useSeededJournal(config, Boolean(isReady), false);
|
|
153
|
+
const [devtoolsTheme, setDevtoolsTheme] = useState<DevtoolsTheme>("light");
|
|
154
|
+
const [inserted, setInserted] = useState(0);
|
|
155
|
+
const rows = (data as ToDo[] | undefined) ?? [];
|
|
156
|
+
|
|
157
|
+
const update = (name: string) => {
|
|
158
|
+
const row = rows.find((r) => r.name === name);
|
|
159
|
+
if (!collection || !row) return;
|
|
160
|
+
swallow(
|
|
161
|
+
collection.update(name, (draft) => {
|
|
162
|
+
draft.status = nextStatus[row.status];
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
const insert = () => {
|
|
167
|
+
if (!collection) return;
|
|
168
|
+
const n = inserted + 1;
|
|
169
|
+
setInserted(n);
|
|
170
|
+
swallow(
|
|
171
|
+
collection.insert({
|
|
172
|
+
name: `TODO-NEW-${String(n).padStart(3, "0")}`,
|
|
173
|
+
doctype: "ToDo",
|
|
174
|
+
description: `Added from the story (#${n})`,
|
|
175
|
+
status: "Open",
|
|
176
|
+
priority: "Medium",
|
|
177
|
+
date: "2026-09-05",
|
|
178
|
+
modified: "2026-09-05 12:00:00",
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
const removeLast = () => {
|
|
183
|
+
const last = rows[rows.length - 1];
|
|
184
|
+
if (!collection || !last) return;
|
|
185
|
+
swallow(collection.delete(last.name));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (!syncModule) return null;
|
|
189
|
+
return (
|
|
190
|
+
<YStack gap="$3">
|
|
191
|
+
<XStack gap="$2" flexWrap="wrap">
|
|
192
|
+
<Button size="$2" onPress={() => update("TODO-0002")} disabled={!isReady}>
|
|
193
|
+
Update TODO-0002 (confirms)
|
|
194
|
+
</Button>
|
|
195
|
+
<Button size="$2" onPress={() => update(HANGING_TODO)} disabled={!isReady}>
|
|
196
|
+
Update {HANGING_TODO} (stays pending)
|
|
197
|
+
</Button>
|
|
198
|
+
<Button size="$2" onPress={() => update(REJECTED_TODO)} disabled={!isReady}>
|
|
199
|
+
Update {REJECTED_TODO} (fails)
|
|
200
|
+
</Button>
|
|
201
|
+
<Button size="$2" onPress={insert} disabled={!isReady}>
|
|
202
|
+
Insert
|
|
203
|
+
</Button>
|
|
204
|
+
<Button size="$2" onPress={removeLast} disabled={!rows.length}>
|
|
205
|
+
Delete last
|
|
206
|
+
</Button>
|
|
207
|
+
<Button
|
|
208
|
+
size="$2"
|
|
209
|
+
onPress={() => setDevtoolsTheme((t) => (t === "light" ? "dark" : "light"))}
|
|
210
|
+
>
|
|
211
|
+
Theme: {devtoolsTheme}
|
|
212
|
+
</Button>
|
|
213
|
+
</XStack>
|
|
214
|
+
<Text fontSize="$2" color="$color10" data-testid="interactive-rows">
|
|
215
|
+
{rows.map((r) => `${r.name}=${r.status}`).join(" · ") || "no rows yet"}
|
|
216
|
+
</Text>
|
|
217
|
+
<PanelFrame syncModule={syncModule} devtoolsTheme={devtoolsTheme} height={400} />
|
|
218
|
+
</YStack>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** One subscription, cached documents, and Confirmed + Pending + Failed journal entries, light palette. */
|
|
223
|
+
export const Main = () => <PanelHost devtoolsTheme="light" />;
|
|
224
|
+
Main.storyName = "Main";
|
|
225
|
+
|
|
226
|
+
/** Same state under the shell's dark theme prop. */
|
|
227
|
+
export const Dark = () => <PanelHost devtoolsTheme="dark" />;
|
|
228
|
+
|
|
229
|
+
/** A SyncModule nobody has subscribed through yet: empty sidebar, empty cache, empty journal. */
|
|
230
|
+
export const Empty = () => <PanelHost devtoolsTheme="light" subscribe={false} seed={false} />;
|
|
231
|
+
|
|
232
|
+
/** Buttons drive inserts, updates and deletes through the collection; watch the tabs move. */
|
|
233
|
+
export const Interactive = () => <InteractiveHost />;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* frappeDevtoolsPlugin contract specs (MPO-8).
|
|
3
|
+
*
|
|
4
|
+
* The plugin is the whole surface the TanStack Devtools shell sees: an id and
|
|
5
|
+
* a name for the tab strip, and a render(hostElement, { theme }) the shell
|
|
6
|
+
* calls when the tab opens. What renders is FrappeDevtoolsPanel bound to the
|
|
7
|
+
* SyncModule the factory closed over — so the contract worth pinning is the
|
|
8
|
+
* binding, not the panel's paint, which FrappeDevtoolsPanel.spec.tsx owns.
|
|
9
|
+
*/
|
|
10
|
+
import { cleanup, render } from "@testing-library/react";
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import type { SyncModule } from "../sync";
|
|
13
|
+
import type { FrappeDebugState } from "../sync/types";
|
|
14
|
+
import { FrappeDevtoolsPanel } from "./FrappeDevtoolsPanel";
|
|
15
|
+
import { frappeDevtoolsPlugin } from "./plugin";
|
|
16
|
+
|
|
17
|
+
afterEach(cleanup);
|
|
18
|
+
|
|
19
|
+
function stubSyncModule(label: string) {
|
|
20
|
+
const state: FrappeDebugState = {
|
|
21
|
+
subscriptions: [
|
|
22
|
+
{
|
|
23
|
+
id: `sub-${label}`,
|
|
24
|
+
query: { doctype: label } as FrappeDebugState["subscriptions"][number]["query"],
|
|
25
|
+
docCount: 1,
|
|
26
|
+
tombstoneCount: 0,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
normalizedCache: { size: 1, doctypes: [{ doctype: label, count: 1 }] },
|
|
30
|
+
mutationJournal: { pending: [], confirmed: [], failed: [] },
|
|
31
|
+
connection: { socketConnected: false, isLeader: false, isOnline: true },
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
__debug: vi.fn(() => state),
|
|
35
|
+
subscribeStore: vi.fn(() => () => {}),
|
|
36
|
+
} as unknown as SyncModule;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("frappeDevtoolsPlugin identity", () => {
|
|
40
|
+
it("is the id and name the shell lists in its tab strip", () => {
|
|
41
|
+
const plugin = frappeDevtoolsPlugin(stubSyncModule("ToDo"));
|
|
42
|
+
expect(plugin.id).toBe("frappe");
|
|
43
|
+
expect(plugin.name).toBe("Frappe");
|
|
44
|
+
expect(typeof plugin.render).toBe("function");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("the identity does not change with the SyncModule it wraps", () => {
|
|
48
|
+
const a = frappeDevtoolsPlugin(stubSyncModule("ToDo"));
|
|
49
|
+
const b = frappeDevtoolsPlugin(stubSyncModule("Note"));
|
|
50
|
+
expect(a.id).toBe(b.id);
|
|
51
|
+
expect(a.name).toBe(b.name);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("frappeDevtoolsPlugin render()", () => {
|
|
56
|
+
it("returns the panel element bound to the module the factory closed over", () => {
|
|
57
|
+
const syncModule = stubSyncModule("ToDo");
|
|
58
|
+
const plugin = frappeDevtoolsPlugin(syncModule);
|
|
59
|
+
const element = plugin.render(document.createElement("div"), { theme: "light" });
|
|
60
|
+
expect(element.type).toBe(FrappeDevtoolsPanel);
|
|
61
|
+
expect(element.props.syncModule).toBe(syncModule);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("forwards the shell's theme prop straight through, both ways", () => {
|
|
65
|
+
const plugin = frappeDevtoolsPlugin(stubSyncModule("ToDo"));
|
|
66
|
+
const host = document.createElement("div");
|
|
67
|
+
expect(plugin.render(host, { theme: "light" }).props.devtoolsTheme).toBe("light");
|
|
68
|
+
expect(plugin.render(host, { theme: "dark" }).props.devtoolsTheme).toBe("dark");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("the returned element actually mounts and paints that module's state", () => {
|
|
72
|
+
const plugin = frappeDevtoolsPlugin(stubSyncModule("Sales Invoice"));
|
|
73
|
+
const { container } = render(plugin.render(document.createElement("div"), { theme: "light" }));
|
|
74
|
+
expect(container.textContent).toContain("Sales Invoice");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("render() does not write into the host element itself — the shell mounts what it returns", () => {
|
|
78
|
+
const plugin = frappeDevtoolsPlugin(stubSyncModule("ToDo"));
|
|
79
|
+
const host = document.createElement("div");
|
|
80
|
+
plugin.render(host, { theme: "light" });
|
|
81
|
+
expect(host.childElementCount).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("two renders of one plugin stay bound to the same module", () => {
|
|
85
|
+
const syncModule = stubSyncModule("ToDo");
|
|
86
|
+
const plugin = frappeDevtoolsPlugin(syncModule);
|
|
87
|
+
const host = document.createElement("div");
|
|
88
|
+
expect(plugin.render(host, { theme: "light" }).props.syncModule).toBe(syncModule);
|
|
89
|
+
expect(plugin.render(host, { theme: "dark" }).props.syncModule).toBe(syncModule);
|
|
90
|
+
});
|
|
91
|
+
});
|