@webitel/ui-datalist 1.1.65 → 1.1.67

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.
Files changed (24) hide show
  1. package/package.json +1 -1
  2. package/src/modules/filters/__tests__/createTableFiltersStore.spec.ts +143 -0
  3. package/src/modules/filters/createTableFiltersStore.ts +23 -5
  4. package/src/modules/filters/modules/filterConfig/components/contact-group/contact-group-filter-value-field.vue +40 -25
  5. package/src/modules/headers/__tests__/createTableHeadersStore.spec.ts +169 -0
  6. package/src/modules/headers/createTableHeadersStore.ts +26 -7
  7. package/src/modules/pagination/__tests__/createTablePaginationStore.spec.ts +134 -0
  8. package/src/modules/pagination/createTablePaginationStore.ts +32 -4
  9. package/src/modules/persist/PersistedStorage.types.ts +14 -0
  10. package/src/modules/persist/__tests__/useLocalStoragePersistedStorage.spec.ts +66 -0
  11. package/src/modules/persist/__tests__/usePersistedStorage.spec.ts +357 -0
  12. package/src/modules/persist/__tests__/useRoutePersistedStorage.spec.ts +145 -0
  13. package/src/modules/persist/usePersistedStorage.ts +93 -60
  14. package/src/modules/persist/useRoutePersistedStorage.ts +40 -18
  15. package/src/modules/table/createTableStore.store.ts +37 -0
  16. package/types/.tsbuildinfo +1 -1
  17. package/types/modules/filter-presets/stores/createFilterPresetsStore.d.ts +1 -0
  18. package/types/modules/filters/createTableFiltersStore.d.ts +2 -0
  19. package/types/modules/filters/modules/filterConfig/components/contact-group/contact-group-filter-value-field.vue.d.ts +4 -4
  20. package/types/modules/headers/createTableHeadersStore.d.ts +2 -0
  21. package/types/modules/pagination/createTablePaginationStore.d.ts +2 -0
  22. package/types/modules/permissions-page/stores/createPermissionsStore.d.ts +2 -0
  23. package/types/modules/persist/PersistedStorage.types.d.ts +13 -0
  24. package/types/modules/table/createTableStore.store.d.ts +2 -0
