@webitel/ui-datalist 1.1.67 → 1.1.69
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/package.json +1 -1
- package/src/modules/filters/__tests__/createTableFiltersStore.spec.ts +14 -0
- package/src/modules/filters/createTableFiltersStore.ts +2 -0
- package/src/modules/headers/__tests__/createTableHeadersStore.spec.ts +144 -1
- package/src/modules/headers/createTableHeadersStore.ts +23 -5
- package/src/modules/persist/PersistedStorage.types.ts +1 -0
- package/src/modules/persist/__tests__/usePersistedStorage.spec.ts +162 -3
- package/src/modules/persist/__tests__/useWebStoragePersistedStorage.spec.ts +107 -0
- package/src/modules/persist/useLocalStoragePersistedStorage.ts +5 -26
- package/src/modules/persist/usePersistedStorage.ts +27 -12
- package/src/modules/persist/useSessionStoragePersistedStorage.ts +13 -0
- package/src/modules/persist/useWebStoragePersistedStorage.ts +37 -0
- package/src/modules/types/tableStore.types.ts +7 -0
- package/types/.tsbuildinfo +1 -1
- package/types/modules/filter-presets/stores/createFilterPresetsStore.d.ts +3 -0
- package/types/modules/headers/createTableHeadersStore.d.ts +6 -0
- package/types/modules/permissions-page/stores/createPermissionsStore.d.ts +6 -0
- package/types/modules/persist/PersistedStorage.types.d.ts +2 -1
- package/types/modules/persist/useSessionStoragePersistedStorage.d.ts +4 -0
- package/types/modules/persist/useWebStoragePersistedStorage.d.ts +5 -0
- package/types/modules/table/createTableStore.store.d.ts +6 -0
- package/types/modules/types/tableStore.types.d.ts +7 -0
- package/src/modules/persist/__tests__/useLocalStoragePersistedStorage.spec.ts +0 -66
package/package.json
CHANGED
|
@@ -22,6 +22,7 @@ describe('tableFiltersStoreBody', () => {
|
|
|
22
22
|
|
|
23
23
|
beforeEach(async () => {
|
|
24
24
|
localStorage.clear();
|
|
25
|
+
sessionStorage.clear();
|
|
25
26
|
|
|
26
27
|
router = createRouter({
|
|
27
28
|
history: createMemoryHistory(),
|
|
@@ -106,6 +107,19 @@ describe('tableFiltersStoreBody', () => {
|
|
|
106
107
|
expect(router.currentRoute.value.query.filters).toContain('open');
|
|
107
108
|
});
|
|
108
109
|
|
|
110
|
+
it('stores filters under the store namespace, not a shared key', async () => {
|
|
111
|
+
const store = await setUpStore();
|
|
112
|
+
|
|
113
|
+
store.addFilter({
|
|
114
|
+
name: 'status',
|
|
115
|
+
value: 'open',
|
|
116
|
+
});
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
118
|
+
|
|
119
|
+
expect(sessionStorage.getItem(`${namespace}/filters`)).toContain('open');
|
|
120
|
+
expect(sessionStorage.getItem('/filters')).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
109
123
|
it('keeps searchMode out of the route query', async () => {
|
|
110
124
|
const store = await setUpStore();
|
|
111
125
|
|
|
@@ -55,7 +55,9 @@ export const tableFiltersStoreBody = (
|
|
|
55
55
|
|
|
56
56
|
storages: [
|
|
57
57
|
PersistedStorageType.Route,
|
|
58
|
+
PersistedStorageType.SessionStorage,
|
|
58
59
|
],
|
|
60
|
+
storagePath: namespace,
|
|
59
61
|
|
|
60
62
|
/* use custom .toString() logic, provided by FiltersManager */
|
|
61
63
|
onStore: async (save, { name }) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
-
import { createApp } from 'vue';
|
|
2
|
+
import { createApp, ref } from 'vue';
|
|
3
3
|
import { createMemoryHistory, createRouter, type Router } from 'vue-router';
|
|
4
4
|
|
|
5
5
|
import type { DatalistTableHeader } from '../../types/tableStore.types';
|
|
@@ -141,6 +141,149 @@ describe('tableHeadersStoreBody', () => {
|
|
|
141
141
|
});
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
describe('access', () => {
|
|
145
|
+
const createGatedStore = (gated: DatalistTableHeader[]) =>
|
|
146
|
+
tableHeadersStoreBody({
|
|
147
|
+
rawHeaders: [
|
|
148
|
+
...rawHeaders.map((header) => ({
|
|
149
|
+
...header,
|
|
150
|
+
})),
|
|
151
|
+
...gated,
|
|
152
|
+
],
|
|
153
|
+
id,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('drops a denied header from headers, shownHeaders and fields', () => {
|
|
157
|
+
const store = createGatedStore([
|
|
158
|
+
{
|
|
159
|
+
field: 'agent',
|
|
160
|
+
show: true,
|
|
161
|
+
sort: null,
|
|
162
|
+
access: () => false,
|
|
163
|
+
} as DatalistTableHeader,
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
expect(store.headers.value.map(({ field }) => field)).not.toContain(
|
|
167
|
+
'agent',
|
|
168
|
+
);
|
|
169
|
+
expect(store.shownHeaders.value.map(({ field }) => field)).not.toContain(
|
|
170
|
+
'agent',
|
|
171
|
+
);
|
|
172
|
+
expect(store.fields.value).not.toContain('agent');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('keeps a granted header and leaves ungated headers untouched', () => {
|
|
176
|
+
const store = createGatedStore([
|
|
177
|
+
{
|
|
178
|
+
field: 'agent',
|
|
179
|
+
show: true,
|
|
180
|
+
sort: null,
|
|
181
|
+
access: () => true,
|
|
182
|
+
} as DatalistTableHeader,
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
expect(store.fields.value).toEqual([
|
|
186
|
+
'name',
|
|
187
|
+
'subject',
|
|
188
|
+
'agent',
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('unwraps a ref returned by the access gate', () => {
|
|
193
|
+
const store = createGatedStore([
|
|
194
|
+
{
|
|
195
|
+
field: 'agent',
|
|
196
|
+
show: true,
|
|
197
|
+
sort: null,
|
|
198
|
+
access: () => ref(false),
|
|
199
|
+
} as DatalistTableHeader,
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
expect(store.fields.value).toEqual([
|
|
203
|
+
'name',
|
|
204
|
+
'subject',
|
|
205
|
+
]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('does not resurrect a denied header from the persisted fields', async () => {
|
|
209
|
+
await router.push({
|
|
210
|
+
name: 'cases',
|
|
211
|
+
query: {
|
|
212
|
+
fields: 'agent,createdAt',
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const store = createGatedStore([
|
|
217
|
+
{
|
|
218
|
+
field: 'agent',
|
|
219
|
+
show: true,
|
|
220
|
+
sort: null,
|
|
221
|
+
access: () => false,
|
|
222
|
+
} as DatalistTableHeader,
|
|
223
|
+
]);
|
|
224
|
+
await runWithRouter(() => store.setupPersistence());
|
|
225
|
+
|
|
226
|
+
expect(store.fields.value).toEqual([
|
|
227
|
+
'createdAt',
|
|
228
|
+
]);
|
|
229
|
+
expect(store.headers.value.map(({ field }) => field)).not.toContain(
|
|
230
|
+
'agent',
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('does not resurrect a denied header on reset', () => {
|
|
235
|
+
const store = createGatedStore([
|
|
236
|
+
{
|
|
237
|
+
field: 'agent',
|
|
238
|
+
show: true,
|
|
239
|
+
sort: null,
|
|
240
|
+
access: () => false,
|
|
241
|
+
} as DatalistTableHeader,
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
store.$reset();
|
|
245
|
+
|
|
246
|
+
expect(store.headers.value.map(({ field }) => field)).not.toContain(
|
|
247
|
+
'agent',
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('keeps a field alive while another allowed header still claims it', async () => {
|
|
252
|
+
await router.push({
|
|
253
|
+
name: 'cases',
|
|
254
|
+
query: {
|
|
255
|
+
fields: 'member',
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
/* `member` and `memberId` share one API field — only one is gated */
|
|
260
|
+
const store = createGatedStore([
|
|
261
|
+
{
|
|
262
|
+
value: 'member',
|
|
263
|
+
field: 'member',
|
|
264
|
+
show: true,
|
|
265
|
+
sort: null,
|
|
266
|
+
} as DatalistTableHeader,
|
|
267
|
+
{
|
|
268
|
+
value: 'memberId',
|
|
269
|
+
field: 'member',
|
|
270
|
+
show: false,
|
|
271
|
+
sort: null,
|
|
272
|
+
access: () => false,
|
|
273
|
+
} as DatalistTableHeader,
|
|
274
|
+
]);
|
|
275
|
+
await runWithRouter(() => store.setupPersistence());
|
|
276
|
+
|
|
277
|
+
expect(store.headers.value.map(({ value }) => value)).toContain('member');
|
|
278
|
+
expect(store.headers.value.map(({ value }) => value)).not.toContain(
|
|
279
|
+
'memberId',
|
|
280
|
+
);
|
|
281
|
+
expect(store.fields.value).toEqual([
|
|
282
|
+
'member',
|
|
283
|
+
]);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
144
287
|
describe('syncPersistence', () => {
|
|
145
288
|
it('publishes a customized column set', async () => {
|
|
146
289
|
const store = await setUpStore();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { WtTableSortOrder } from '@webitel/ui-sdk/components/wt-table/types/WtTable';
|
|
2
2
|
import { sortToQueryAdapter } from '@webitel/ui-sdk/scripts';
|
|
3
3
|
import { SortSymbols } from '@webitel/ui-sdk/scripts/sortQueryAdapters';
|
|
4
|
-
import { computed, nextTick, ref } from 'vue';
|
|
4
|
+
import { computed, nextTick, ref, unref } from 'vue';
|
|
5
5
|
|
|
6
6
|
import { createDatalistStore } from '../_shared/createDatalistStore';
|
|
7
7
|
import {
|
|
@@ -24,7 +24,21 @@ export const tableHeadersStoreBody = ({
|
|
|
24
24
|
rawHeaders,
|
|
25
25
|
id,
|
|
26
26
|
}: TableHeadersStoreBodyParams) => {
|
|
27
|
-
const
|
|
27
|
+
const isAllowed = (header: DatalistTableHeader) =>
|
|
28
|
+
!header.access || !!unref(header.access());
|
|
29
|
+
|
|
30
|
+
const allowedHeaders = rawHeaders.filter(isAllowed);
|
|
31
|
+
|
|
32
|
+
/* headers may share a `field`, so it stays allowed while any of them is */
|
|
33
|
+
const allowedFields = new Set(allowedHeaders.map((header) => header.field));
|
|
34
|
+
const deniedFields = new Set(
|
|
35
|
+
rawHeaders
|
|
36
|
+
.filter((header) => !isAllowed(header))
|
|
37
|
+
.map((header) => header.field)
|
|
38
|
+
.filter((field) => !allowedFields.has(field)),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const headers = ref<DatalistTableHeader[]>(allowedHeaders);
|
|
28
42
|
const isReorderingColumn = ref(false);
|
|
29
43
|
|
|
30
44
|
const shownHeaders = computed(() => {
|
|
@@ -64,7 +78,7 @@ export const tableHeadersStoreBody = ({
|
|
|
64
78
|
});
|
|
65
79
|
|
|
66
80
|
const $reset = () => {
|
|
67
|
-
headers.value =
|
|
81
|
+
headers.value = allowedHeaders;
|
|
68
82
|
};
|
|
69
83
|
|
|
70
84
|
const updateShownHeaders = (value: DatalistTableHeader[]) => {
|
|
@@ -112,7 +126,10 @@ export const tableHeadersStoreBody = ({
|
|
|
112
126
|
});
|
|
113
127
|
};
|
|
114
128
|
|
|
115
|
-
const updateFields = (
|
|
129
|
+
const updateFields = (persistedFields: string[]) => {
|
|
130
|
+
/* persisted state predates the gate, and unknown fields are revived below */
|
|
131
|
+
const fields = persistedFields.filter((field) => !deniedFields.has(field));
|
|
132
|
+
|
|
116
133
|
const fieldsSet = new Set(fields);
|
|
117
134
|
const mainFieldNames = new Set(headers.value.map((header) => header.field));
|
|
118
135
|
|
|
@@ -232,9 +249,10 @@ export const tableHeadersStoreBody = ({
|
|
|
232
249
|
const fieldsStorage = usePersistedStorage({
|
|
233
250
|
name: 'fields',
|
|
234
251
|
value: fields,
|
|
252
|
+
/* order is the restore priority: a shared link wins over local columns */
|
|
235
253
|
storages: [
|
|
236
|
-
PersistedStorageType.LocalStorage,
|
|
237
254
|
PersistedStorageType.Route,
|
|
255
|
+
PersistedStorageType.LocalStorage,
|
|
238
256
|
],
|
|
239
257
|
storagePath: id,
|
|
240
258
|
onStore: (save, { name }) => {
|
|
@@ -26,10 +26,11 @@ const createStorageMock = (): StorageLike & {
|
|
|
26
26
|
|
|
27
27
|
const routeStorage = createStorageMock();
|
|
28
28
|
const localStorage = createStorageMock();
|
|
29
|
+
const sessionStorage = createStorageMock();
|
|
29
30
|
|
|
30
31
|
/*
|
|
31
32
|
route storage resolves `undefined` for a missing query param,
|
|
32
|
-
|
|
33
|
+
the web ones resolve `null` – all are mocked to keep that difference
|
|
33
34
|
*/
|
|
34
35
|
vi.mock('../useRoutePersistedStorage', () => ({
|
|
35
36
|
useRoutePersistedStorage: () => ({
|
|
@@ -44,15 +45,109 @@ vi.mock('../useLocalStoragePersistedStorage', () => ({
|
|
|
44
45
|
useLocalStoragePersistedStorage: () => localStorage,
|
|
45
46
|
}));
|
|
46
47
|
|
|
48
|
+
vi.mock('../useSessionStoragePersistedStorage', () => ({
|
|
49
|
+
useSessionStoragePersistedStorage: () => sessionStorage,
|
|
50
|
+
}));
|
|
51
|
+
|
|
47
52
|
const { usePersistedStorage } = await import('../usePersistedStorage');
|
|
48
53
|
|
|
54
|
+
const storageMocks = {
|
|
55
|
+
[PersistedStorageType.Route]: routeStorage,
|
|
56
|
+
[PersistedStorageType.LocalStorage]: localStorage,
|
|
57
|
+
[PersistedStorageType.SessionStorage]: sessionStorage,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const storageKinds = Object.values(PersistedStorageType).map((type) => ({
|
|
61
|
+
type,
|
|
62
|
+
storage: storageMocks[type],
|
|
63
|
+
}));
|
|
64
|
+
|
|
49
65
|
describe('usePersistedStorage', () => {
|
|
50
66
|
beforeEach(() => {
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
Object.values(storageMocks).forEach((storage) => {
|
|
68
|
+
storage.value = null;
|
|
69
|
+
});
|
|
53
70
|
vi.clearAllMocks();
|
|
54
71
|
});
|
|
55
72
|
|
|
73
|
+
/* every kind has to take part in all of it, so every enum member is run */
|
|
74
|
+
describe.each(storageKinds)('$type storage', ({ type, storage }) => {
|
|
75
|
+
it('is restored from', async () => {
|
|
76
|
+
storage.value = 'stored';
|
|
77
|
+
const value = ref('');
|
|
78
|
+
|
|
79
|
+
await usePersistedStorage({
|
|
80
|
+
name: 'filters',
|
|
81
|
+
value,
|
|
82
|
+
storages: [
|
|
83
|
+
type,
|
|
84
|
+
],
|
|
85
|
+
}).restore();
|
|
86
|
+
|
|
87
|
+
expect(value.value).toBe('stored');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('is written to by the watcher', async () => {
|
|
91
|
+
const value = ref('initial');
|
|
92
|
+
|
|
93
|
+
await usePersistedStorage({
|
|
94
|
+
name: 'filters',
|
|
95
|
+
value,
|
|
96
|
+
storages: [
|
|
97
|
+
type,
|
|
98
|
+
],
|
|
99
|
+
}).restore();
|
|
100
|
+
|
|
101
|
+
value.value = 'changed';
|
|
102
|
+
await nextTick();
|
|
103
|
+
|
|
104
|
+
expect(storage.value).toBe('changed');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('is published into by sync when it holds nothing', async () => {
|
|
108
|
+
const value = ref('filtered');
|
|
109
|
+
|
|
110
|
+
await usePersistedStorage({
|
|
111
|
+
name: 'filters',
|
|
112
|
+
value,
|
|
113
|
+
storages: [
|
|
114
|
+
type,
|
|
115
|
+
],
|
|
116
|
+
}).sync();
|
|
117
|
+
|
|
118
|
+
expect(storage.value).toBe('filtered');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('is left untouched by sync when it already holds a value', async () => {
|
|
122
|
+
storage.value = 'stored';
|
|
123
|
+
|
|
124
|
+
await usePersistedStorage({
|
|
125
|
+
name: 'filters',
|
|
126
|
+
value: ref('from-memory'),
|
|
127
|
+
storages: [
|
|
128
|
+
type,
|
|
129
|
+
],
|
|
130
|
+
}).sync();
|
|
131
|
+
|
|
132
|
+
expect(storage.setItem).not.toHaveBeenCalled();
|
|
133
|
+
expect(storage.value).toBe('stored');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('is cleared by reset', async () => {
|
|
137
|
+
storage.value = 'stored';
|
|
138
|
+
|
|
139
|
+
await usePersistedStorage({
|
|
140
|
+
name: 'filters',
|
|
141
|
+
value: ref('from-memory'),
|
|
142
|
+
storages: [
|
|
143
|
+
type,
|
|
144
|
+
],
|
|
145
|
+
}).reset();
|
|
146
|
+
|
|
147
|
+
expect(storage.value).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
56
151
|
describe('sync', () => {
|
|
57
152
|
it('publishes the current value to an empty storage', async () => {
|
|
58
153
|
const value = ref('filtered');
|
|
@@ -224,6 +319,40 @@ describe('usePersistedStorage', () => {
|
|
|
224
319
|
expect(value.value).toBe('from-local-storage');
|
|
225
320
|
});
|
|
226
321
|
|
|
322
|
+
it('follows the declared order, not the order the kinds are defined in', async () => {
|
|
323
|
+
routeStorage.value = 'from-url';
|
|
324
|
+
localStorage.value = 'from-local-storage';
|
|
325
|
+
const value = ref('');
|
|
326
|
+
|
|
327
|
+
await usePersistedStorage({
|
|
328
|
+
name: 'fields',
|
|
329
|
+
value,
|
|
330
|
+
storages: [
|
|
331
|
+
PersistedStorageType.LocalStorage,
|
|
332
|
+
PersistedStorageType.Route,
|
|
333
|
+
],
|
|
334
|
+
}).restore();
|
|
335
|
+
|
|
336
|
+
expect(value.value).toBe('from-local-storage');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('restores from the third storage when the first two hold nothing', async () => {
|
|
340
|
+
sessionStorage.value = 'from-session-storage';
|
|
341
|
+
const value = ref('');
|
|
342
|
+
|
|
343
|
+
await usePersistedStorage({
|
|
344
|
+
name: 'fields',
|
|
345
|
+
value,
|
|
346
|
+
storages: [
|
|
347
|
+
PersistedStorageType.Route,
|
|
348
|
+
PersistedStorageType.LocalStorage,
|
|
349
|
+
PersistedStorageType.SessionStorage,
|
|
350
|
+
],
|
|
351
|
+
}).restore();
|
|
352
|
+
|
|
353
|
+
expect(value.value).toBe('from-session-storage');
|
|
354
|
+
});
|
|
355
|
+
|
|
227
356
|
it('prefers the route value over the next storages', async () => {
|
|
228
357
|
routeStorage.value = 'from-url';
|
|
229
358
|
localStorage.value = 'from-local-storage';
|
|
@@ -241,6 +370,36 @@ describe('usePersistedStorage', () => {
|
|
|
241
370
|
expect(value.value).toBe('from-url');
|
|
242
371
|
});
|
|
243
372
|
|
|
373
|
+
it('seeds the storages that held nothing with the restored value', async () => {
|
|
374
|
+
sessionStorage.value = 'from-session-storage';
|
|
375
|
+
|
|
376
|
+
await usePersistedStorage({
|
|
377
|
+
name: 'filters',
|
|
378
|
+
value: ref(''),
|
|
379
|
+
storages: [
|
|
380
|
+
PersistedStorageType.Route,
|
|
381
|
+
PersistedStorageType.SessionStorage,
|
|
382
|
+
],
|
|
383
|
+
}).restore();
|
|
384
|
+
|
|
385
|
+
expect(routeStorage.value).toBe('from-session-storage');
|
|
386
|
+
expect(sessionStorage.setItem).not.toHaveBeenCalled();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('seeds nothing when the state is still default', async () => {
|
|
390
|
+
await usePersistedStorage({
|
|
391
|
+
name: 'filters',
|
|
392
|
+
value: ref(''),
|
|
393
|
+
storages: [
|
|
394
|
+
PersistedStorageType.Route,
|
|
395
|
+
PersistedStorageType.SessionStorage,
|
|
396
|
+
],
|
|
397
|
+
}).restore();
|
|
398
|
+
|
|
399
|
+
expect(routeStorage.setItem).not.toHaveBeenCalled();
|
|
400
|
+
expect(sessionStorage.setItem).not.toHaveBeenCalled();
|
|
401
|
+
});
|
|
402
|
+
|
|
244
403
|
it('leaves the value untouched when no storage holds anything', async () => {
|
|
245
404
|
const value = ref('in-memory');
|
|
246
405
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { useLocalStoragePersistedStorage } from '../useLocalStoragePersistedStorage';
|
|
4
|
+
import { useSessionStoragePersistedStorage } from '../useSessionStoragePersistedStorage';
|
|
5
|
+
|
|
6
|
+
const webStorages = [
|
|
7
|
+
{
|
|
8
|
+
name: 'useLocalStoragePersistedStorage',
|
|
9
|
+
useStorage: useLocalStoragePersistedStorage,
|
|
10
|
+
nativeStorage: () => localStorage,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'useSessionStoragePersistedStorage',
|
|
14
|
+
useStorage: useSessionStoragePersistedStorage,
|
|
15
|
+
nativeStorage: () => sessionStorage,
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
describe.each(webStorages)('$name', ({ useStorage, nativeStorage }) => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
localStorage.clear();
|
|
22
|
+
sessionStorage.clear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('namespaces keys with the storagePath', async () => {
|
|
26
|
+
const storage = useStorage({
|
|
27
|
+
storagePath: 'cases/headers',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
await storage.setItem('fields', 'name,subject');
|
|
31
|
+
|
|
32
|
+
expect(nativeStorage().getItem('cases/headers/fields')).toBe(
|
|
33
|
+
'name,subject',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keeps stores with different paths apart', async () => {
|
|
38
|
+
const cases = useStorage({
|
|
39
|
+
storagePath: 'cases',
|
|
40
|
+
});
|
|
41
|
+
const contacts = useStorage({
|
|
42
|
+
storagePath: 'contacts',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await cases.setItem('fields', 'subject');
|
|
46
|
+
await contacts.setItem('fields', 'name');
|
|
47
|
+
|
|
48
|
+
expect(await cases.getItem('fields')).toBe('subject');
|
|
49
|
+
expect(await contacts.getItem('fields')).toBe('name');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('resolves null for a missing key', async () => {
|
|
53
|
+
const storage = useStorage({
|
|
54
|
+
storagePath: 'cases',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(await storage.getItem('fields')).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('stores an array as a comma-joined value', async () => {
|
|
61
|
+
const storage = useStorage({
|
|
62
|
+
storagePath: 'cases',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await storage.setItem('fields', [
|
|
66
|
+
'name',
|
|
67
|
+
'subject',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
expect(await storage.getItem('fields')).toBe('name,subject');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('removes a key', async () => {
|
|
74
|
+
const storage = useStorage({
|
|
75
|
+
storagePath: 'cases',
|
|
76
|
+
});
|
|
77
|
+
await storage.setItem('fields', 'name');
|
|
78
|
+
|
|
79
|
+
await storage.removeItem('fields');
|
|
80
|
+
|
|
81
|
+
expect(await storage.getItem('fields')).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('web storages isolation', () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
localStorage.clear();
|
|
88
|
+
sessionStorage.clear();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('does not let the two kinds see each other values', async () => {
|
|
92
|
+
const local = useLocalStoragePersistedStorage({
|
|
93
|
+
storagePath: 'cases',
|
|
94
|
+
});
|
|
95
|
+
const session = useSessionStoragePersistedStorage({
|
|
96
|
+
storagePath: 'cases',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await local.setItem('filters', 'from-local-storage');
|
|
100
|
+
|
|
101
|
+
expect(await session.getItem('filters')).toBeNull();
|
|
102
|
+
|
|
103
|
+
await session.setItem('filters', 'from-session-storage');
|
|
104
|
+
|
|
105
|
+
expect(await local.getItem('filters')).toBe('from-local-storage');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -1,34 +1,13 @@
|
|
|
1
1
|
import type { StorageLike } from './PersistedStorage.types.ts';
|
|
2
|
-
|
|
3
|
-
const separator = ';';
|
|
4
|
-
|
|
5
|
-
const makePath = (storagePath: string, key: string) => `${storagePath}/${key}`;
|
|
2
|
+
import { useWebStoragePersistedStorage } from './useWebStoragePersistedStorage';
|
|
6
3
|
|
|
7
4
|
export const useLocalStoragePersistedStorage = ({
|
|
8
5
|
storagePath = '',
|
|
9
6
|
}: {
|
|
10
7
|
storagePath?: string;
|
|
11
8
|
}): StorageLike => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const setItem = async (key: string, inputValue: string | string[]) => {
|
|
19
|
-
const value = Array.isArray(inputValue)
|
|
20
|
-
? inputValue.join(separator)
|
|
21
|
-
: inputValue;
|
|
22
|
-
localStorage.setItem(makePath(storagePath, key), value);
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const removeItem = async (key: string) => {
|
|
26
|
-
localStorage.removeItem(makePath(storagePath, key));
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
getItem,
|
|
31
|
-
setItem,
|
|
32
|
-
removeItem,
|
|
33
|
-
};
|
|
9
|
+
return useWebStoragePersistedStorage({
|
|
10
|
+
storage: () => localStorage,
|
|
11
|
+
storagePath,
|
|
12
|
+
});
|
|
34
13
|
};
|
|
@@ -8,9 +8,11 @@ import {
|
|
|
8
8
|
type PersistedStorageController,
|
|
9
9
|
PersistedStorageType,
|
|
10
10
|
type PersistStorableValue,
|
|
11
|
+
type StorageLike,
|
|
11
12
|
} from './PersistedStorage.types';
|
|
12
13
|
import { useLocalStoragePersistedStorage } from './useLocalStoragePersistedStorage';
|
|
13
14
|
import { useRoutePersistedStorage } from './useRoutePersistedStorage';
|
|
15
|
+
import { useSessionStoragePersistedStorage } from './useSessionStoragePersistedStorage';
|
|
14
16
|
|
|
15
17
|
const toStorableValue = (value: PersistableValue): PersistStorableValue => {
|
|
16
18
|
return typeof value === 'string' ? value : (value?.toString() ?? '');
|
|
@@ -58,24 +60,30 @@ export const usePersistedStorage = ({
|
|
|
58
60
|
configStorages,
|
|
59
61
|
];
|
|
60
62
|
|
|
63
|
+
/* keyed by the enum, so a kind without an adapter is a type error */
|
|
64
|
+
const adapterFactories: Record<PersistedStorageType, () => StorageLike> = {
|
|
65
|
+
[PersistedStorageType.Route]: () => useRoutePersistedStorage(),
|
|
66
|
+
[PersistedStorageType.LocalStorage]: () =>
|
|
67
|
+
useLocalStoragePersistedStorage({
|
|
68
|
+
storagePath,
|
|
69
|
+
}),
|
|
70
|
+
[PersistedStorageType.SessionStorage]: () =>
|
|
71
|
+
useSessionStoragePersistedStorage({
|
|
72
|
+
storagePath,
|
|
73
|
+
}),
|
|
74
|
+
};
|
|
75
|
+
|
|
61
76
|
/*
|
|
62
77
|
order matters, as the first storage in the list has the highest priority
|
|
63
78
|
*/
|
|
64
|
-
|
|
65
|
-
adapters.
|
|
66
|
-
type: PersistedStorageType.Route,
|
|
67
|
-
...useRoutePersistedStorage(),
|
|
68
|
-
});
|
|
69
|
-
}
|
|
79
|
+
storages.forEach((type) => {
|
|
80
|
+
if (adapters.some((adapter) => adapter.type === type)) return;
|
|
70
81
|
|
|
71
|
-
if (storages.includes(PersistedStorageType.LocalStorage)) {
|
|
72
82
|
adapters.push({
|
|
73
|
-
type
|
|
74
|
-
...
|
|
75
|
-
storagePath,
|
|
76
|
-
}),
|
|
83
|
+
type,
|
|
84
|
+
...adapterFactories[type](),
|
|
77
85
|
});
|
|
78
|
-
}
|
|
86
|
+
});
|
|
79
87
|
|
|
80
88
|
/*
|
|
81
89
|
runs the storing path with a `save` doing whatever the caller needs:
|
|
@@ -163,6 +171,13 @@ export const usePersistedStorage = ({
|
|
|
163
171
|
value.value = restoredValue;
|
|
164
172
|
}
|
|
165
173
|
}
|
|
174
|
+
/*
|
|
175
|
+
the restored value comes from one storage, and the others may hold nothing
|
|
176
|
+
– seed them, so every storage mirrors the state right away instead of
|
|
177
|
+
waiting for the next sync()
|
|
178
|
+
*/
|
|
179
|
+
await sync();
|
|
180
|
+
|
|
166
181
|
/*
|
|
167
182
|
start watching after restoring value to prevent restored value
|
|
168
183
|
from storing again
|