@peers-app/peers-ui 0.8.3 → 0.8.5
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/hooks.d.ts +3 -2
- package/dist/hooks.js +22 -53
- package/dist/hooks.test.d.ts +1 -0
- package/dist/hooks.test.js +247 -0
- package/dist/screens/console-logs/console-logs-list.js +11 -5
- package/dist/screens/console-logs/mobile-log-card.d.ts +8 -0
- package/dist/screens/console-logs/mobile-log-card.js +96 -0
- package/dist/screens/settings/settings-page.js +19 -1
- package/dist/setupTests.js +4 -0
- package/package.json +5 -5
- package/src/hooks.test.tsx +324 -0
- package/src/hooks.ts +23 -51
- package/src/screens/console-logs/console-logs-list.tsx +21 -8
- package/src/screens/console-logs/mobile-log-card.tsx +154 -0
- package/src/screens/settings/settings-page.tsx +27 -0
- package/src/setupTests.ts +5 -0
- package/tsconfig.json +3 -5
package/dist/hooks.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
1
|
import { Observable } from "@peers-app/peers-sdk";
|
|
2
|
+
import React from 'react';
|
|
3
3
|
/**
|
|
4
4
|
* Use this to subscribe to an observable or computed in a functional component.
|
|
5
|
+
* Uses useSyncExternalStore for proper React 18 concurrent rendering support.
|
|
5
6
|
* @param sub the observable or computed to subscribe to
|
|
6
|
-
* @param deps an array of dependencies
|
|
7
|
+
* @param deps an array of dependencies that will cause re-subscription when changed
|
|
7
8
|
* @returns the current value of the observable or computed and a function to set the value
|
|
8
9
|
*/
|
|
9
10
|
export declare function useObservable<T>(sub: Observable<T> | T, deps?: React.DependencyList): [T, (value: T) => void];
|
package/dist/hooks.js
CHANGED
|
@@ -5,46 +5,38 @@ exports.usePromise = usePromise;
|
|
|
5
5
|
exports.useObservableState = useObservableState;
|
|
6
6
|
exports.useSubscription = useSubscription;
|
|
7
7
|
exports.useOnScreen = useOnScreen;
|
|
8
|
-
const react_1 = require("react");
|
|
9
|
-
const lodash_1 = require("lodash");
|
|
10
8
|
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
9
|
+
const react_1 = require("react");
|
|
11
10
|
/**
|
|
12
11
|
* Use this to subscribe to an observable or computed in a functional component.
|
|
12
|
+
* Uses useSyncExternalStore for proper React 18 concurrent rendering support.
|
|
13
13
|
* @param sub the observable or computed to subscribe to
|
|
14
|
-
* @param deps an array of dependencies
|
|
14
|
+
* @param deps an array of dependencies that will cause re-subscription when changed
|
|
15
15
|
* @returns the current value of the observable or computed and a function to set the value
|
|
16
16
|
*/
|
|
17
17
|
function useObservable(sub, deps = []) {
|
|
18
|
-
|
|
19
|
-
(0, react_1.
|
|
18
|
+
// Create memoized subscribe function that adapts Observable's subscribe API to useSyncExternalStore's expected signature
|
|
19
|
+
const subscribe = (0, react_1.useCallback)((onStoreChange) => {
|
|
20
20
|
if (!(0, peers_sdk_1.isSubscribable)(sub)) {
|
|
21
|
-
return
|
|
22
|
-
|
|
23
|
-
const subscription = sub.subscribe(() => {
|
|
24
|
-
const newData = sub();
|
|
25
|
-
// @ts-ignore
|
|
26
|
-
if ((0, lodash_1.isArray)(newData) && newData === data) {
|
|
27
|
-
// @ts-ignore
|
|
28
|
-
setData([...newData]);
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
setData(newData);
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
// the data might change _after_ useObservable is called but _before_ the subscription has been created
|
|
35
|
-
// this checks for that and updates the state with the new data if necessary
|
|
36
|
-
const newData = sub();
|
|
37
|
-
if (!(0, lodash_1.isEqual)(data, newData)) {
|
|
38
|
-
setData(newData);
|
|
21
|
+
// If not subscribable, return a no-op unsubscribe
|
|
22
|
+
return () => { };
|
|
39
23
|
}
|
|
24
|
+
const subscription = sub.subscribe(onStoreChange);
|
|
40
25
|
return () => subscription.dispose();
|
|
41
|
-
}, deps);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
26
|
+
}, [sub, ...deps]);
|
|
27
|
+
// getSnapshot returns the current value from the observable
|
|
28
|
+
const getSnapshot = (0, react_1.useCallback)(() => {
|
|
29
|
+
return (0, peers_sdk_1.unwrapObservable)(sub);
|
|
30
|
+
}, [sub, ...deps]);
|
|
31
|
+
// Use useSyncExternalStore for concurrent-safe subscription
|
|
32
|
+
const data = (0, react_1.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
|
|
33
|
+
// Create setter that updates both local understanding and the observable
|
|
34
|
+
const setter = (0, react_1.useCallback)((newData) => {
|
|
35
|
+
if ((0, peers_sdk_1.isSubscribable)(sub)) {
|
|
36
|
+
sub(newData);
|
|
37
|
+
}
|
|
38
|
+
}, [sub]);
|
|
39
|
+
return [data, setter];
|
|
48
40
|
}
|
|
49
41
|
/**
|
|
50
42
|
* Use this to easily wait for a promise in a functional component.
|
|
@@ -104,26 +96,3 @@ function useOnScreen(ref) {
|
|
|
104
96
|
}, []);
|
|
105
97
|
return isIntersecting;
|
|
106
98
|
}
|
|
107
|
-
// /**
|
|
108
|
-
// * This creates an observable that will automatically persist its value between page reloads using localStorage.
|
|
109
|
-
// * If localStorage is not available, the observable will not persist its value.
|
|
110
|
-
// * @param initialValue the initial value of the observable
|
|
111
|
-
// * @param globalName the name to use when storing the value in localStorage
|
|
112
|
-
// * @returns the observable that will persist between page reloads
|
|
113
|
-
// */
|
|
114
|
-
// export function persistentValue<T>(initialValue: T, globalName: string): Observable<T | undefined> {
|
|
115
|
-
// let q = observable<T>();
|
|
116
|
-
// if (typeof localStorage === 'undefined') {
|
|
117
|
-
// return q;
|
|
118
|
-
// }
|
|
119
|
-
// q.subscribe(newVal => {
|
|
120
|
-
// localStorage.setItem(globalName, JSON.stringify(toJSON(newVal)))
|
|
121
|
-
// })
|
|
122
|
-
// const existing = localStorage.getItem(globalName);
|
|
123
|
-
// if (existing) {
|
|
124
|
-
// q(fromJSON(JSON.parse(existing)))
|
|
125
|
-
// } else {
|
|
126
|
-
// q(initialValue);
|
|
127
|
-
// }
|
|
128
|
-
// return q;
|
|
129
|
-
// }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const react_1 = __importDefault(require("react"));
|
|
7
|
+
const react_2 = require("@testing-library/react");
|
|
8
|
+
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
9
|
+
const hooks_1 = require("./hooks");
|
|
10
|
+
// Helper component to test useObservable
|
|
11
|
+
function TestComponent({ obs, testId = 'value' }) {
|
|
12
|
+
const [value, setValue] = (0, hooks_1.useObservable)(obs);
|
|
13
|
+
return (react_1.default.createElement("div", null,
|
|
14
|
+
react_1.default.createElement("span", { "data-testid": testId }, JSON.stringify(value)),
|
|
15
|
+
react_1.default.createElement("button", { "data-testid": "setter", onClick: () => setValue('new-value') }, "Set")));
|
|
16
|
+
}
|
|
17
|
+
// Helper component to test array behavior
|
|
18
|
+
function ArrayTestComponent({ obs }) {
|
|
19
|
+
const [value] = (0, hooks_1.useObservable)(obs);
|
|
20
|
+
return (react_1.default.createElement("div", null,
|
|
21
|
+
react_1.default.createElement("span", { "data-testid": "array-value" }, JSON.stringify(value)),
|
|
22
|
+
react_1.default.createElement("span", { "data-testid": "array-length" }, value.length)));
|
|
23
|
+
}
|
|
24
|
+
// Helper component to test useObservableState
|
|
25
|
+
function ObservableStateTestComponent({ initialValue }) {
|
|
26
|
+
const obs = (0, hooks_1.useObservableState)(initialValue);
|
|
27
|
+
const [value] = (0, hooks_1.useObservable)(obs);
|
|
28
|
+
return (react_1.default.createElement("div", null,
|
|
29
|
+
react_1.default.createElement("span", { "data-testid": "state-value" }, value),
|
|
30
|
+
react_1.default.createElement("button", { "data-testid": "increment", onClick: () => obs(obs() + 1) }, "Increment")));
|
|
31
|
+
}
|
|
32
|
+
describe('useObservable', () => {
|
|
33
|
+
describe('basic functionality', () => {
|
|
34
|
+
it('should return the current value of an observable', () => {
|
|
35
|
+
const obs = (0, peers_sdk_1.observable)('hello');
|
|
36
|
+
(0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
|
|
37
|
+
expect(react_2.screen.getByTestId('value')).toHaveTextContent('"hello"');
|
|
38
|
+
});
|
|
39
|
+
it('should return the value when passed a plain value (non-observable)', () => {
|
|
40
|
+
(0, react_2.render)(react_1.default.createElement(TestComponent, { obs: "plain-value" }));
|
|
41
|
+
expect(react_2.screen.getByTestId('value')).toHaveTextContent('"plain-value"');
|
|
42
|
+
});
|
|
43
|
+
it('should re-render when observable value changes', async () => {
|
|
44
|
+
const obs = (0, peers_sdk_1.observable)('initial');
|
|
45
|
+
(0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
|
|
46
|
+
expect(react_2.screen.getByTestId('value')).toHaveTextContent('"initial"');
|
|
47
|
+
(0, react_2.act)(() => {
|
|
48
|
+
obs('updated');
|
|
49
|
+
});
|
|
50
|
+
await (0, react_2.waitFor)(() => {
|
|
51
|
+
expect(react_2.screen.getByTestId('value')).toHaveTextContent('"updated"');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
it('should update the observable when setter is called', async () => {
|
|
55
|
+
const obs = (0, peers_sdk_1.observable)('initial');
|
|
56
|
+
(0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
|
|
57
|
+
(0, react_2.act)(() => {
|
|
58
|
+
react_2.screen.getByTestId('setter').click();
|
|
59
|
+
});
|
|
60
|
+
await (0, react_2.waitFor)(() => {
|
|
61
|
+
expect(obs()).toBe('new-value');
|
|
62
|
+
expect(react_2.screen.getByTestId('value')).toHaveTextContent('"new-value"');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
it('should handle undefined values', () => {
|
|
66
|
+
const obs = (0, peers_sdk_1.observable)(undefined);
|
|
67
|
+
(0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
|
|
68
|
+
// undefined serializes to empty string in JSON.stringify, but actually shows as nothing
|
|
69
|
+
expect(react_2.screen.getByTestId('value')).toHaveTextContent('');
|
|
70
|
+
});
|
|
71
|
+
it('should handle object values', async () => {
|
|
72
|
+
const obs = (0, peers_sdk_1.observable)({ name: 'test', count: 5 });
|
|
73
|
+
(0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
|
|
74
|
+
expect(react_2.screen.getByTestId('value')).toHaveTextContent('{"name":"test","count":5}');
|
|
75
|
+
(0, react_2.act)(() => {
|
|
76
|
+
obs({ name: 'updated', count: 10 });
|
|
77
|
+
});
|
|
78
|
+
await (0, react_2.waitFor)(() => {
|
|
79
|
+
expect(react_2.screen.getByTestId('value')).toHaveTextContent('{"name":"updated","count":10}');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('array handling', () => {
|
|
84
|
+
it('should handle array values', () => {
|
|
85
|
+
const obs = (0, peers_sdk_1.observable)(['a', 'b', 'c']);
|
|
86
|
+
(0, react_2.render)(react_1.default.createElement(ArrayTestComponent, { obs: obs }));
|
|
87
|
+
expect(react_2.screen.getByTestId('array-value')).toHaveTextContent('["a","b","c"]');
|
|
88
|
+
expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('3');
|
|
89
|
+
});
|
|
90
|
+
it('should re-render when array is replaced with new reference', async () => {
|
|
91
|
+
const obs = (0, peers_sdk_1.observable)(['a', 'b']);
|
|
92
|
+
(0, react_2.render)(react_1.default.createElement(ArrayTestComponent, { obs: obs }));
|
|
93
|
+
expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('2');
|
|
94
|
+
(0, react_2.act)(() => {
|
|
95
|
+
obs(['a', 'b', 'c']); // New array reference
|
|
96
|
+
});
|
|
97
|
+
await (0, react_2.waitFor)(() => {
|
|
98
|
+
expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('3');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
it('should NOT re-render when array is mutated in place (requires immutable updates)', async () => {
|
|
102
|
+
// This tests the case where an array is mutated in place and notifySubscribers is called.
|
|
103
|
+
//
|
|
104
|
+
// IMPORTANT BEHAVIORAL NOTE:
|
|
105
|
+
// The OLD implementation (useState + useEffect) had special handling that would
|
|
106
|
+
// spread the array when it detected same-reference mutation:
|
|
107
|
+
// if (isArray(newData) && newData === data) setData([...newData])
|
|
108
|
+
//
|
|
109
|
+
// The NEW implementation (useSyncExternalStore) does NOT do this.
|
|
110
|
+
// useSyncExternalStore uses Object.is() comparison on getSnapshot results.
|
|
111
|
+
// If the reference is the same, React won't re-render.
|
|
112
|
+
//
|
|
113
|
+
// This is actually the CORRECT behavior - it encourages immutable data patterns
|
|
114
|
+
// which are required for React 18 concurrent rendering to work correctly.
|
|
115
|
+
// Mutating data in place can cause tearing in concurrent mode.
|
|
116
|
+
const obs = (0, peers_sdk_1.observable)(['a', 'b']);
|
|
117
|
+
(0, react_2.render)(react_1.default.createElement(ArrayTestComponent, { obs: obs }));
|
|
118
|
+
expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('2');
|
|
119
|
+
(0, react_2.act)(() => {
|
|
120
|
+
// Mutate the array in place (BAD PRACTICE - don't do this!)
|
|
121
|
+
const arr = obs();
|
|
122
|
+
arr.push('c');
|
|
123
|
+
obs.notifySubscribers();
|
|
124
|
+
});
|
|
125
|
+
// The observable itself has the mutated value
|
|
126
|
+
expect(obs()).toEqual(['a', 'b', 'c']);
|
|
127
|
+
// But the component did NOT re-render because the reference didn't change
|
|
128
|
+
// This is expected with useSyncExternalStore - it requires immutable updates
|
|
129
|
+
expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('2');
|
|
130
|
+
});
|
|
131
|
+
it('should re-render when array is properly updated with spread', async () => {
|
|
132
|
+
// This is the recommended pattern for updating arrays
|
|
133
|
+
const obs = (0, peers_sdk_1.observable)(['a', 'b']);
|
|
134
|
+
(0, react_2.render)(react_1.default.createElement(ArrayTestComponent, { obs: obs }));
|
|
135
|
+
expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('2');
|
|
136
|
+
(0, react_2.act)(() => {
|
|
137
|
+
obs([...obs(), 'c']); // Proper immutable update
|
|
138
|
+
});
|
|
139
|
+
await (0, react_2.waitFor)(() => {
|
|
140
|
+
expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('3');
|
|
141
|
+
expect(react_2.screen.getByTestId('array-value')).toHaveTextContent('["a","b","c"]');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('subscription management', () => {
|
|
146
|
+
it('should unsubscribe when component unmounts', () => {
|
|
147
|
+
const obs = (0, peers_sdk_1.observable)('test');
|
|
148
|
+
const initialSubscriberCount = obs.subscriberCount();
|
|
149
|
+
const { unmount } = (0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
|
|
150
|
+
// Should have subscribed
|
|
151
|
+
expect(obs.subscriberCount()).toBeGreaterThan(initialSubscriberCount);
|
|
152
|
+
unmount();
|
|
153
|
+
// Should have unsubscribed
|
|
154
|
+
expect(obs.subscriberCount()).toBe(initialSubscriberCount);
|
|
155
|
+
});
|
|
156
|
+
it('should handle multiple components subscribing to same observable', async () => {
|
|
157
|
+
const obs = (0, peers_sdk_1.observable)('shared');
|
|
158
|
+
(0, react_2.render)(react_1.default.createElement("div", null,
|
|
159
|
+
react_1.default.createElement(TestComponent, { obs: obs, testId: "value1" }),
|
|
160
|
+
react_1.default.createElement(TestComponent, { obs: obs, testId: "value2" })));
|
|
161
|
+
expect(react_2.screen.getByTestId('value1')).toHaveTextContent('"shared"');
|
|
162
|
+
expect(react_2.screen.getByTestId('value2')).toHaveTextContent('"shared"');
|
|
163
|
+
(0, react_2.act)(() => {
|
|
164
|
+
obs('updated');
|
|
165
|
+
});
|
|
166
|
+
await (0, react_2.waitFor)(() => {
|
|
167
|
+
expect(react_2.screen.getByTestId('value1')).toHaveTextContent('"updated"');
|
|
168
|
+
expect(react_2.screen.getByTestId('value2')).toHaveTextContent('"updated"');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe('rapid updates', () => {
|
|
173
|
+
it('should handle rapid sequential updates correctly', async () => {
|
|
174
|
+
const obs = (0, peers_sdk_1.observable)(0);
|
|
175
|
+
function CounterComponent() {
|
|
176
|
+
const [value] = (0, hooks_1.useObservable)(obs);
|
|
177
|
+
return react_1.default.createElement("span", { "data-testid": "counter" }, value);
|
|
178
|
+
}
|
|
179
|
+
(0, react_2.render)(react_1.default.createElement(CounterComponent, null));
|
|
180
|
+
(0, react_2.act)(() => {
|
|
181
|
+
for (let i = 1; i <= 10; i++) {
|
|
182
|
+
obs(i);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
await (0, react_2.waitFor)(() => {
|
|
186
|
+
expect(react_2.screen.getByTestId('counter')).toHaveTextContent('10');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
describe('useObservableState', () => {
|
|
192
|
+
it('should create an observable with the initial value', () => {
|
|
193
|
+
(0, react_2.render)(react_1.default.createElement(ObservableStateTestComponent, { initialValue: 42 }));
|
|
194
|
+
expect(react_2.screen.getByTestId('state-value')).toHaveTextContent('42');
|
|
195
|
+
});
|
|
196
|
+
it('should re-render when the observable is updated', async () => {
|
|
197
|
+
(0, react_2.render)(react_1.default.createElement(ObservableStateTestComponent, { initialValue: 0 }));
|
|
198
|
+
expect(react_2.screen.getByTestId('state-value')).toHaveTextContent('0');
|
|
199
|
+
(0, react_2.act)(() => {
|
|
200
|
+
react_2.screen.getByTestId('increment').click();
|
|
201
|
+
});
|
|
202
|
+
await (0, react_2.waitFor)(() => {
|
|
203
|
+
expect(react_2.screen.getByTestId('state-value')).toHaveTextContent('1');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
it('should maintain the same observable instance across re-renders', async () => {
|
|
207
|
+
let capturedObs = null;
|
|
208
|
+
function CaptureObsComponent() {
|
|
209
|
+
const obs = (0, hooks_1.useObservableState)(0);
|
|
210
|
+
if (!capturedObs) {
|
|
211
|
+
capturedObs = obs;
|
|
212
|
+
}
|
|
213
|
+
const [value] = (0, hooks_1.useObservable)(obs);
|
|
214
|
+
return (react_1.default.createElement("div", null,
|
|
215
|
+
react_1.default.createElement("span", { "data-testid": "value" }, value),
|
|
216
|
+
react_1.default.createElement("span", { "data-testid": "same-obs" }, obs === capturedObs ? 'same' : 'different'),
|
|
217
|
+
react_1.default.createElement("button", { "data-testid": "update", onClick: () => obs(obs() + 1) }, "Update")));
|
|
218
|
+
}
|
|
219
|
+
(0, react_2.render)(react_1.default.createElement(CaptureObsComponent, null));
|
|
220
|
+
expect(react_2.screen.getByTestId('same-obs')).toHaveTextContent('same');
|
|
221
|
+
// Trigger a re-render by updating the observable
|
|
222
|
+
(0, react_2.act)(() => {
|
|
223
|
+
react_2.screen.getByTestId('update').click();
|
|
224
|
+
});
|
|
225
|
+
await (0, react_2.waitFor)(() => {
|
|
226
|
+
expect(react_2.screen.getByTestId('value')).toHaveTextContent('1');
|
|
227
|
+
expect(react_2.screen.getByTestId('same-obs')).toHaveTextContent('same');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
it('should not subscribe when doNotSubscribe is true', () => {
|
|
231
|
+
function NoSubscribeComponent() {
|
|
232
|
+
const obs = (0, hooks_1.useObservableState)(0, true); // doNotSubscribe = true
|
|
233
|
+
return (react_1.default.createElement("div", null,
|
|
234
|
+
react_1.default.createElement("span", { "data-testid": "value" }, obs()),
|
|
235
|
+
react_1.default.createElement("button", { "data-testid": "update", onClick: () => obs(obs() + 1) }, "Update")));
|
|
236
|
+
}
|
|
237
|
+
(0, react_2.render)(react_1.default.createElement(NoSubscribeComponent, null));
|
|
238
|
+
expect(react_2.screen.getByTestId('value')).toHaveTextContent('0');
|
|
239
|
+
// Update won't cause re-render since we're not subscribed
|
|
240
|
+
(0, react_2.act)(() => {
|
|
241
|
+
react_2.screen.getByTestId('update').click();
|
|
242
|
+
});
|
|
243
|
+
// Value in DOM should still show 0 since component didn't re-render
|
|
244
|
+
// But the observable itself has been updated
|
|
245
|
+
expect(react_2.screen.getByTestId('value')).toHaveTextContent('0');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -48,6 +48,8 @@ const color_mode_dropdown_1 = require("../settings/color-mode-dropdown");
|
|
|
48
48
|
const log_display_1 = require("./log-display");
|
|
49
49
|
const log_filters_1 = require("./log-filters");
|
|
50
50
|
const resizable_table_header_1 = require("./resizable-table-header");
|
|
51
|
+
const globals_1 = require("../../globals");
|
|
52
|
+
const mobile_log_card_1 = require("./mobile-log-card");
|
|
51
53
|
const windowHeight = () => window.innerHeight;
|
|
52
54
|
const DEFAULT_COLUMNS = [
|
|
53
55
|
{ key: 'timestamp', label: 'Timestamp', width: 150 },
|
|
@@ -68,6 +70,8 @@ const ConsoleLogsList = () => {
|
|
|
68
70
|
const [_colorMode] = (0, hooks_1.useObservable)(color_mode_dropdown_1.colorMode);
|
|
69
71
|
const logsEndRef = react_1.default.useRef(null);
|
|
70
72
|
const containerRef = react_1.default.useRef(null);
|
|
73
|
+
(0, hooks_1.useObservable)(globals_1.isDesktop);
|
|
74
|
+
const isMobile = !(0, globals_1.isDesktop)();
|
|
71
75
|
const batchSize = 50;
|
|
72
76
|
// Track fixed column widths to trigger message column recalculation
|
|
73
77
|
const fixedColumnsWidthKey = (0, react_1.useMemo)(() => {
|
|
@@ -225,7 +229,7 @@ const ConsoleLogsList = () => {
|
|
|
225
229
|
marginBottom: '50px',
|
|
226
230
|
overflow: 'hidden',
|
|
227
231
|
} },
|
|
228
|
-
react_1.default.createElement(resizable_table_header_1.ResizableTableHeader, { columns: columns, onColumnsChange: setColumns, colorMode: _colorMode }),
|
|
232
|
+
!isMobile && (react_1.default.createElement(resizable_table_header_1.ResizableTableHeader, { columns: columns, onColumnsChange: setColumns, colorMode: _colorMode })),
|
|
229
233
|
react_1.default.createElement("div", { id: "scrollableLogsDiv", style: {
|
|
230
234
|
flex: 1,
|
|
231
235
|
overflow: 'auto',
|
|
@@ -239,10 +243,12 @@ const ConsoleLogsList = () => {
|
|
|
239
243
|
react_1.default.createElement("div", { ref: logsEndRef }),
|
|
240
244
|
_logs.length === 0 && allLogsLoaded ? (react_1.default.createElement("div", { className: "text-center p-5 text-muted" },
|
|
241
245
|
react_1.default.createElement("i", { className: "bi bi-inbox display-1" }),
|
|
242
|
-
react_1.default.createElement("p", { className: "mt-2" }, "No logs found"))) :
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
+
react_1.default.createElement("p", { className: "mt-2" }, "No logs found"))) : isMobile ? (
|
|
247
|
+
/* Mobile: Card-based layout */
|
|
248
|
+
react_1.default.createElement("div", null, _logs.map((log) => (react_1.default.createElement(mobile_log_card_1.MobileLogCard, { key: log.logId, log: log, colorMode: _colorMode }))))) : (
|
|
249
|
+
/* Desktop: Table layout */
|
|
250
|
+
react_1.default.createElement("table", { className: "table table-sm table-hover mb-0", style: { fontSize: '0.85rem', tableLayout: 'fixed', width: '100%' } },
|
|
251
|
+
react_1.default.createElement("tbody", null, _logs.map((log) => (react_1.default.createElement(log_display_1.LogDisplay, { key: log.logId, log: log, columns: columns }))))))))),
|
|
246
252
|
react_1.default.createElement("div", { style: {
|
|
247
253
|
position: 'fixed',
|
|
248
254
|
bottom: 0,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { IConsoleLog } from "@peers-app/peers-sdk";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
interface MobileLogCardProps {
|
|
4
|
+
log: IConsoleLog;
|
|
5
|
+
colorMode: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const MobileLogCard: ({ log, colorMode }: MobileLogCardProps) => React.JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MobileLogCard = void 0;
|
|
7
|
+
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
8
|
+
const moment_1 = __importDefault(require("moment"));
|
|
9
|
+
const react_1 = __importDefault(require("react"));
|
|
10
|
+
const getLevelColor = (level) => {
|
|
11
|
+
switch (level) {
|
|
12
|
+
case 'error': return '#dc3545';
|
|
13
|
+
case 'warn': return '#ffc107';
|
|
14
|
+
case 'info': return '#0dcaf0';
|
|
15
|
+
case 'log': return '#6c757d';
|
|
16
|
+
case 'debug': return '#6c757d';
|
|
17
|
+
default: return '#6c757d';
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const getProcessColor = (process) => {
|
|
21
|
+
switch (process) {
|
|
22
|
+
case 'ui':
|
|
23
|
+
case 'renderer': return '#0dcaf0';
|
|
24
|
+
case 'electron':
|
|
25
|
+
case 'main': return '#d946ef';
|
|
26
|
+
case 'react-native': return '#61dafb';
|
|
27
|
+
default: return '#6c757d';
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const getLevelIcon = (level) => {
|
|
31
|
+
switch (level) {
|
|
32
|
+
case 'error': return 'bi-x-circle-fill';
|
|
33
|
+
case 'warn': return 'bi-exclamation-triangle-fill';
|
|
34
|
+
case 'info': return 'bi-info-circle-fill';
|
|
35
|
+
case 'debug': return 'bi-bug-fill';
|
|
36
|
+
default: return 'bi-chat-square-text';
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const MobileLogCard = ({ log, colorMode }) => {
|
|
40
|
+
const isDark = colorMode === 'dark';
|
|
41
|
+
// Parse context if it's a string
|
|
42
|
+
let context = log.context;
|
|
43
|
+
if (typeof context === 'string') {
|
|
44
|
+
try {
|
|
45
|
+
context = (0, peers_sdk_1.fromJSONString)(context);
|
|
46
|
+
}
|
|
47
|
+
catch (err) { }
|
|
48
|
+
}
|
|
49
|
+
return (react_1.default.createElement("div", { style: {
|
|
50
|
+
padding: '10px 12px',
|
|
51
|
+
borderBottom: `1px solid ${isDark ? '#333' : '#e9ecef'}`,
|
|
52
|
+
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
|
53
|
+
} },
|
|
54
|
+
react_1.default.createElement("div", { className: "d-flex align-items-center gap-2 mb-1", style: { flexWrap: 'wrap' } },
|
|
55
|
+
react_1.default.createElement("span", { style: { fontSize: '0.7rem', color: isDark ? '#888' : '#666' } }, (0, moment_1.default)(log.timestamp).format('HH:mm:ss.SSS')),
|
|
56
|
+
react_1.default.createElement("span", { className: "badge text-white", style: {
|
|
57
|
+
backgroundColor: getLevelColor(log.level),
|
|
58
|
+
fontSize: '0.65rem',
|
|
59
|
+
padding: '2px 6px'
|
|
60
|
+
} },
|
|
61
|
+
react_1.default.createElement("i", { className: `${getLevelIcon(log.level)} me-1` }),
|
|
62
|
+
log.level),
|
|
63
|
+
react_1.default.createElement("span", { className: "badge text-white", style: {
|
|
64
|
+
backgroundColor: getProcessColor(log.process),
|
|
65
|
+
fontSize: '0.65rem',
|
|
66
|
+
padding: '2px 6px'
|
|
67
|
+
} }, log.process),
|
|
68
|
+
log.source && (react_1.default.createElement("span", { style: { fontSize: '0.65rem', color: isDark ? '#666' : '#999' } }, log.source))),
|
|
69
|
+
react_1.default.createElement("div", { style: {
|
|
70
|
+
fontSize: '0.8rem',
|
|
71
|
+
wordBreak: 'break-word',
|
|
72
|
+
color: isDark ? '#e0e0e0' : '#333'
|
|
73
|
+
} }, log.message),
|
|
74
|
+
context && (react_1.default.createElement("details", { className: "mt-1" },
|
|
75
|
+
react_1.default.createElement("summary", { className: "text-muted", style: { cursor: 'pointer', fontSize: '0.7rem' } },
|
|
76
|
+
react_1.default.createElement("i", { className: "bi bi-code-square me-1" }),
|
|
77
|
+
"Context"),
|
|
78
|
+
react_1.default.createElement("pre", { className: "mt-1 p-2 rounded", style: {
|
|
79
|
+
fontSize: '0.65rem',
|
|
80
|
+
backgroundColor: isDark ? '#252525' : '#f8f9fa',
|
|
81
|
+
color: isDark ? '#aaa' : '#333',
|
|
82
|
+
overflow: 'auto',
|
|
83
|
+
maxHeight: '150px'
|
|
84
|
+
} }, JSON.stringify(context, null, 2)))),
|
|
85
|
+
log.stackTrace && (react_1.default.createElement("details", { className: "mt-1" },
|
|
86
|
+
react_1.default.createElement("summary", { className: "text-danger", style: { cursor: 'pointer', fontSize: '0.7rem' } },
|
|
87
|
+
react_1.default.createElement("i", { className: "bi bi-bug me-1" }),
|
|
88
|
+
"Stack Trace"),
|
|
89
|
+
react_1.default.createElement("pre", { className: "mt-1 p-2 rounded text-danger", style: {
|
|
90
|
+
fontSize: '0.6rem',
|
|
91
|
+
backgroundColor: isDark ? '#2a1a1a' : '#fff0f0',
|
|
92
|
+
overflow: 'auto',
|
|
93
|
+
maxHeight: '150px'
|
|
94
|
+
} }, log.stackTrace)))));
|
|
95
|
+
};
|
|
96
|
+
exports.MobileLogCard = MobileLogCard;
|
|
@@ -46,7 +46,8 @@ const SettingsPage = () => {
|
|
|
46
46
|
react_1.default.createElement(DeviceName, null),
|
|
47
47
|
react_1.default.createElement(PackagesRootDirectory, null),
|
|
48
48
|
react_1.default.createElement(ReloadPackagesOnPageRefresh, null),
|
|
49
|
-
react_1.default.createElement(ResetDeviceSyncInfos, null)
|
|
49
|
+
react_1.default.createElement(ResetDeviceSyncInfos, null),
|
|
50
|
+
react_1.default.createElement(DeleteLocalDatabase, null)));
|
|
50
51
|
};
|
|
51
52
|
exports.SettingsPage = SettingsPage;
|
|
52
53
|
const DeviceName = () => {
|
|
@@ -130,3 +131,20 @@ const ResetDeviceSyncInfos = () => {
|
|
|
130
131
|
}
|
|
131
132
|
} }, "Reset All Device Sync Info")));
|
|
132
133
|
};
|
|
134
|
+
const DeleteLocalDatabase = () => {
|
|
135
|
+
return (react_1.default.createElement("div", { className: 'mt-3' },
|
|
136
|
+
react_1.default.createElement("button", { className: 'btn btn-danger btn-sm ms-2', onClick: async () => {
|
|
137
|
+
const confirmed = confirm('Are you sure you want to delete the local database for the current group? ' +
|
|
138
|
+
'This will remove all local data. You will need to restart the app and re-sync from other devices.');
|
|
139
|
+
if (!confirmed)
|
|
140
|
+
return;
|
|
141
|
+
try {
|
|
142
|
+
await peers_sdk_1.rpcServerCalls.deleteLocalDatabase();
|
|
143
|
+
alert('Local database has been deleted. Please restart the app.');
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
console.error('Error while deleting local database', err);
|
|
147
|
+
alert('Failed to delete local database: ' + err.message);
|
|
148
|
+
}
|
|
149
|
+
} }, "Delete Local Database")));
|
|
150
|
+
};
|
package/dist/setupTests.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
require("@testing-library/jest-dom");
|
|
4
|
+
const util_1 = require("util");
|
|
5
|
+
// Polyfill TextEncoder/TextDecoder for jsdom environment
|
|
6
|
+
global.TextEncoder = util_1.TextEncoder;
|
|
7
|
+
global.TextDecoder = util_1.TextDecoder;
|
|
4
8
|
// Mock IntersectionObserver
|
|
5
9
|
global.IntersectionObserver = class {
|
|
6
10
|
constructor() { }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peers-app/peers-ui",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/peers-app/peers-ui.git"
|
|
@@ -21,13 +21,13 @@
|
|
|
21
21
|
"release:minor": "yarn clean && yarn build && npm version minor && git push && git push --tags",
|
|
22
22
|
"release:major": "yarn clean && yarn build && npm version major && git push && git push --tags",
|
|
23
23
|
"release": "yarn release:patch",
|
|
24
|
-
"test": "
|
|
24
|
+
"test": "jest --no-watchman",
|
|
25
25
|
"test:watch": "jest --watch",
|
|
26
26
|
"test:coverage": "jest --coverage"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
+
"@peers-app/peers-sdk": "^0.8.5",
|
|
29
30
|
"bootstrap": "^5.3.3",
|
|
30
|
-
"@peers-app/peers-sdk": "^0.8.3",
|
|
31
31
|
"react": "^18.0.0",
|
|
32
32
|
"react-dom": "^18.0.0"
|
|
33
33
|
},
|
|
@@ -37,13 +37,14 @@
|
|
|
37
37
|
"@babel/preset-react": "^7.24.1",
|
|
38
38
|
"@babel/preset-typescript": "^7.27.1",
|
|
39
39
|
"@electron/rebuild": "^3.6.0",
|
|
40
|
+
"@peers-app/peers-sdk": "0.8.5",
|
|
40
41
|
"@testing-library/dom": "^10.4.0",
|
|
41
42
|
"@testing-library/jest-dom": "^6.6.3",
|
|
42
43
|
"@testing-library/react": "^16.3.0",
|
|
43
44
|
"@testing-library/user-event": "^14.6.1",
|
|
44
45
|
"@types/bootstrap": "^5.2.10",
|
|
45
46
|
"@types/dompurify": "^3.0.5",
|
|
46
|
-
"@types/jest": "^
|
|
47
|
+
"@types/jest": "^30.0.0",
|
|
47
48
|
"@types/lodash": "^4.17.1",
|
|
48
49
|
"@types/node": "^20.12.10",
|
|
49
50
|
"@types/prismjs": "^1.26.4",
|
|
@@ -57,7 +58,6 @@
|
|
|
57
58
|
"jest": "^29.7.0",
|
|
58
59
|
"jest-environment-jsdom": "^30.0.5",
|
|
59
60
|
"path-browserify": "^1.0.1",
|
|
60
|
-
"@peers-app/peers-sdk": "0.8.3",
|
|
61
61
|
"react": "^18.0.0",
|
|
62
62
|
"react-dom": "^18.0.0",
|
|
63
63
|
"string-width": "^7.1.0",
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, act, waitFor } from '@testing-library/react';
|
|
3
|
+
import { observable, Observable } from '@peers-app/peers-sdk';
|
|
4
|
+
import { useObservable, useObservableState } from './hooks';
|
|
5
|
+
|
|
6
|
+
// Helper component to test useObservable
|
|
7
|
+
function TestComponent<T>({ obs, testId = 'value' }: { obs: Observable<T> | T, testId?: string }) {
|
|
8
|
+
const [value, setValue] = useObservable(obs);
|
|
9
|
+
return (
|
|
10
|
+
<div>
|
|
11
|
+
<span data-testid={testId}>{JSON.stringify(value)}</span>
|
|
12
|
+
<button data-testid="setter" onClick={() => setValue('new-value' as any)}>Set</button>
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Helper component to test array behavior
|
|
18
|
+
function ArrayTestComponent({ obs }: { obs: Observable<string[]> }) {
|
|
19
|
+
const [value] = useObservable(obs);
|
|
20
|
+
return (
|
|
21
|
+
<div>
|
|
22
|
+
<span data-testid="array-value">{JSON.stringify(value)}</span>
|
|
23
|
+
<span data-testid="array-length">{value.length}</span>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Helper component to test useObservableState
|
|
29
|
+
function ObservableStateTestComponent({ initialValue }: { initialValue: number }) {
|
|
30
|
+
const obs = useObservableState(initialValue);
|
|
31
|
+
const [value] = useObservable(obs);
|
|
32
|
+
return (
|
|
33
|
+
<div>
|
|
34
|
+
<span data-testid="state-value">{value}</span>
|
|
35
|
+
<button data-testid="increment" onClick={() => obs(obs() + 1)}>Increment</button>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('useObservable', () => {
|
|
41
|
+
describe('basic functionality', () => {
|
|
42
|
+
it('should return the current value of an observable', () => {
|
|
43
|
+
const obs = observable('hello');
|
|
44
|
+
render(<TestComponent obs={obs} />);
|
|
45
|
+
expect(screen.getByTestId('value')).toHaveTextContent('"hello"');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should return the value when passed a plain value (non-observable)', () => {
|
|
49
|
+
render(<TestComponent obs="plain-value" />);
|
|
50
|
+
expect(screen.getByTestId('value')).toHaveTextContent('"plain-value"');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should re-render when observable value changes', async () => {
|
|
54
|
+
const obs = observable('initial');
|
|
55
|
+
render(<TestComponent obs={obs} />);
|
|
56
|
+
|
|
57
|
+
expect(screen.getByTestId('value')).toHaveTextContent('"initial"');
|
|
58
|
+
|
|
59
|
+
act(() => {
|
|
60
|
+
obs('updated');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await waitFor(() => {
|
|
64
|
+
expect(screen.getByTestId('value')).toHaveTextContent('"updated"');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should update the observable when setter is called', async () => {
|
|
69
|
+
const obs = observable('initial');
|
|
70
|
+
render(<TestComponent obs={obs} />);
|
|
71
|
+
|
|
72
|
+
act(() => {
|
|
73
|
+
screen.getByTestId('setter').click();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await waitFor(() => {
|
|
77
|
+
expect(obs()).toBe('new-value');
|
|
78
|
+
expect(screen.getByTestId('value')).toHaveTextContent('"new-value"');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should handle undefined values', () => {
|
|
83
|
+
const obs = observable<string | undefined>(undefined);
|
|
84
|
+
render(<TestComponent obs={obs} />);
|
|
85
|
+
// undefined serializes to empty string in JSON.stringify, but actually shows as nothing
|
|
86
|
+
expect(screen.getByTestId('value')).toHaveTextContent('');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should handle object values', async () => {
|
|
90
|
+
const obs = observable({ name: 'test', count: 5 });
|
|
91
|
+
render(<TestComponent obs={obs} />);
|
|
92
|
+
|
|
93
|
+
expect(screen.getByTestId('value')).toHaveTextContent('{"name":"test","count":5}');
|
|
94
|
+
|
|
95
|
+
act(() => {
|
|
96
|
+
obs({ name: 'updated', count: 10 });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await waitFor(() => {
|
|
100
|
+
expect(screen.getByTestId('value')).toHaveTextContent('{"name":"updated","count":10}');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('array handling', () => {
|
|
106
|
+
it('should handle array values', () => {
|
|
107
|
+
const obs = observable(['a', 'b', 'c']);
|
|
108
|
+
render(<ArrayTestComponent obs={obs} />);
|
|
109
|
+
|
|
110
|
+
expect(screen.getByTestId('array-value')).toHaveTextContent('["a","b","c"]');
|
|
111
|
+
expect(screen.getByTestId('array-length')).toHaveTextContent('3');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should re-render when array is replaced with new reference', async () => {
|
|
115
|
+
const obs = observable(['a', 'b']);
|
|
116
|
+
render(<ArrayTestComponent obs={obs} />);
|
|
117
|
+
|
|
118
|
+
expect(screen.getByTestId('array-length')).toHaveTextContent('2');
|
|
119
|
+
|
|
120
|
+
act(() => {
|
|
121
|
+
obs(['a', 'b', 'c']); // New array reference
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(screen.getByTestId('array-length')).toHaveTextContent('3');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should NOT re-render when array is mutated in place (requires immutable updates)', async () => {
|
|
130
|
+
// This tests the case where an array is mutated in place and notifySubscribers is called.
|
|
131
|
+
//
|
|
132
|
+
// IMPORTANT BEHAVIORAL NOTE:
|
|
133
|
+
// The OLD implementation (useState + useEffect) had special handling that would
|
|
134
|
+
// spread the array when it detected same-reference mutation:
|
|
135
|
+
// if (isArray(newData) && newData === data) setData([...newData])
|
|
136
|
+
//
|
|
137
|
+
// The NEW implementation (useSyncExternalStore) does NOT do this.
|
|
138
|
+
// useSyncExternalStore uses Object.is() comparison on getSnapshot results.
|
|
139
|
+
// If the reference is the same, React won't re-render.
|
|
140
|
+
//
|
|
141
|
+
// This is actually the CORRECT behavior - it encourages immutable data patterns
|
|
142
|
+
// which are required for React 18 concurrent rendering to work correctly.
|
|
143
|
+
// Mutating data in place can cause tearing in concurrent mode.
|
|
144
|
+
|
|
145
|
+
const obs = observable(['a', 'b']);
|
|
146
|
+
render(<ArrayTestComponent obs={obs} />);
|
|
147
|
+
|
|
148
|
+
expect(screen.getByTestId('array-length')).toHaveTextContent('2');
|
|
149
|
+
|
|
150
|
+
act(() => {
|
|
151
|
+
// Mutate the array in place (BAD PRACTICE - don't do this!)
|
|
152
|
+
const arr = obs();
|
|
153
|
+
arr.push('c');
|
|
154
|
+
obs.notifySubscribers();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// The observable itself has the mutated value
|
|
158
|
+
expect(obs()).toEqual(['a', 'b', 'c']);
|
|
159
|
+
|
|
160
|
+
// But the component did NOT re-render because the reference didn't change
|
|
161
|
+
// This is expected with useSyncExternalStore - it requires immutable updates
|
|
162
|
+
expect(screen.getByTestId('array-length')).toHaveTextContent('2');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should re-render when array is properly updated with spread', async () => {
|
|
166
|
+
// This is the recommended pattern for updating arrays
|
|
167
|
+
const obs = observable(['a', 'b']);
|
|
168
|
+
render(<ArrayTestComponent obs={obs} />);
|
|
169
|
+
|
|
170
|
+
expect(screen.getByTestId('array-length')).toHaveTextContent('2');
|
|
171
|
+
|
|
172
|
+
act(() => {
|
|
173
|
+
obs([...obs(), 'c']); // Proper immutable update
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await waitFor(() => {
|
|
177
|
+
expect(screen.getByTestId('array-length')).toHaveTextContent('3');
|
|
178
|
+
expect(screen.getByTestId('array-value')).toHaveTextContent('["a","b","c"]');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('subscription management', () => {
|
|
184
|
+
it('should unsubscribe when component unmounts', () => {
|
|
185
|
+
const obs = observable('test');
|
|
186
|
+
const initialSubscriberCount = obs.subscriberCount();
|
|
187
|
+
|
|
188
|
+
const { unmount } = render(<TestComponent obs={obs} />);
|
|
189
|
+
|
|
190
|
+
// Should have subscribed
|
|
191
|
+
expect(obs.subscriberCount()).toBeGreaterThan(initialSubscriberCount);
|
|
192
|
+
|
|
193
|
+
unmount();
|
|
194
|
+
|
|
195
|
+
// Should have unsubscribed
|
|
196
|
+
expect(obs.subscriberCount()).toBe(initialSubscriberCount);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should handle multiple components subscribing to same observable', async () => {
|
|
200
|
+
const obs = observable('shared');
|
|
201
|
+
|
|
202
|
+
render(
|
|
203
|
+
<div>
|
|
204
|
+
<TestComponent obs={obs} testId="value1" />
|
|
205
|
+
<TestComponent obs={obs} testId="value2" />
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(screen.getByTestId('value1')).toHaveTextContent('"shared"');
|
|
210
|
+
expect(screen.getByTestId('value2')).toHaveTextContent('"shared"');
|
|
211
|
+
|
|
212
|
+
act(() => {
|
|
213
|
+
obs('updated');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await waitFor(() => {
|
|
217
|
+
expect(screen.getByTestId('value1')).toHaveTextContent('"updated"');
|
|
218
|
+
expect(screen.getByTestId('value2')).toHaveTextContent('"updated"');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('rapid updates', () => {
|
|
224
|
+
it('should handle rapid sequential updates correctly', async () => {
|
|
225
|
+
const obs = observable(0);
|
|
226
|
+
|
|
227
|
+
function CounterComponent() {
|
|
228
|
+
const [value] = useObservable(obs);
|
|
229
|
+
return <span data-testid="counter">{value}</span>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
render(<CounterComponent />);
|
|
233
|
+
|
|
234
|
+
act(() => {
|
|
235
|
+
for (let i = 1; i <= 10; i++) {
|
|
236
|
+
obs(i);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await waitFor(() => {
|
|
241
|
+
expect(screen.getByTestId('counter')).toHaveTextContent('10');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('useObservableState', () => {
|
|
248
|
+
it('should create an observable with the initial value', () => {
|
|
249
|
+
render(<ObservableStateTestComponent initialValue={42} />);
|
|
250
|
+
expect(screen.getByTestId('state-value')).toHaveTextContent('42');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should re-render when the observable is updated', async () => {
|
|
254
|
+
render(<ObservableStateTestComponent initialValue={0} />);
|
|
255
|
+
|
|
256
|
+
expect(screen.getByTestId('state-value')).toHaveTextContent('0');
|
|
257
|
+
|
|
258
|
+
act(() => {
|
|
259
|
+
screen.getByTestId('increment').click();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await waitFor(() => {
|
|
263
|
+
expect(screen.getByTestId('state-value')).toHaveTextContent('1');
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should maintain the same observable instance across re-renders', async () => {
|
|
268
|
+
let capturedObs: Observable<number> | null = null;
|
|
269
|
+
|
|
270
|
+
function CaptureObsComponent() {
|
|
271
|
+
const obs = useObservableState(0);
|
|
272
|
+
if (!capturedObs) {
|
|
273
|
+
capturedObs = obs;
|
|
274
|
+
}
|
|
275
|
+
const [value] = useObservable(obs);
|
|
276
|
+
return (
|
|
277
|
+
<div>
|
|
278
|
+
<span data-testid="value">{value}</span>
|
|
279
|
+
<span data-testid="same-obs">{obs === capturedObs ? 'same' : 'different'}</span>
|
|
280
|
+
<button data-testid="update" onClick={() => obs(obs() + 1)}>Update</button>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
render(<CaptureObsComponent />);
|
|
286
|
+
|
|
287
|
+
expect(screen.getByTestId('same-obs')).toHaveTextContent('same');
|
|
288
|
+
|
|
289
|
+
// Trigger a re-render by updating the observable
|
|
290
|
+
act(() => {
|
|
291
|
+
screen.getByTestId('update').click();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await waitFor(() => {
|
|
295
|
+
expect(screen.getByTestId('value')).toHaveTextContent('1');
|
|
296
|
+
expect(screen.getByTestId('same-obs')).toHaveTextContent('same');
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('should not subscribe when doNotSubscribe is true', () => {
|
|
301
|
+
function NoSubscribeComponent() {
|
|
302
|
+
const obs = useObservableState(0, true); // doNotSubscribe = true
|
|
303
|
+
return (
|
|
304
|
+
<div>
|
|
305
|
+
<span data-testid="value">{obs()}</span>
|
|
306
|
+
<button data-testid="update" onClick={() => obs(obs() + 1)}>Update</button>
|
|
307
|
+
</div>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
render(<NoSubscribeComponent />);
|
|
312
|
+
|
|
313
|
+
expect(screen.getByTestId('value')).toHaveTextContent('0');
|
|
314
|
+
|
|
315
|
+
// Update won't cause re-render since we're not subscribed
|
|
316
|
+
act(() => {
|
|
317
|
+
screen.getByTestId('update').click();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Value in DOM should still show 0 since component didn't re-render
|
|
321
|
+
// But the observable itself has been updated
|
|
322
|
+
expect(screen.getByTestId('value')).toHaveTextContent('0');
|
|
323
|
+
});
|
|
324
|
+
});
|
package/src/hooks.ts
CHANGED
|
@@ -1,47 +1,43 @@
|
|
|
1
1
|
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import { observable, isSubscribable, unwrapObservable, Observable } from "@peers-app/peers-sdk";
|
|
2
|
+
import { isSubscribable, observable, Observable, unwrapObservable } from "@peers-app/peers-sdk";
|
|
3
|
+
import React, { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
|
|
5
4
|
|
|
6
5
|
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Use this to subscribe to an observable or computed in a functional component.
|
|
9
|
+
* Uses useSyncExternalStore for proper React 18 concurrent rendering support.
|
|
10
10
|
* @param sub the observable or computed to subscribe to
|
|
11
|
-
* @param deps an array of dependencies
|
|
11
|
+
* @param deps an array of dependencies that will cause re-subscription when changed
|
|
12
12
|
* @returns the current value of the observable or computed and a function to set the value
|
|
13
13
|
*/
|
|
14
14
|
export function useObservable<T>(sub: Observable<T> | T, deps: React.DependencyList = []): [T, (value: T) => void] {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Create memoized subscribe function that adapts Observable's subscribe API to useSyncExternalStore's expected signature
|
|
16
|
+
const subscribe = useCallback((onStoreChange: () => void) => {
|
|
17
17
|
if (!isSubscribable(sub)) {
|
|
18
|
-
return
|
|
19
|
-
|
|
20
|
-
const subscription = sub.subscribe(() => {
|
|
21
|
-
const newData = sub();
|
|
22
|
-
// @ts-ignore
|
|
23
|
-
if (isArray(newData) && newData === data) {
|
|
24
|
-
// @ts-ignore
|
|
25
|
-
setData([...newData])
|
|
26
|
-
} else {
|
|
27
|
-
setData(newData)
|
|
28
|
-
}
|
|
29
|
-
})
|
|
30
|
-
// the data might change _after_ useObservable is called but _before_ the subscription has been created
|
|
31
|
-
// this checks for that and updates the state with the new data if necessary
|
|
32
|
-
const newData = sub();
|
|
33
|
-
if (!isEqual(data, newData)) {
|
|
34
|
-
setData(newData);
|
|
18
|
+
// If not subscribable, return a no-op unsubscribe
|
|
19
|
+
return () => {};
|
|
35
20
|
}
|
|
21
|
+
const subscription = sub.subscribe(onStoreChange);
|
|
36
22
|
return () => subscription.dispose();
|
|
37
|
-
}, deps);
|
|
23
|
+
}, [sub, ...deps]);
|
|
38
24
|
|
|
39
|
-
|
|
40
|
-
|
|
25
|
+
// getSnapshot returns the current value from the observable
|
|
26
|
+
const getSnapshot = useCallback(() => {
|
|
27
|
+
return unwrapObservable(sub);
|
|
28
|
+
}, [sub, ...deps]);
|
|
29
|
+
|
|
30
|
+
// Use useSyncExternalStore for concurrent-safe subscription
|
|
31
|
+
const data = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
32
|
+
|
|
33
|
+
// Create setter that updates both local understanding and the observable
|
|
34
|
+
const setter = useCallback((newData: T) => {
|
|
41
35
|
if (isSubscribable(sub)) {
|
|
42
36
|
sub(newData);
|
|
43
37
|
}
|
|
44
|
-
}];
|
|
38
|
+
}, [sub]);
|
|
39
|
+
|
|
40
|
+
return [data, setter];
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
/**
|
|
@@ -120,27 +116,3 @@ export function useOnScreen(ref: React.RefObject<any>) {
|
|
|
120
116
|
|
|
121
117
|
return isIntersecting
|
|
122
118
|
}
|
|
123
|
-
|
|
124
|
-
// /**
|
|
125
|
-
// * This creates an observable that will automatically persist its value between page reloads using localStorage.
|
|
126
|
-
// * If localStorage is not available, the observable will not persist its value.
|
|
127
|
-
// * @param initialValue the initial value of the observable
|
|
128
|
-
// * @param globalName the name to use when storing the value in localStorage
|
|
129
|
-
// * @returns the observable that will persist between page reloads
|
|
130
|
-
// */
|
|
131
|
-
// export function persistentValue<T>(initialValue: T, globalName: string): Observable<T | undefined> {
|
|
132
|
-
// let q = observable<T>();
|
|
133
|
-
// if (typeof localStorage === 'undefined') {
|
|
134
|
-
// return q;
|
|
135
|
-
// }
|
|
136
|
-
// q.subscribe(newVal => {
|
|
137
|
-
// localStorage.setItem(globalName, JSON.stringify(toJSON(newVal)))
|
|
138
|
-
// })
|
|
139
|
-
// const existing = localStorage.getItem(globalName);
|
|
140
|
-
// if (existing) {
|
|
141
|
-
// q(fromJSON(JSON.parse(existing)))
|
|
142
|
-
// } else {
|
|
143
|
-
// q(initialValue);
|
|
144
|
-
// }
|
|
145
|
-
// return q;
|
|
146
|
-
// }
|
|
@@ -9,6 +9,8 @@ import { colorMode } from '../settings/color-mode-dropdown';
|
|
|
9
9
|
import { LogDisplay } from './log-display';
|
|
10
10
|
import { LogFilters } from './log-filters';
|
|
11
11
|
import { ResizableTableHeader } from './resizable-table-header';
|
|
12
|
+
import { isDesktop } from '../../globals';
|
|
13
|
+
import { MobileLogCard } from './mobile-log-card';
|
|
12
14
|
|
|
13
15
|
const windowHeight = () => window.innerHeight;
|
|
14
16
|
|
|
@@ -37,7 +39,9 @@ export const ConsoleLogsList = () => {
|
|
|
37
39
|
const [totalLogCount, setTotalLogCount] = useState<number>(0);
|
|
38
40
|
const [_colorMode] = useObservable(colorMode);
|
|
39
41
|
const logsEndRef = React.useRef<HTMLDivElement>(null);
|
|
40
|
-
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
42
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
43
|
+
useObservable(isDesktop);
|
|
44
|
+
const isMobile = !isDesktop();
|
|
41
45
|
|
|
42
46
|
const batchSize = 50;
|
|
43
47
|
|
|
@@ -226,12 +230,14 @@ export const ConsoleLogsList = () => {
|
|
|
226
230
|
overflow: 'hidden',
|
|
227
231
|
}}
|
|
228
232
|
>
|
|
229
|
-
{/* Resizable table header outside scroll area */}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
{/* Desktop: Resizable table header outside scroll area */}
|
|
234
|
+
{!isMobile && (
|
|
235
|
+
<ResizableTableHeader
|
|
236
|
+
columns={columns}
|
|
237
|
+
onColumnsChange={setColumns}
|
|
238
|
+
colorMode={_colorMode}
|
|
239
|
+
/>
|
|
240
|
+
)}
|
|
235
241
|
|
|
236
242
|
{/* Scrollable content area */}
|
|
237
243
|
<div
|
|
@@ -267,11 +273,18 @@ export const ConsoleLogsList = () => {
|
|
|
267
273
|
<i className="bi bi-inbox display-1"></i>
|
|
268
274
|
<p className="mt-2">No logs found</p>
|
|
269
275
|
</div>
|
|
276
|
+
) : isMobile ? (
|
|
277
|
+
/* Mobile: Card-based layout */
|
|
278
|
+
<div>
|
|
279
|
+
{_logs.map((log) => (
|
|
280
|
+
<MobileLogCard key={log.logId} log={log} colorMode={_colorMode} />
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
270
283
|
) : (
|
|
284
|
+
/* Desktop: Table layout */
|
|
271
285
|
<table className="table table-sm table-hover mb-0" style={{ fontSize: '0.85rem', tableLayout: 'fixed', width: '100%' }}>
|
|
272
286
|
<tbody>
|
|
273
287
|
{_logs.map((log) => (
|
|
274
|
-
// log.logId
|
|
275
288
|
<LogDisplay key={log.logId} log={log} columns={columns} />
|
|
276
289
|
))}
|
|
277
290
|
</tbody>
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { fromJSONString, IConsoleLog } from "@peers-app/peers-sdk";
|
|
2
|
+
import moment from 'moment';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
interface MobileLogCardProps {
|
|
6
|
+
log: IConsoleLog;
|
|
7
|
+
colorMode: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const getLevelColor = (level: string) => {
|
|
11
|
+
switch (level) {
|
|
12
|
+
case 'error': return '#dc3545';
|
|
13
|
+
case 'warn': return '#ffc107';
|
|
14
|
+
case 'info': return '#0dcaf0';
|
|
15
|
+
case 'log': return '#6c757d';
|
|
16
|
+
case 'debug': return '#6c757d';
|
|
17
|
+
default: return '#6c757d';
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const getProcessColor = (process: string) => {
|
|
22
|
+
switch (process) {
|
|
23
|
+
case 'ui':
|
|
24
|
+
case 'renderer': return '#0dcaf0';
|
|
25
|
+
case 'electron':
|
|
26
|
+
case 'main': return '#d946ef';
|
|
27
|
+
case 'react-native': return '#61dafb';
|
|
28
|
+
default: return '#6c757d';
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const getLevelIcon = (level: string) => {
|
|
33
|
+
switch (level) {
|
|
34
|
+
case 'error': return 'bi-x-circle-fill';
|
|
35
|
+
case 'warn': return 'bi-exclamation-triangle-fill';
|
|
36
|
+
case 'info': return 'bi-info-circle-fill';
|
|
37
|
+
case 'debug': return 'bi-bug-fill';
|
|
38
|
+
default: return 'bi-chat-square-text';
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const MobileLogCard = ({ log, colorMode }: MobileLogCardProps) => {
|
|
43
|
+
const isDark = colorMode === 'dark';
|
|
44
|
+
|
|
45
|
+
// Parse context if it's a string
|
|
46
|
+
let context = log.context;
|
|
47
|
+
if (typeof context === 'string') {
|
|
48
|
+
try {
|
|
49
|
+
context = fromJSONString(context);
|
|
50
|
+
} catch (err) {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
style={{
|
|
56
|
+
padding: '10px 12px',
|
|
57
|
+
borderBottom: `1px solid ${isDark ? '#333' : '#e9ecef'}`,
|
|
58
|
+
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{/* Header row: timestamp, level, process */}
|
|
62
|
+
<div className="d-flex align-items-center gap-2 mb-1" style={{ flexWrap: 'wrap' }}>
|
|
63
|
+
<span style={{ fontSize: '0.7rem', color: isDark ? '#888' : '#666' }}>
|
|
64
|
+
{moment(log.timestamp).format('HH:mm:ss.SSS')}
|
|
65
|
+
</span>
|
|
66
|
+
<span
|
|
67
|
+
className="badge text-white"
|
|
68
|
+
style={{
|
|
69
|
+
backgroundColor: getLevelColor(log.level),
|
|
70
|
+
fontSize: '0.65rem',
|
|
71
|
+
padding: '2px 6px'
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<i className={`${getLevelIcon(log.level)} me-1`}></i>
|
|
75
|
+
{log.level}
|
|
76
|
+
</span>
|
|
77
|
+
<span
|
|
78
|
+
className="badge text-white"
|
|
79
|
+
style={{
|
|
80
|
+
backgroundColor: getProcessColor(log.process),
|
|
81
|
+
fontSize: '0.65rem',
|
|
82
|
+
padding: '2px 6px'
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
{log.process}
|
|
86
|
+
</span>
|
|
87
|
+
{log.source && (
|
|
88
|
+
<span style={{ fontSize: '0.65rem', color: isDark ? '#666' : '#999' }}>
|
|
89
|
+
{log.source}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Message */}
|
|
95
|
+
<div style={{
|
|
96
|
+
fontSize: '0.8rem',
|
|
97
|
+
wordBreak: 'break-word',
|
|
98
|
+
color: isDark ? '#e0e0e0' : '#333'
|
|
99
|
+
}}>
|
|
100
|
+
{log.message}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{/* Context (collapsible) */}
|
|
104
|
+
{context && (
|
|
105
|
+
<details className="mt-1">
|
|
106
|
+
<summary
|
|
107
|
+
className="text-muted"
|
|
108
|
+
style={{ cursor: 'pointer', fontSize: '0.7rem' }}
|
|
109
|
+
>
|
|
110
|
+
<i className="bi bi-code-square me-1"></i>
|
|
111
|
+
Context
|
|
112
|
+
</summary>
|
|
113
|
+
<pre
|
|
114
|
+
className="mt-1 p-2 rounded"
|
|
115
|
+
style={{
|
|
116
|
+
fontSize: '0.65rem',
|
|
117
|
+
backgroundColor: isDark ? '#252525' : '#f8f9fa',
|
|
118
|
+
color: isDark ? '#aaa' : '#333',
|
|
119
|
+
overflow: 'auto',
|
|
120
|
+
maxHeight: '150px'
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
{JSON.stringify(context, null, 2)}
|
|
124
|
+
</pre>
|
|
125
|
+
</details>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{/* Stack trace (collapsible) */}
|
|
129
|
+
{log.stackTrace && (
|
|
130
|
+
<details className="mt-1">
|
|
131
|
+
<summary
|
|
132
|
+
className="text-danger"
|
|
133
|
+
style={{ cursor: 'pointer', fontSize: '0.7rem' }}
|
|
134
|
+
>
|
|
135
|
+
<i className="bi bi-bug me-1"></i>
|
|
136
|
+
Stack Trace
|
|
137
|
+
</summary>
|
|
138
|
+
<pre
|
|
139
|
+
className="mt-1 p-2 rounded text-danger"
|
|
140
|
+
style={{
|
|
141
|
+
fontSize: '0.6rem',
|
|
142
|
+
backgroundColor: isDark ? '#2a1a1a' : '#fff0f0',
|
|
143
|
+
overflow: 'auto',
|
|
144
|
+
maxHeight: '150px'
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
{log.stackTrace}
|
|
148
|
+
</pre>
|
|
149
|
+
</details>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
@@ -13,6 +13,7 @@ export const SettingsPage: React.FC = () => {
|
|
|
13
13
|
<PackagesRootDirectory />
|
|
14
14
|
<ReloadPackagesOnPageRefresh />
|
|
15
15
|
<ResetDeviceSyncInfos />
|
|
16
|
+
<DeleteLocalDatabase />
|
|
16
17
|
</div>
|
|
17
18
|
);
|
|
18
19
|
};
|
|
@@ -144,4 +145,30 @@ const ResetDeviceSyncInfos: React.FC = () => {
|
|
|
144
145
|
</button>
|
|
145
146
|
</div>
|
|
146
147
|
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const DeleteLocalDatabase: React.FC = () => {
|
|
151
|
+
return (
|
|
152
|
+
<div className='mt-3'>
|
|
153
|
+
<button
|
|
154
|
+
className='btn btn-danger btn-sm ms-2'
|
|
155
|
+
onClick={async () => {
|
|
156
|
+
const confirmed = confirm(
|
|
157
|
+
'Are you sure you want to delete the local database for the current group? ' +
|
|
158
|
+
'This will remove all local data. You will need to restart the app and re-sync from other devices.'
|
|
159
|
+
);
|
|
160
|
+
if (!confirmed) return;
|
|
161
|
+
try {
|
|
162
|
+
await rpcServerCalls.deleteLocalDatabase();
|
|
163
|
+
alert('Local database has been deleted. Please restart the app.');
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error('Error while deleting local database', err);
|
|
166
|
+
alert('Failed to delete local database: ' + (err as Error).message);
|
|
167
|
+
}
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
Delete Local Database
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
147
174
|
}
|
package/src/setupTests.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import '@testing-library/jest-dom';
|
|
2
|
+
import { TextEncoder, TextDecoder } from 'util';
|
|
3
|
+
|
|
4
|
+
// Polyfill TextEncoder/TextDecoder for jsdom environment
|
|
5
|
+
global.TextEncoder = TextEncoder;
|
|
6
|
+
global.TextDecoder = TextDecoder as any;
|
|
2
7
|
|
|
3
8
|
// Mock IntersectionObserver
|
|
4
9
|
(global as any).IntersectionObserver = class {
|
package/tsconfig.json
CHANGED
|
@@ -9,16 +9,14 @@
|
|
|
9
9
|
"strict": true,
|
|
10
10
|
"noImplicitAny": true,
|
|
11
11
|
"strictNullChecks": true,
|
|
12
|
-
"esModuleInterop": true
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"types": ["jest", "node"]
|
|
13
14
|
},
|
|
14
15
|
"include": [
|
|
15
16
|
"src/**/*.ts",
|
|
16
|
-
"src/**/*.tsx"
|
|
17
|
+
"src/**/*.tsx"
|
|
17
18
|
],
|
|
18
19
|
"exclude": [
|
|
19
|
-
"src/**/__tests__/**/*",
|
|
20
|
-
"src/**/*.test.*",
|
|
21
|
-
"src/**/*.spec.*",
|
|
22
20
|
"node_modules"
|
|
23
21
|
]
|
|
24
22
|
}
|