@@ -0,0 +1,145 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { createApp } from 'vue';
3
+ import {
4
+ createMemoryHistory,
5
+ createRouter,
6
+ type Router,
7
+ useRoute,
8
+ } from 'vue-router';
9
+
10
+ import type { StorageLike } from '../PersistedStorage.types';
11
+ import { useRoutePersistedStorage } from '../useRoutePersistedStorage';
12
+
13
+ const routes = [
14
+ {
15
+ path: '/',
16
+ name: 'root',
17
+ component: {
18
+ template: '<div />',
19
+ },
20
+ },
21
+ {
22
+ path: '/cases/:id?',
23
+ name: 'cases',
24
+ component: {
25
+ template: '<div />',
26
+ },
27
+ },
28
+ ];
29
+
30
+ describe('useRoutePersistedStorage', () => {
31
+ let router: Router;
32
+ let storage: StorageLike;
33
+ let currentQuery: () => Record<string, unknown>;
34
+
35
+ beforeEach(async () => {
36
+ router = createRouter({
37
+ history: createMemoryHistory(),
38
+ routes,
39
+ });
40
+
41
+ const app = createApp({});
42
+ app.use(router);
43
+
44
+ await router.push('/');
45
+ await router.isReady();
46
+
47
+ app.runWithContext(() => {
48
+ storage = useRoutePersistedStorage();
49
+ const route = useRoute();
50
+ currentQuery = () => route.query;
51
+ });
52
+ });
53
+
54
+ it('reads a value from the route query', async () => {
55
+ await router.push({
56
+ name: 'cases',
57
+ query: {
58
+ page: '3',
59
+ },
60
+ });
61
+
62
+ expect(await storage.getItem('page')).toBe('3');
63
+ });
64
+
65
+ it('resolves undefined for a missing query param', async () => {
66
+ expect(await storage.getItem('page')).toBeUndefined();
67
+ });
68
+
69
+ it('writes a value keeping the rest of the query', async () => {
70
+ await router.push({
71
+ name: 'cases',
72
+ query: {
73
+ filters: '{"name":"test"}',
74
+ },
75
+ });
76
+
77
+ await storage.setItem('page', '3');
78
+
79
+ expect(currentQuery()).toEqual({
80
+ filters: '{"name":"test"}',
81
+ page: '3',
82
+ });
83
+ });
84
+
85
+ it('writes without leaving the current route', async () => {
86
+ await router.push({
87
+ name: 'cases',
88
+ params: {
89
+ id: '42',
90
+ },
91
+ hash: '#tab',
92
+ });
93
+
94
+ await storage.setItem('page', '3');
95
+
96
+ expect(router.currentRoute.value.name).toBe('cases');
97
+ expect(router.currentRoute.value.params.id).toBe('42');
98
+ expect(router.currentRoute.value.hash).toBe('#tab');
99
+ });
100
+
101
+ it('does not add a history entry', async () => {
102
+ await router.push({
103
+ name: 'cases',
104
+ });
105
+ const positionBefore = window.history.length;
106
+
107
+ await storage.setItem('page', '3');
108
+
109
+ expect(window.history.length).toBe(positionBefore);
110
+ });
111
+
112
+ it('keeps every key when writes are issued concurrently', async () => {
113
+ await router.push({
114
+ name: 'cases',
115
+ });
116
+
117
+ await Promise.all([
118
+ storage.setItem('page', '3'),
119
+ storage.setItem('sort', '-createdAt'),
120
+ storage.setItem('fields', 'name,subject'),
121
+ ]);
122
+
123
+ expect(currentQuery()).toEqual({
124
+ page: '3',
125
+ sort: '-createdAt',
126
+ fields: 'name,subject',
127
+ });
128
+ });
129
+
130
+ it('removes a value keeping the rest of the query', async () => {
131
+ await router.push({
132
+ name: 'cases',
133
+ query: {
134
+ page: '3',
135
+ size: '20',
136
+ },
137
+ });
138
+
139
+ await storage.removeItem('page');
140
+
141
+ expect(currentQuery()).toEqual({
142
+ size: '20',
143
+ });
144
+ });
145
+ });
@@ -1,15 +1,21 @@
1
+ import { isEmpty } from '@webitel/ui-sdk/scripts';
1
2
  import { type WatchHandle, watch } from 'vue';
2
3
 
3
4
  import {
4
5
  type PersistableValue,
5
6
  type PersistedPropertyConfig,
7
+ type PersistedStorageAdapter,
6
8
  type PersistedStorageController,
7
9
  PersistedStorageType,
8
- type StorageLike,
10
+ type PersistStorableValue,
9
11
  } from './PersistedStorage.types';
10
12
  import { useLocalStoragePersistedStorage } from './useLocalStoragePersistedStorage';
11
13
  import { useRoutePersistedStorage } from './useRoutePersistedStorage';
12
14
 
