@rozenite/storage-plugin 1.13.0 → 2.1.0
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/CHANGELOG.md +46 -0
- package/README.md +6 -1
- package/dist/devtools/assets/panel-BlDRXgVW.js +32 -0
- package/dist/devtools/assets/panel-DBweZnW5.css +1 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/async-storage.require.cjs +1 -1
- package/dist/react-native/chunks/async-storage.require.js +8 -7
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +70 -66
- package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
- package/dist/react-native/chunks/secure-storage.require.js +17 -13
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -344
- package/dist/react-native/index.d.ts +166 -18
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.cjs +1 -1
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.js +21 -12
- package/package.json +35 -37
- package/react-native.ts +12 -22
- package/rozenite.config.ts +74 -0
- package/sdk.ts +1 -5
- package/src/react-native/__tests__/entry-preview-pagination.test.ts +234 -0
- package/src/react-native/__tests__/export-snapshot.test.ts +138 -0
- package/src/react-native/__tests__/full-entry-request.test.ts +143 -0
- package/src/react-native/__tests__/import.test.ts +174 -11
- package/src/react-native/__tests__/storage-discovery.test.ts +144 -0
- package/src/react-native/__tests__/storage-view.test.ts +172 -0
- package/src/react-native/__tests__/stress-verification.test.ts +140 -0
- package/src/react-native/__tests__/use-storage-agent-tools.test.ts +221 -0
- package/src/react-native/adapters/__tests__/mmkv.test.ts +20 -30
- package/src/react-native/adapters/async-storage.ts +8 -9
- package/src/react-native/adapters/index.ts +2 -8
- package/src/react-native/adapters/mmkv.ts +12 -24
- package/src/react-native/adapters/secure-storage.ts +4 -0
- package/src/react-native/entry-preview-pagination.ts +261 -0
- package/src/react-native/export-snapshot.ts +88 -0
- package/src/react-native/full-entry-request.ts +91 -0
- package/src/react-native/import.ts +179 -16
- package/src/react-native/storage-discovery.ts +92 -0
- package/src/react-native/storage-view.ts +47 -109
- package/src/react-native/useRozeniteStoragePlugin.ts +153 -129
- package/src/react-native/useStorageAgentTools.ts +196 -91
- package/src/shared/__tests__/entry-preview.test.ts +99 -0
- package/src/shared/__tests__/snapshot.test.ts +31 -32
- package/src/shared/agent-tools.ts +33 -38
- package/src/shared/entry-preview.ts +54 -0
- package/src/shared/messaging.ts +140 -21
- package/src/shared/snapshot.ts +12 -18
- package/src/shared/types.ts +21 -4
- package/src/ui/__tests__/binary-value-editor-state.test.ts +16 -13
- package/src/ui/__tests__/binary.test.ts +31 -33
- package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
- package/src/ui/__tests__/large-value-viewer.test.tsx +54 -0
- package/src/ui/__tests__/panel.test.tsx +382 -0
- package/src/ui/__tests__/storage-groups.test.ts +88 -0
- package/src/ui/__tests__/type-conversion.test.ts +8 -24
- package/src/ui/__tests__/use-storage-entries.test.tsx +337 -0
- package/src/ui/add-entry-dialog.tsx +66 -117
- package/src/ui/binary-value-editor-state.ts +16 -25
- package/src/ui/binary-value-editor.tsx +59 -47
- package/src/ui/binary.ts +54 -15
- package/src/ui/edit-entry-dialog.tsx +68 -104
- package/src/ui/editor-switcher.tsx +9 -16
- package/src/ui/entry-detail-dialog.tsx +62 -181
- package/src/ui/format-value.tsx +25 -0
- package/src/ui/globals.css +1 -123
- package/src/ui/import-dialog.tsx +72 -109
- package/src/ui/large-value-viewer-state.ts +25 -0
- package/src/ui/large-value-viewer.tsx +65 -0
- package/src/ui/panel.tsx +649 -509
- package/src/ui/query-client.tsx +23 -0
- package/src/ui/storage-groups.ts +66 -0
- package/src/ui/type-conversion.ts +2 -5
- package/src/ui/typed-value-editor.tsx +15 -20
- package/src/ui/use-storage-entries.ts +192 -0
- package/src/ui/utils.ts +1 -4
- package/tsconfig.json +5 -1
- package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
- package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
- package/postcss.config.js +0 -6
- package/src/ui/confirm-dialog.tsx +0 -100
- package/src/ui/editable-table.tsx +0 -300
- package/tailwind.config.ts +0 -94
|
@@ -25,13 +25,9 @@ describe('convertValue', () => {
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
it('UTF-8 encodes a string to a byte array', () => {
|
|
28
|
-
expect(convertValue('string', 'buffer', 'hello')).toEqual([
|
|
29
|
-
0x68, 0x65, 0x6c, 0x6c, 0x6f,
|
|
30
|
-
]);
|
|
28
|
+
expect(convertValue('string', 'buffer', 'hello')).toEqual([0x68, 0x65, 0x6c, 0x6c, 0x6f]);
|
|
31
29
|
// 'é' is two bytes in UTF-8: 0xC3 0xA9.
|
|
32
|
-
expect(convertValue('string', 'buffer', 'café')).toEqual([
|
|
33
|
-
0x63, 0x61, 0x66, 0xc3, 0xa9,
|
|
34
|
-
]);
|
|
30
|
+
expect(convertValue('string', 'buffer', 'café')).toEqual([0x63, 0x61, 0x66, 0xc3, 0xa9]);
|
|
35
31
|
expect(convertValue('string', 'buffer', '')).toEqual([]);
|
|
36
32
|
});
|
|
37
33
|
});
|
|
@@ -66,20 +62,14 @@ describe('convertValue', () => {
|
|
|
66
62
|
});
|
|
67
63
|
|
|
68
64
|
it('encodes the literal "true" / "false" bytes for buffer conversion', () => {
|
|
69
|
-
expect(convertValue('boolean', 'buffer', true)).toEqual([
|
|
70
|
-
|
|
71
|
-
]);
|
|
72
|
-
expect(convertValue('boolean', 'buffer', false)).toEqual([
|
|
73
|
-
0x66, 0x61, 0x6c, 0x73, 0x65,
|
|
74
|
-
]);
|
|
65
|
+
expect(convertValue('boolean', 'buffer', true)).toEqual([0x74, 0x72, 0x75, 0x65]);
|
|
66
|
+
expect(convertValue('boolean', 'buffer', false)).toEqual([0x66, 0x61, 0x6c, 0x73, 0x65]);
|
|
75
67
|
});
|
|
76
68
|
});
|
|
77
69
|
|
|
78
70
|
describe('from buffer', () => {
|
|
79
71
|
it('UTF-8 decodes bytes back to a string (the headline round-trip)', () => {
|
|
80
|
-
expect(
|
|
81
|
-
convertValue('buffer', 'string', [0x68, 0x65, 0x6c, 0x6c, 0x6f]),
|
|
82
|
-
).toBe('hello');
|
|
72
|
+
expect(convertValue('buffer', 'string', [0x68, 0x65, 0x6c, 0x6c, 0x6f])).toBe('hello');
|
|
83
73
|
});
|
|
84
74
|
|
|
85
75
|
it('returns an empty string when bytes are not valid UTF-8', () => {
|
|
@@ -88,19 +78,13 @@ describe('convertValue', () => {
|
|
|
88
78
|
|
|
89
79
|
it('decodes then parses to number, falls back to 0 on failure', () => {
|
|
90
80
|
expect(convertValue('buffer', 'number', [0x34, 0x32])).toBe(42); // "42"
|
|
91
|
-
expect(
|
|
92
|
-
convertValue('buffer', 'number', [0x68, 0x65, 0x6c, 0x6c, 0x6f]),
|
|
93
|
-
).toBe(0); // "hello" → NaN → 0
|
|
81
|
+
expect(convertValue('buffer', 'number', [0x68, 0x65, 0x6c, 0x6c, 0x6f])).toBe(0); // "hello" → NaN → 0
|
|
94
82
|
expect(convertValue('buffer', 'number', [0xff])).toBe(0); // invalid UTF-8 → 0
|
|
95
83
|
});
|
|
96
84
|
|
|
97
85
|
it('decodes then matches "true" for boolean conversion', () => {
|
|
98
|
-
expect(convertValue('buffer', 'boolean', [0x74, 0x72, 0x75, 0x65])).toBe(
|
|
99
|
-
|
|
100
|
-
);
|
|
101
|
-
expect(
|
|
102
|
-
convertValue('buffer', 'boolean', [0x66, 0x61, 0x6c, 0x73, 0x65]),
|
|
103
|
-
).toBe(false);
|
|
86
|
+
expect(convertValue('buffer', 'boolean', [0x74, 0x72, 0x75, 0x65])).toBe(true);
|
|
87
|
+
expect(convertValue('buffer', 'boolean', [0x66, 0x61, 0x6c, 0x73, 0x65])).toBe(false);
|
|
104
88
|
expect(convertValue('buffer', 'boolean', [0xff])).toBe(false);
|
|
105
89
|
});
|
|
106
90
|
});
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
4
|
+
import { act } from 'react';
|
|
5
|
+
import { createRoot } from 'react-dom/client';
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import type { RozeniteDevToolsRequestClient } from '@rozenite/plugin-bridge';
|
|
8
|
+
import type { StorageEventMap } from '../../shared/messaging';
|
|
9
|
+
import type { StorageTarget } from '../../shared/types';
|
|
10
|
+
import { createStorageQueryClient } from '../query-client';
|
|
11
|
+
import {
|
|
12
|
+
resetSelectedStorageQueries,
|
|
13
|
+
dropStorageFullEntries,
|
|
14
|
+
invalidateStorageEntryPreviews,
|
|
15
|
+
storageEntryPreviewQueryKey,
|
|
16
|
+
useStorageEntryPreviews,
|
|
17
|
+
useStorageFullEntry,
|
|
18
|
+
} from '../use-storage-entries';
|
|
19
|
+
|
|
20
|
+
declare global {
|
|
21
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const target: StorageTarget = { adapterId: 'mmkv', storageId: 'default' };
|
|
25
|
+
const otherTarget: StorageTarget = { adapterId: 'async', storageId: 'other' };
|
|
26
|
+
|
|
27
|
+
const response = (
|
|
28
|
+
cursor: string | undefined,
|
|
29
|
+
options: { next?: string; previous?: string } = {},
|
|
30
|
+
) => ({
|
|
31
|
+
type: 'entry-previews' as const,
|
|
32
|
+
requestId: `request-${cursor ?? 'first'}`,
|
|
33
|
+
target,
|
|
34
|
+
items: [
|
|
35
|
+
{
|
|
36
|
+
key: cursor ?? 'first',
|
|
37
|
+
type: 'string' as const,
|
|
38
|
+
preview: cursor ?? 'first',
|
|
39
|
+
valueSize: (cursor ?? 'first').length,
|
|
40
|
+
isTruncated: false,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
nextCursor: options.next,
|
|
44
|
+
previousCursor: options.previous,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
type PreviewProps = Parameters<typeof useStorageEntryPreviews>[0];
|
|
48
|
+
|
|
49
|
+
const renderPreviewHook = async (initialProps: PreviewProps) => {
|
|
50
|
+
const queryClient = createStorageQueryClient();
|
|
51
|
+
let result: ReturnType<typeof useStorageEntryPreviews> | undefined;
|
|
52
|
+
const container = document.createElement('div');
|
|
53
|
+
const root = createRoot(container);
|
|
54
|
+
|
|
55
|
+
const Hook = (props: PreviewProps) => {
|
|
56
|
+
result = useStorageEntryPreviews(props);
|
|
57
|
+
return null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const render = async (props: PreviewProps) => {
|
|
61
|
+
await act(async () => {
|
|
62
|
+
root.render(
|
|
63
|
+
<QueryClientProvider client={queryClient}>
|
|
64
|
+
<Hook {...props} />
|
|
65
|
+
</QueryClientProvider>,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
await render(initialProps);
|
|
71
|
+
return {
|
|
72
|
+
get result() {
|
|
73
|
+
if (!result) throw new Error('Hook did not render.');
|
|
74
|
+
return result;
|
|
75
|
+
},
|
|
76
|
+
queryClient,
|
|
77
|
+
rerender: render,
|
|
78
|
+
unmount: async () => {
|
|
79
|
+
await act(async () => root.unmount());
|
|
80
|
+
container.remove();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const asClient = (request: ReturnType<typeof vi.fn>) =>
|
|
86
|
+
({ request }) as unknown as RozeniteDevToolsRequestClient<StorageEventMap>;
|
|
87
|
+
|
|
88
|
+
describe('storage entry query hooks', () => {
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
document.body.innerHTML = '';
|
|
91
|
+
delete globalThis.IS_REACT_ACT_ENVIRONMENT;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('loads both directions and bounds retained pages', async () => {
|
|
95
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
96
|
+
const request = vi.fn(({ payload }) => {
|
|
97
|
+
if (payload.cursor === undefined)
|
|
98
|
+
return Promise.resolve(response(undefined, { next: 'next' }));
|
|
99
|
+
if (payload.cursor === 'next')
|
|
100
|
+
return Promise.resolve(response('next', { next: 'last', previous: 'first' }));
|
|
101
|
+
if (payload.cursor === 'last') return Promise.resolve(response('last', { previous: 'next' }));
|
|
102
|
+
return Promise.resolve(response('first', { next: 'next' }));
|
|
103
|
+
});
|
|
104
|
+
const hook = await renderPreviewHook({
|
|
105
|
+
client: asClient(request),
|
|
106
|
+
target,
|
|
107
|
+
search: '',
|
|
108
|
+
keySortDirection: 'ascending',
|
|
109
|
+
maxPages: 2,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await act(async () => {
|
|
113
|
+
await vi.waitFor(() => expect(hook.result.isSuccess).toBe(true));
|
|
114
|
+
});
|
|
115
|
+
await act(async () => hook.result.fetchNextPage());
|
|
116
|
+
await act(async () => hook.result.fetchNextPage());
|
|
117
|
+
expect(request.mock.calls.map(([options]) => options.payload.cursor)).toEqual([
|
|
118
|
+
undefined,
|
|
119
|
+
'next',
|
|
120
|
+
'last',
|
|
121
|
+
]);
|
|
122
|
+
const queryKey = storageEntryPreviewQueryKey(target, '', 'ascending');
|
|
123
|
+
const pageKeys = () =>
|
|
124
|
+
hook.queryClient
|
|
125
|
+
.getQueryData<{ pages: Array<ReturnType<typeof response>> }>(queryKey)
|
|
126
|
+
?.pages.map((page) => page.items[0].key);
|
|
127
|
+
await vi.waitFor(() => expect(pageKeys()).toEqual(['next', 'last']));
|
|
128
|
+
|
|
129
|
+
await act(async () => hook.result.fetchPreviousPage());
|
|
130
|
+
await vi.waitFor(() => expect(pageKeys()).toEqual(['first', 'next']));
|
|
131
|
+
await hook.unmount();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('cancels stale target requests and starts a new sequence at the first page', async () => {
|
|
135
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
136
|
+
let resolveFirst: ((value: ReturnType<typeof response>) => void) | undefined;
|
|
137
|
+
const request = vi.fn(({ payload }) => {
|
|
138
|
+
if (payload.target === target) {
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
resolveFirst = resolve;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return Promise.resolve({ ...response(undefined), target: otherTarget });
|
|
144
|
+
});
|
|
145
|
+
const hook = await renderPreviewHook({
|
|
146
|
+
client: asClient(request),
|
|
147
|
+
target,
|
|
148
|
+
search: 'one',
|
|
149
|
+
keySortDirection: 'ascending',
|
|
150
|
+
});
|
|
151
|
+
const firstSignal = request.mock.calls[0][0].signal as AbortSignal;
|
|
152
|
+
|
|
153
|
+
await hook.rerender({
|
|
154
|
+
client: asClient(request),
|
|
155
|
+
target: otherTarget,
|
|
156
|
+
search: 'two',
|
|
157
|
+
keySortDirection: 'descending',
|
|
158
|
+
});
|
|
159
|
+
expect(firstSignal.aborted).toBe(true);
|
|
160
|
+
expect(request.mock.calls[1][0].payload).toMatchObject({
|
|
161
|
+
cursor: undefined,
|
|
162
|
+
search: 'two',
|
|
163
|
+
keySortDirection: 'descending',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await act(async () => resolveFirst?.(response(undefined)));
|
|
167
|
+
await vi.waitFor(() =>
|
|
168
|
+
expect(
|
|
169
|
+
hook.queryClient.getQueryData<{
|
|
170
|
+
pages: Array<ReturnType<typeof response>>;
|
|
171
|
+
}>(storageEntryPreviewQueryKey(otherTarget, 'two', 'descending'))?.pages[0].target,
|
|
172
|
+
).toEqual(otherTarget),
|
|
173
|
+
);
|
|
174
|
+
await hook.unmount();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('keeps the previous pages while a search or sort change is loading', async () => {
|
|
178
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
179
|
+
let resolveNext: ((value: ReturnType<typeof response>) => void) | undefined;
|
|
180
|
+
const request = vi.fn(({ payload }) => {
|
|
181
|
+
if (payload.search === 'next') {
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
resolveNext = resolve;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return Promise.resolve(response(undefined));
|
|
187
|
+
});
|
|
188
|
+
const hook = await renderPreviewHook({
|
|
189
|
+
client: asClient(request),
|
|
190
|
+
target,
|
|
191
|
+
search: 'current',
|
|
192
|
+
keySortDirection: 'ascending',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await act(async () => {
|
|
196
|
+
await vi.waitFor(() => expect(hook.result.isSuccess).toBe(true));
|
|
197
|
+
});
|
|
198
|
+
expect(hook.result.data?.pages[0].items[0].key).toBe('first');
|
|
199
|
+
|
|
200
|
+
await hook.rerender({
|
|
201
|
+
client: asClient(request),
|
|
202
|
+
target,
|
|
203
|
+
search: 'next',
|
|
204
|
+
keySortDirection: 'descending',
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(hook.result.isFetching).toBe(true);
|
|
208
|
+
expect(hook.result.isPlaceholderData).toBe(true);
|
|
209
|
+
expect(hook.result.data?.pages[0].items[0].key).toBe('first');
|
|
210
|
+
expect(request.mock.calls[1][0].payload).toMatchObject({
|
|
211
|
+
search: 'next',
|
|
212
|
+
keySortDirection: 'descending',
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await act(async () => {
|
|
216
|
+
resolveNext?.(response('new'));
|
|
217
|
+
await vi.waitFor(() => expect(hook.result.isPlaceholderData).toBe(false));
|
|
218
|
+
});
|
|
219
|
+
expect(hook.result.data?.pages[0].items[0].key).toBe('new');
|
|
220
|
+
await hook.unmount();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('exposes request errors without retrying bridge work', async () => {
|
|
224
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
225
|
+
const request = vi.fn(() => Promise.reject(new Error('device failed')));
|
|
226
|
+
const hook = await renderPreviewHook({
|
|
227
|
+
client: asClient(request),
|
|
228
|
+
target,
|
|
229
|
+
search: '',
|
|
230
|
+
keySortDirection: 'ascending',
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await act(async () => {
|
|
234
|
+
await vi.waitFor(() => expect(hook.result.isError).toBe(true));
|
|
235
|
+
});
|
|
236
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
237
|
+
await hook.unmount();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('resets every selected-storage preview variant but refetches only the active first page', async () => {
|
|
241
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
242
|
+
const request = vi.fn(({ payload }) => Promise.resolve(response(payload.cursor)));
|
|
243
|
+
const hook = await renderPreviewHook({
|
|
244
|
+
client: asClient(request),
|
|
245
|
+
target,
|
|
246
|
+
search: 'active',
|
|
247
|
+
keySortDirection: 'ascending',
|
|
248
|
+
});
|
|
249
|
+
await act(async () => {
|
|
250
|
+
await vi.waitFor(() => expect(hook.result.isSuccess).toBe(true));
|
|
251
|
+
});
|
|
252
|
+
hook.queryClient.setQueryData(storageEntryPreviewQueryKey(target, 'inactive', 'ascending'), {
|
|
253
|
+
pages: [response(undefined)],
|
|
254
|
+
pageParams: [undefined],
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await act(async () => resetSelectedStorageQueries(hook.queryClient, target));
|
|
258
|
+
expect(request).toHaveBeenCalledTimes(2);
|
|
259
|
+
expect(
|
|
260
|
+
hook.queryClient.getQueryData(storageEntryPreviewQueryKey(target, 'inactive', 'ascending')),
|
|
261
|
+
).toBeUndefined();
|
|
262
|
+
await hook.unmount();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('invalidates only the changed storage preview prefix and drops its stale full value', async () => {
|
|
266
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
267
|
+
const request = vi.fn(({ payload }) => Promise.resolve(response(payload.cursor)));
|
|
268
|
+
const hook = await renderPreviewHook({
|
|
269
|
+
client: asClient(request),
|
|
270
|
+
target,
|
|
271
|
+
search: 'active',
|
|
272
|
+
keySortDirection: 'ascending',
|
|
273
|
+
});
|
|
274
|
+
await act(async () => {
|
|
275
|
+
await vi.waitFor(() => expect(hook.result.isSuccess).toBe(true));
|
|
276
|
+
});
|
|
277
|
+
const otherQueryKey = storageEntryPreviewQueryKey(otherTarget, 'other', 'ascending');
|
|
278
|
+
hook.queryClient.setQueryDefaults(otherQueryKey, { gcTime: Infinity });
|
|
279
|
+
hook.queryClient.setQueryData(otherQueryKey, {
|
|
280
|
+
pages: [response(undefined)],
|
|
281
|
+
pageParams: [undefined],
|
|
282
|
+
});
|
|
283
|
+
hook.queryClient.setQueryData(
|
|
284
|
+
['storage-full-entry', target.adapterId, target.storageId, 'changed'],
|
|
285
|
+
{ entry: { key: 'changed', type: 'string', value: 'stale full value' } },
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
await act(async () => {
|
|
289
|
+
await invalidateStorageEntryPreviews(hook.queryClient, target);
|
|
290
|
+
await dropStorageFullEntries(hook.queryClient, target, 'changed');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(request).toHaveBeenCalledTimes(2);
|
|
294
|
+
expect(hook.queryClient.getQueryData(otherQueryKey)).toBeDefined();
|
|
295
|
+
expect(
|
|
296
|
+
hook.queryClient.getQueryData([
|
|
297
|
+
'storage-full-entry',
|
|
298
|
+
target.adapterId,
|
|
299
|
+
target.storageId,
|
|
300
|
+
'changed',
|
|
301
|
+
]),
|
|
302
|
+
).toBeUndefined();
|
|
303
|
+
await hook.unmount();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('removes a full value when its interaction unmounts', async () => {
|
|
307
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
308
|
+
const queryClient = createStorageQueryClient();
|
|
309
|
+
const request = vi.fn(() =>
|
|
310
|
+
Promise.resolve({
|
|
311
|
+
type: 'entry' as const,
|
|
312
|
+
requestId: 'entry-1',
|
|
313
|
+
target,
|
|
314
|
+
entry: { key: 'large', type: 'string' as const, value: 'full value' },
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
const container = document.createElement('div');
|
|
318
|
+
const root = createRoot(container);
|
|
319
|
+
const Hook = () => {
|
|
320
|
+
useStorageFullEntry({ client: asClient(request), target, key: 'large' });
|
|
321
|
+
return null;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
await act(async () => {
|
|
325
|
+
root.render(
|
|
326
|
+
<QueryClientProvider client={queryClient}>
|
|
327
|
+
<Hook />
|
|
328
|
+
</QueryClientProvider>,
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
await act(async () => {
|
|
332
|
+
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
333
|
+
});
|
|
334
|
+
await act(async () => root.unmount());
|
|
335
|
+
expect(queryClient.getQueryCache().getAll()).toHaveLength(0);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
+
import { Button, ConfirmDialog, Dialog, Field, Input } from '@rozenite/ui';
|
|
1
2
|
import { useEffect, useState } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
4
|
-
StorageEntry,
|
|
5
|
-
StorageEntryType,
|
|
6
|
-
StorageEntryValue,
|
|
7
|
-
} from '../shared/types';
|
|
8
|
-
import { ConfirmDialog } from './confirm-dialog';
|
|
3
|
+
import type { StorageEntry, StorageEntryType, StorageEntryValue } from '../shared/types';
|
|
9
4
|
import { TypedValueEditor } from './typed-value-editor';
|
|
10
5
|
import { defaultValueForType } from './type-conversion';
|
|
11
6
|
|
|
@@ -50,11 +45,7 @@ export const AddEntryDialog = ({
|
|
|
50
45
|
const [currentValue, setCurrentValue] = useState<StorageEntryValue | null>(
|
|
51
46
|
defaultValueForType(initialType),
|
|
52
47
|
);
|
|
53
|
-
const [
|
|
54
|
-
isOpen: boolean;
|
|
55
|
-
title: string;
|
|
56
|
-
message: string;
|
|
57
|
-
}>({ isOpen: false, title: '', message: '' });
|
|
48
|
+
const [alert, setAlert] = useState<{ title: string; message: string } | null>(null);
|
|
58
49
|
|
|
59
50
|
// Reset state every time the dialog opens, so a previous session's
|
|
60
51
|
// type / value doesn't bleed in.
|
|
@@ -79,8 +70,7 @@ export const AddEntryDialog = ({
|
|
|
79
70
|
if (!newEntryKey.trim() || currentValue === null) return;
|
|
80
71
|
|
|
81
72
|
if (!isCurrentTypeSupported) {
|
|
82
|
-
|
|
83
|
-
isOpen: true,
|
|
73
|
+
setAlert({
|
|
84
74
|
title: 'Unsupported Type',
|
|
85
75
|
message: 'Selected type is not supported by this storage.',
|
|
86
76
|
});
|
|
@@ -88,8 +78,7 @@ export const AddEntryDialog = ({
|
|
|
88
78
|
}
|
|
89
79
|
|
|
90
80
|
if (existingKeys.includes(newEntryKey)) {
|
|
91
|
-
|
|
92
|
-
isOpen: true,
|
|
81
|
+
setAlert({
|
|
93
82
|
title: 'Key Already Exists',
|
|
94
83
|
message: 'An entry with this key already exists.',
|
|
95
84
|
});
|
|
@@ -101,120 +90,80 @@ export const AddEntryDialog = ({
|
|
|
101
90
|
};
|
|
102
91
|
|
|
103
92
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
104
|
-
if (event.key === '
|
|
105
|
-
resetAndClose();
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
if (
|
|
109
|
-
event.key === 'Enter' &&
|
|
110
|
-
newEntryKey.trim() &&
|
|
111
|
-
currentType !== 'buffer'
|
|
112
|
-
) {
|
|
93
|
+
if (event.key === 'Enter' && newEntryKey.trim() && currentType !== 'buffer') {
|
|
113
94
|
handleAdd();
|
|
114
95
|
}
|
|
115
96
|
};
|
|
116
97
|
|
|
117
|
-
if (!isOpen) {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
98
|
// Unsavable when no key, no supported type, or when the value is
|
|
122
99
|
// null — the hex editor signals invalid / empty hex via null.
|
|
123
|
-
const isAddDisabled =
|
|
124
|
-
!newEntryKey.trim() || !isCurrentTypeSupported || currentValue === null;
|
|
100
|
+
const isAddDisabled = !newEntryKey.trim() || !isCurrentTypeSupported || currentValue === null;
|
|
125
101
|
|
|
126
102
|
return (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
onClick={(event) => event.stopPropagation()}
|
|
134
|
-
onKeyDown={handleKeyDown}
|
|
103
|
+
<>
|
|
104
|
+
<Dialog
|
|
105
|
+
open={isOpen}
|
|
106
|
+
onOpenChange={(open) => {
|
|
107
|
+
if (!open) resetAndClose();
|
|
108
|
+
}}
|
|
135
109
|
>
|
|
136
|
-
<
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
110
|
+
<Dialog.Content onKeyDown={handleKeyDown}>
|
|
111
|
+
<Dialog.Header>
|
|
112
|
+
<Dialog.Title>Add New Entry</Dialog.Title>
|
|
113
|
+
</Dialog.Header>
|
|
114
|
+
|
|
115
|
+
<div className="flex flex-col gap-4">
|
|
116
|
+
<Field>
|
|
117
|
+
<Field.Label htmlFor="new-entry-key">Key</Field.Label>
|
|
118
|
+
<Input
|
|
119
|
+
id="new-entry-key"
|
|
120
|
+
value={newEntryKey}
|
|
121
|
+
onChange={(event) => setNewEntryKey(event.target.value)}
|
|
122
|
+
placeholder="Enter key name"
|
|
123
|
+
autoFocus
|
|
124
|
+
/>
|
|
125
|
+
</Field>
|
|
126
|
+
|
|
127
|
+
<Field>
|
|
128
|
+
<Field.Label htmlFor="new-entry-value">Value</Field.Label>
|
|
129
|
+
<TypedValueEditor
|
|
130
|
+
supportedTypes={supportedTypes}
|
|
131
|
+
type={currentType}
|
|
132
|
+
value={currentValue}
|
|
133
|
+
onChange={(nextType, nextValue) => {
|
|
134
|
+
setCurrentType(nextType);
|
|
135
|
+
setCurrentValue(nextValue);
|
|
136
|
+
}}
|
|
137
|
+
inputId="new-entry-value"
|
|
138
|
+
/>
|
|
139
|
+
{!isCurrentTypeSupported && (
|
|
140
|
+
<Field.Description className="text-destructive">
|
|
141
|
+
Selected type is not supported by this storage.
|
|
142
|
+
</Field.Description>
|
|
143
|
+
)}
|
|
144
|
+
</Field>
|
|
164
145
|
</div>
|
|
165
146
|
|
|
166
|
-
<
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
>
|
|
171
|
-
|
|
172
|
-
</
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
value={currentValue}
|
|
177
|
-
onChange={(nextType, nextValue) => {
|
|
178
|
-
setCurrentType(nextType);
|
|
179
|
-
setCurrentValue(nextValue);
|
|
180
|
-
}}
|
|
181
|
-
inputId="new-entry-value"
|
|
182
|
-
/>
|
|
183
|
-
{!isCurrentTypeSupported && (
|
|
184
|
-
<p className="text-xs text-amber-400 mt-1">
|
|
185
|
-
Selected type is not supported by this storage.
|
|
186
|
-
</p>
|
|
187
|
-
)}
|
|
188
|
-
</div>
|
|
189
|
-
</div>
|
|
190
|
-
|
|
191
|
-
<div className="flex items-center justify-end gap-2 mt-6">
|
|
192
|
-
<button
|
|
193
|
-
onClick={resetAndClose}
|
|
194
|
-
className="px-4 py-2 text-sm text-gray-300 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
195
|
-
>
|
|
196
|
-
Cancel
|
|
197
|
-
</button>
|
|
198
|
-
<button
|
|
199
|
-
onClick={handleAdd}
|
|
200
|
-
disabled={isAddDisabled}
|
|
201
|
-
className="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded transition-colors"
|
|
202
|
-
>
|
|
203
|
-
Add Entry
|
|
204
|
-
</button>
|
|
205
|
-
</div>
|
|
206
|
-
</div>
|
|
147
|
+
<Dialog.Footer>
|
|
148
|
+
<Button variant="outline" onClick={resetAndClose}>
|
|
149
|
+
Cancel
|
|
150
|
+
</Button>
|
|
151
|
+
<Button onClick={handleAdd} disabled={isAddDisabled}>
|
|
152
|
+
Add Entry
|
|
153
|
+
</Button>
|
|
154
|
+
</Dialog.Footer>
|
|
155
|
+
</Dialog.Content>
|
|
156
|
+
</Dialog>
|
|
207
157
|
|
|
208
158
|
<ConfirmDialog
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
title={
|
|
215
|
-
|
|
216
|
-
type="alert"
|
|
159
|
+
open={alert !== null}
|
|
160
|
+
onOpenChange={(open) => {
|
|
161
|
+
if (!open) setAlert(null);
|
|
162
|
+
}}
|
|
163
|
+
variant="alert"
|
|
164
|
+
title={alert?.title ?? ''}
|
|
165
|
+
description={alert?.message}
|
|
217
166
|
/>
|
|
218
|
-
|
|
167
|
+
</>
|
|
219
168
|
);
|
|
220
169
|
};
|