@webitel/ui-datalist 1.1.68 → 1.1.70

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webitel/ui-datalist",
3
- "version": "1.1.68",
3
+ "version": "1.1.70",
4
4
  "description": "Toolkit for building data lists in webitel ui system",
5
5
  "scripts": {
6
6
  "build:types": "vue-tsc -b tsconfig.build.json",
@@ -0,0 +1,58 @@
1
+ import { createPinia, setActivePinia } from 'pinia';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+
4
+ import { createFilterPresetsStore } from '../createFilterPresetsStore';
5
+
6
+ const NAMESPACE = 'test';
7
+ const STORAGE_KEY = `${NAMESPACE}/presets/preset`;
8
+ const CACHED_PRESET_ID = 42;
9
+
10
+ const setupStore = async () => {
11
+ const store = createFilterPresetsStore(NAMESPACE)();
12
+ await store.setupPresetPersistence();
13
+
14
+ return store;
15
+ };
16
+
17
+ describe('filter presets persistence', () => {
18
+ beforeEach(() => {
19
+ setActivePinia(createPinia());
20
+ localStorage.clear();
21
+ });
22
+
23
+ it('restores the cached preset id', async () => {
24
+ localStorage.setItem(STORAGE_KEY, String(CACHED_PRESET_ID));
25
+
26
+ const store = await setupStore();
27
+
28
+ expect(store.presetId).toBe(CACHED_PRESET_ID);
29
+ });
30
+
31
+ /*
32
+ restore() snapshots the value through onStore before reading the storages,
33
+ so an onStore that clears used to delete the cached preset on every setup
34
+ */
35
+ it('keeps the cached preset id stored while restoring it', async () => {
36
+ localStorage.setItem(STORAGE_KEY, String(CACHED_PRESET_ID));
37
+
38
+ await setupStore();
39
+
40
+ expect(localStorage.getItem(STORAGE_KEY)).toBe(String(CACHED_PRESET_ID));
41
+ });
42
+
43
+ it('leaves presetId empty when nothing is cached, instead of NaN', async () => {
44
+ const store = await setupStore();
45
+
46
+ expect(store.presetId).toBeNull();
47
+ });
48
+
49
+ it('drops the cached preset on resetPreset', async () => {
50
+ localStorage.setItem(STORAGE_KEY, String(CACHED_PRESET_ID));
51
+ const store = await setupStore();
52
+
53
+ await store.resetPreset();
54
+
55
+ expect(store.presetId).toBeNull();
56
+ expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
57
+ });
58
+ });
@@ -28,6 +28,8 @@ export const filterPresetsStoreBody = (namespace = 'presets') => {
28
28
 
29
29
  const presetId = ref<number | null>(null);
30
30
 
31
+ let resetPersistedPreset: (() => Promise<void>) | null = null;
32
+
31
33
  const setupPresetPersistence = async () => {
32
34
  const { restore: restorePreset, reset } = usePersistedStorage({
33
35
  name: 'preset',
@@ -36,11 +38,15 @@ export const filterPresetsStoreBody = (namespace = 'presets') => {
36
38
  PersistedStorageType.LocalStorage,
37
39
  ],
38
40
  storagePath: presetsNamespace,
39
- onStore: (save, { name }) => {
41
+ /*
42
+ side-effect free on purpose: serialize() runs this path merely to snapshot
43
+ the value, so clearing the storage from here wipes the cached preset
44
+ before restore() gets to read it
45
+ */
46
+ onStore: async (save, { name }) => {
40
47
  const value = presetId.value;
41
- if (!value) {
42
- return reset();
43
- }
48
+ if (!value) return;
49
+
44
50
  return save({
45
51
  name,
46
52
  value,
@@ -48,9 +54,13 @@ export const filterPresetsStoreBody = (namespace = 'presets') => {
48
54
  },
49
55
  onRestore: async (restore, name) => {
50
56
  const value = await restore(name);
51
- presetId.value = Number(value);
57
+ /* absent key resolves undefined, and Number(undefined) is NaN */
58
+ if (value) presetId.value = Number(value);
52
59
  },
53
60
  });
61
+
62
+ resetPersistedPreset = reset;
63
+
54
64
  await restorePreset();
55
65
  };
56
66
 
@@ -59,8 +69,10 @@ export const filterPresetsStoreBody = (namespace = 'presets') => {
59
69
  presetsTableConfig,
60
70
  );
61
71
 
62
- const resetPreset = () => {
72
+ /* onStore no longer clears, so dropping the cached preset is explicit */
73
+ const resetPreset = async () => {
63
74
  presetId.value = null;
75
+ await resetPersistedPreset?.();
64
76
  };
65
77
 
66
78
  return {
@@ -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 }) => {
@@ -249,9 +249,10 @@ export const tableHeadersStoreBody = ({
249
249
  const fieldsStorage = usePersistedStorage({
250
250
  name: 'fields',
251
251
  value: fields,
252
+ /* order is the restore priority: a shared link wins over local columns */
252
253
  storages: [
253
- PersistedStorageType.LocalStorage,
254
254
  PersistedStorageType.Route,
255
+ PersistedStorageType.LocalStorage,
255
256
  ],
256
257
  storagePath: id,
257
258
  onStore: (save, { name }) => {
@@ -3,6 +3,7 @@ import type { Ref, WatchOptions } from 'vue';
3
3
  export enum PersistedStorageType {
4
4
  LocalStorage = 'localStorage',
5
5
  Route = 'route',
6
+ SessionStorage = 'sessionStorage',
6
7
  }
7
8
 
8
9
  // in route query, or in localStorage
@@ -35,6 +36,10 @@ export interface PersistedPropertyConfig {
35
36
  storagePath?: string;
36
37
  startWatchManually?: boolean;
37
38
  watchConfig?: WatchOptions;
39
+ // must be side-effect free apart from the `save` it is handed: serialize()
40
+ // runs this path with a `save` that only captures, so touching the storages
41
+ // from here mutates them at snapshot time – including before restore() has
42
+ // read them
38
43
  onStore?: (
39
44
  save: ({
40
45
  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
- local storage resolves `null` – both are mocked to keep that difference
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
- routeStorage.value = null;
52
- localStorage.value = null;
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
- const getItem = async (key: string) => {
13
- const value = localStorage.getItem(makePath(storagePath, key));
14
- if (value === null) return null;
15
- return value.split(separator).join();
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
- if (storages.includes(PersistedStorageType.Route)) {
65
- adapters.push({
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: PersistedStorageType.LocalStorage,
74
- ...useLocalStoragePersistedStorage({
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
@@ -0,0 +1,13 @@
1
+ import type { StorageLike } from './PersistedStorage.types.ts';
2
+ import { useWebStoragePersistedStorage } from './useWebStoragePersistedStorage';
3
+
4
+ export const useSessionStoragePersistedStorage = ({
5
+ storagePath = '',
6
+ }: {
7
+ storagePath?: string;
8
+ }): StorageLike => {
9
+ return useWebStoragePersistedStorage({
10
+ storage: () => sessionStorage,
11
+ storagePath,
12
+ });
13
+ };
@@ -0,0 +1,37 @@
1
+ import type { StorageLike } from './PersistedStorage.types.ts';
2
+
3
+ const separator = ';';
4
+
5
+ const makePath = (storagePath: string, key: string) => `${storagePath}/${key}`;
6
+
7
+ /* `storage` is a getter, so the global is touched at call time, not at module load */
8
+ export const useWebStoragePersistedStorage = ({
9
+ storage,
10
+ storagePath = '',
11
+ }: {
12
+ storage: () => Storage;
13
+ storagePath?: string;
14
+ }): StorageLike => {
15
+ const getItem = async (key: string) => {
16
+ const value = storage().getItem(makePath(storagePath, key));
17
+ if (value === null) return null;
18
+ return value.split(separator).join();
19
+ };
20
+
21
+ const setItem = async (key: string, inputValue: string | string[]) => {
22
+ const value = Array.isArray(inputValue)
23
+ ? inputValue.join(separator)
24
+ : inputValue;
25
+ storage().setItem(makePath(storagePath, key), value);
26
+ };
27
+
28
+ const removeItem = async (key: string) => {
29
+ storage().removeItem(makePath(storagePath, key));
30
+ };
31
+
32
+ return {
33
+ getItem,
34
+ setItem,
35
+ removeItem,
36
+ };
37
+ };