15
+ const toStorableValue = (value: PersistableValue): PersistStorableValue => {
16
+ return typeof value === 'string' ? value : (value?.toString() ?? '');
17
+ };
18
+
13
19
  export const usePersistedStorage = ({
14
20
  name,
15
21
  value,
@@ -22,10 +28,9 @@ export const usePersistedStorage = ({
22
28
  onRestore,
23
29
  }: PersistedPropertyConfig): PersistedStorageController => {
24
30
  let unwatch: WatchHandle | null = null;
31
+ let defaultSnapshot: PersistStorableValue | undefined;
25
32
 
26
- const setItemFns: StorageLike['setItem'][] = [];
27
- const getItemFns: StorageLike['getItem'][] = [];
28
- const removeItemFns: StorageLike['removeItem'][] = [];
33
+ const adapters: PersistedStorageAdapter[] = [];
29
34
 
30
35
  // `null` entries are kept: callers below pick the first non-null value, so the
31
36
  // per-storage misses have to stay in the list to preserve priority order.
@@ -33,7 +38,7 @@ export const usePersistedStorage = ({
33
38
  name: string,
34
39
  ): Promise<Array<PersistableValue | null>> => {
35
40
  const settledResults = await Promise.allSettled(
36
- getItemFns.map((getter) => getter(name)),
41
+ adapters.map(({ getItem }) => getItem(name)),
37
42
  );
38
43
 
39
44
  return settledResults.reduce(
@@ -57,58 +62,67 @@ export const usePersistedStorage = ({
57
62
  order matters, as the first storage in the list has the highest priority
58
63
  */
59
64
  if (storages.includes(PersistedStorageType.Route)) {
60
- const { setItem, getItem, removeItem } = useRoutePersistedStorage();
61
- setItemFns.push(setItem);
62
- getItemFns.push(getItem);
63
- removeItemFns.push(removeItem);
65
+ adapters.push({
66
+ type: PersistedStorageType.Route,
67
+ ...useRoutePersistedStorage(),
68
+ });
64
69
  }
65
70
 
66
71
  if (storages.includes(PersistedStorageType.LocalStorage)) {
67
- const { setItem, getItem, removeItem } = useLocalStoragePersistedStorage({
68
- storagePath,
72
+ adapters.push({
73
+ type: PersistedStorageType.LocalStorage,
74
+ ...useLocalStoragePersistedStorage({
75
+ storagePath,
76
+ }),
69
77
  });
70
- setItemFns.push(setItem);
71
- getItemFns.push(getItem);
72
- removeItemFns.push(removeItem);
73
78
  }
74
79
 
80
+ /*
81
+ runs the storing path with a `save` doing whatever the caller needs:
82
+ writing to the storages, or merely capturing the serialized value
83
+ */
84
+ const runStoringPath = async (
85
+ save: (params: { name: string; value: PersistableValue }) => Promise<void>,
86
+ ) => {
87
+ if (onStore) {
88
+ /* save is wrapped in one callback, so that onStore is called only once */
89
+ return onStore(save, {
90
+ name,
91
+ value: value.value ?? '',
92
+ });
93
+ }
94
+
95
+ return save({
96
+ name,
97
+ value: value.value ?? '',
98
+ });
99
+ };
100
+
101
+ const serialize = async () => {
102
+ let serializedValue: PersistStorableValue = '';
103
+
104
+ await runStoringPath(async ({ value: storedValue }) => {
105
+ serializedValue = toStorableValue(storedValue);
106
+ });
107
+
108
+ return serializedValue;
109
+ };
110
+
111
+ const store = (targets: PersistedStorageAdapter[]) => {
112
+ if (!targets.length) return Promise.resolve();
113
+
114
+ return runStoringPath(async ({ name, value: storedValue }) => {
115
+ await Promise.all(
116
+ targets.map((adapter) => adapter.setItem(name, storedValue)),
117
+ );
118
+ });
119
+ };
120
+
75
121
  const startWatch = () => {
76
122
  unwatch = watch(
77
123
  value,
78
- async () => {
79
- /*
80
- if onStore callback is provided,
81
- call custom logic for storing value
82
- */
83
- if (onStore) {
84
- /*
85
- wrap all setItemFns in one callback
86
- so that onStore is called only once on each value change
87
- */
88
- const save = async ({
89
- name,
90
- value: storedValue,
91
- }: {
92
- name: string;
93
- value: PersistableValue;
94
- }) => {
95
- setItemFns.forEach((setter) => {
96
- setter(name, storedValue);
97
- });
98
- };
99
- await onStore(save, {
100
- name,
101
- value: value.value ?? '',
102
- });
103
- } else {
104
- /*
105
- else, perform default storing logic
106
- */
107
- const storedValue = value.value ?? '';
108
- setItemFns.forEach((setter) => {
109
- setter(name, storedValue);
110
- });
111
- }
124
+ () => {
125
+ store(adapters);
112
126
  },
113
127
  {
114
128
  deep: true,
@@ -118,14 +132,13 @@ export const usePersistedStorage = ({
118
132
 
119
133
  const restore = async () => {
120
134
  /*
121
- if onRestore callback is provided,
122
- call custom logic for restoring value
123
- */
135
+ nothing has mutated the value yet, so this is what a freshly created store
136
+ serializes sync() uses it to tell untouched state from a real one
137
+ */
138
+ defaultSnapshot = await serialize();
139
+
124
140
  if (onRestore) {
125
- /*
126
- wrap all getItemFns in one callback
127
- so that onRestore is called only once on each value change
128
- */
141
+ /* every getter is wrapped in one callback, so that onRestore is called only once */
129
142
  const restore = async (name: string) => {
130
143
  const restoredValues = await composedValueGetter(name);
131
144
  /*
@@ -138,13 +151,15 @@ export const usePersistedStorage = ({
138
151
  };
139
152
  await onRestore(restore, name);
140
153
  } else {
154
+ const restoredValues = await composedValueGetter(name);
141
155
  /*
142
- else, perform default restoring logic
156
+ loose check on purpose: useRoutePersistedStorage resolves `undefined`
157
+ for a missing query param, so a storage listed after the route one
158
+ would never win with a strict `!== null`
143
159
  */
144
- const restoredValues = await composedValueGetter(name);
145
- const restoredValue = restoredValues.find((value) => value !== null);
160
+ const restoredValue = restoredValues.find((value) => value != null);
146
161
 
147
- if (restoredValue !== undefined) {
162
+ if (restoredValue != null) {
148
163
  value.value = restoredValue;
149
164
  }
150
165
  }
@@ -157,8 +172,25 @@ export const usePersistedStorage = ({
157
172
  }
158
173
  };
159
174
 
175
+ const sync = async () => {
176
+ const serializedValue = await serialize();
177
+
178
+ if (isEmpty(serializedValue) || serializedValue === defaultSnapshot) return;
179
+
180
+ const settledResults = await Promise.allSettled(
181
+ adapters.map(({ getItem }) => getItem(name)),
182
+ );
183
+
184
+ const emptyStorages = adapters.filter((_, index) => {
185
+ const result = settledResults[index];
186
+ return result.status === 'fulfilled' && result.value == null;
187
+ });
188
+
189
+ return store(emptyStorages);
190
+ };
191
+
160
192
  const reset = async () => {
161
- await Promise.all(removeItemFns.map((removeItem) => removeItem(name)));
193
+ await Promise.all(adapters.map(({ removeItem }) => removeItem(name)));
162
194
  };
163
195
 
164
196
  const endWatch = () => unwatch?.();
@@ -167,6 +199,7 @@ export const usePersistedStorage = ({
167
199
  watch: startWatch,
168
200
  unwatch: endWatch,
169
201
  restore,
202
+ sync,
170
203
  reset,
171
204
  };
172
205
  };
@@ -2,6 +2,23 @@ import { useRoute, useRouter } from 'vue-router';
2
2
 
3
3
  import type { StorageLike } from './PersistedStorage.types.ts';
4
4
 
5
+ /*
6
+ every write is a router.replace() built on top of the current query, and the
7
+ query only changes once the navigation resolves – so concurrent writes would
8
+ all start from the same snapshot and drop each other's keys. they are queued
9
+ process-wide: a table store writes several properties at once (a new sort
10
+ makes both `sort` and `fields` emit), and all of them target the same router
11
+ */
12
+ let pendingWrite: Promise<unknown> = Promise.resolve();
13
+
14
+ const enqueueWrite = <T>(write: () => Promise<T>): Promise<T> => {
15
+ const result = pendingWrite.then(write, write);
16
+
17
+ pendingWrite = result.catch(() => {});
18
+
19
+ return result;
20
+ };
21
+
5
22
  export const useRoutePersistedStorage = (): StorageLike => {
6
23
  const router = useRouter();
7
24
  const route = useRoute();
@@ -11,27 +28,32 @@ export const useRoutePersistedStorage = (): StorageLike => {
11
28
  };
12
29
 
13
30
  const setItem = async (key: string, value: string | string[]) => {
14
- await router.replace({
15
- name: route.name,
16
- params: route.params,
17
- hash: route.hash,
18
- query: {
19
- ...route.query,
20
- [key]: value,
21
- },
22
- });
31
+ await enqueueWrite(() =>
32
+ router.replace({
33
+ name: route.name,
34
+ params: route.params,
35
+ hash: route.hash,
36
+ query: {
37
+ ...route.query,
38
+ [key]: value,
39
+ },
40
+ }),
41
+ );
23
42
  };
24
43
 
25
44
  const removeItem = async (key: string) => {
26
- const query = {
27
- ...route.query,
28
- };
29
- delete query[key];
30
- await router.replace({
31
- name: route.name,
32
- params: route.params,
33
- hash: route.hash,
34
- query,
45
+ await enqueueWrite(() => {
46
+ const query = {
47
+ ...route.query,
48
+ };
49
+ delete query[key];
50
+
51
+ return router.replace({
52
+ name: route.name,
53
+ params: route.params,
54
+ hash: route.hash,
55
+ query,
56
+ });
35
57
  });
36
58
  };
37
59
 
@@ -46,6 +46,7 @@ export const tableStoreBody = <Entity extends Identifiable>(
46
46
  // $reset: $resetPaginationStore,
47
47
  $patch: $patchPaginationStore,
48
48
  setupPersistence: setupPaginationPersistence,
49
+ syncPersistence: syncPaginationPersistence,
49
50
  } = paginationStore;
50
51
 
51
52
  const headersStore = useHeadersStore();
@@ -63,6 +64,7 @@ export const tableStoreBody = <Entity extends Identifiable>(
63
64
  columnReorder,
64
65
  updateShownHeaders,
65
66
  setupPersistence: setupHeadersPersistence,
67
+ syncPersistence: syncHeadersPersistence,
66
68
  } = headersStore;
67
69
 
68
70
  const filtersStore = useFiltersStore();
@@ -77,6 +79,7 @@ export const tableStoreBody = <Entity extends Identifiable>(
77
79
  updateFilter,
78
80
  deleteFilter,
79
81
  setupPersistence: setupFiltersPersistence,
82
+ syncPersistence: syncFiltersPersistence,
80
83
  updateSearchMode,
81
84
  } = filtersStore;
82
85
 
@@ -285,6 +288,25 @@ export const tableStoreBody = <Entity extends Identifiable>(
285
288
  isStoreSetUp.value = true;
286
289
  };
287
290
 
291
+ /**
292
+ * @description
293
+ * Republishes the current store state into the storages that hold nothing
294
+ * for it – in practice, into the route query.
295
+ *
296
+ * Persistence is restored only once per app lifetime (see `setupStore`),
297
+ * and afterwards the query is written by watchers only, i.e. on change.
298
+ * So a registry re-opened with state still in memory (closing a card back to
299
+ * it, or reaching it from the menu) used to render filtered data behind a
300
+ * bare url, losing filters on reload or on copying the link.
301
+ *
302
+ * [WTEL-10093](https://webitel.atlassian.net/browse/WTEL-10093)
303
+ */
304
+ const syncPersistence = async () => {
305
+ await syncPaginationPersistence();
306
+ await syncFiltersPersistence();
307
+ await syncHeadersPersistence();
308
+ };
309
+
288
310
  const initialize = async ({
289
311
  parentId: storeParentId,
290
312
  }: {
@@ -294,8 +316,22 @@ export const tableStoreBody = <Entity extends Identifiable>(
294
316
  parentId.value = storeParentId;
295
317
  }
296
318
 
319
+ const isStoreAlreadySetUp = isStoreSetUp.value;
320
+
297
321
  await setupStore();
298
322
 
323
+ /*
324
+ on the first setup the restore path is authoritative.
325
+
326
+ a store initialized with a parentId is a list nested in a card page, not a
327
+ registry: it shares the query param names with the registry stores, and
328
+ the url it would publish into is the card one – so it keeps writing on
329
+ change only, and syncs merely on demand
330
+ */
331
+ if (isStoreAlreadySetUp && !disablePersistence && !storeParentId) {
332
+ await syncPersistence();
333
+ }
334
+
299
335
  return loadDataList();
300
336
  };
301
337
 
@@ -329,6 +365,7 @@ export const tableStoreBody = <Entity extends Identifiable>(
329
365
 
330
366
  setupStore, // only setup, no data loading
331
367
  initialize, // setup + load data
368
+ syncPersistence, // republish store state into the route query
332
369
 
333
370
  loadDataList,
334
371
  appendToDataList,