@webitel/ui-datalist 1.1.64 → 1.1.66

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 (23) hide show
  1. package/package.json +1 -1
  2. package/src/modules/card/stores/createCardStore.ts +22 -11
  3. package/src/modules/filters/__tests__/createTableFiltersStore.spec.ts +143 -0
  4. package/src/modules/filters/createTableFiltersStore.ts +23 -5
  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/headers/createTableHeadersStore.d.ts +2 -0
  20. package/types/modules/pagination/createTablePaginationStore.d.ts +2 -0
  21. package/types/modules/permissions-page/stores/createPermissionsStore.d.ts +2 -0
  22. package/types/modules/persist/PersistedStorage.types.d.ts +13 -0
  23. package/types/modules/table/createTableStore.store.d.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webitel/ui-datalist",
3
- "version": "1.1.64",
3
+ "version": "1.1.66",
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",
@@ -6,7 +6,7 @@ import {
6
6
  import { getDefaultsFromZodSchema } from '@webitel/api-services/utils';
7
7
  import type { ApiModule } from '@webitel/ui-sdk/src/api/types/ApiModule';
8
8
  import { defineStore } from 'pinia';
9
- import { ref, toRaw, watch } from 'vue';
9
+ import { effectScope, ref, toRaw, watch } from 'vue';
10
10
  import type { z } from 'zod/v4';
11
11
 
12
12
  import type { CardItemId, CardParentId } from '../types/CardStore.types';
@@ -68,14 +68,26 @@ export const createCardStore = <
68
68
  const isSaving = ref(false);
69
69
  const error = ref<unknown>(null); // if needed
70
70
 
71
- validationSchema.value = useRegleSchema(
72
- draftItemInstance,
73
- standardValidationSchema,
74
- {
75
- ...defaultRegleValidationOptions,
76
- ...validationSchemaOptions,
77
- },
78
- );
71
+ // @regle/core auto-binds to whatever effect scope is active on first
72
+ // useRegleSchema() call — here, the opening card component's. It then
73
+ // tears r$ down when THAT unmounts, even though this store outlives it.
74
+ // A detached scope keeps regle alive for the store's lifetime
75
+ let validationScope: ReturnType<typeof effectScope>;
76
+ const createValidationSchema = () => {
77
+ validationScope?.stop();
78
+ validationScope = effectScope(true);
79
+ validationScope.run(() => {
80
+ validationSchema.value = useRegleSchema(
81
+ draftItemInstance,
82
+ standardValidationSchema,
83
+ {
84
+ ...defaultRegleValidationOptions,
85
+ ...validationSchemaOptions,
86
+ },
87
+ );
88
+ });
89
+ };
90
+ createValidationSchema();
79
91
 
80
92
  const loadItem = async () => {
81
93
  isLoading.value = true;
@@ -175,11 +187,10 @@ export const createCardStore = <
175
187
  const $reset = () => {
176
188
  itemId.value = null;
177
189
  parentId.value = null;
190
+ createValidationSchema();
178
191
 
179
192
  originalItemInstance.value = {} as Entity;
180
193
 
181
- validationSchema.value.r$.$reset();
182
-
183
194
  isLoading.value = false;
184
195
  isSaving.value = false;
185
196
  error.value = null;
@@ -0,0 +1,143 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { createApp } from 'vue';
3
+ import { createMemoryHistory, createRouter, type Router } from 'vue-router';
4
+
5
+ import { tableFiltersStoreBody } from '../createTableFiltersStore';
6
+
7
+ const routes = [
8
+ {
9
+ path: '/cases',
10
+ name: 'cases',
11
+ component: {
12
+ template: '<div />',
13
+ },
14
+ },
15
+ ];
16
+
17
+ const namespace = 'cases';
18
+
19
+ describe('tableFiltersStoreBody', () => {
20
+ let router: Router;
21
+ let runWithRouter: <T>(fn: () => T) => T;
22
+
23
+ beforeEach(async () => {
24
+ localStorage.clear();
25
+
26
+ router = createRouter({
27
+ history: createMemoryHistory(),
28
+ routes,
29
+ });
30
+
31
+ const app = createApp({});
32
+ app.use(router);
33
+
34
+ await router.push('/cases');
35
+ await router.isReady();
36
+
37
+ runWithRouter = (fn) => app.runWithContext(fn);
38
+ });
39
+
40
+ const setUpStore = async () => {
41
+ const store = tableFiltersStoreBody(namespace);
42
+ await runWithRouter(() => store.setupPersistence());
43
+ return store;
44
+ };
45
+
46
+ it('starts with no filters', () => {
47
+ const store = tableFiltersStoreBody(namespace);
48
+
49
+ expect(store.filtersList.value).toEqual([]);
50
+ });
51
+
52
+ it('adds, updates and deletes a filter', () => {
53
+ const store = tableFiltersStoreBody(namespace);
54
+
55
+ store.addFilter({
56
+ name: 'status',
57
+ value: 'open',
58
+ });
59
+ expect(store.hasFilter('status')).toBe(true);
60
+
61
+ store.updateFilter({
62
+ name: 'status',
63
+ value: 'closed',
64
+ });
65
+ expect(store.filtersManager.getFilter('status')?.value).toBe('closed');
66
+
67
+ store.deleteFilter({
68
+ name: 'status',
69
+ });
70
+ expect(store.hasFilter('status')).toBe(false);
71
+ });
72
+
73
+ it('keeps searchMode', () => {
74
+ const store = tableFiltersStoreBody(namespace);
75
+
76
+ store.updateSearchMode('subject');
77
+
78
+ expect(store.searchMode.value).toBe('subject');
79
+ });
80
+
81
+ describe('persistence', () => {
82
+ it('restores filters from the route query', async () => {
83
+ await router.push({
84
+ name: 'cases',
85
+ query: {
86
+ filters: JSON.stringify({
87
+ status_val: 'open',
88
+ }),
89
+ },
90
+ });
91
+
92
+ const store = await setUpStore();
93
+
94
+ expect(store.filtersManager.getFilter('status')?.value).toBe('open');
95
+ });
96
+
97
+ it('writes an added filter into the route query', async () => {
98
+ const store = await setUpStore();
99
+
100
+ store.addFilter({
101
+ name: 'status',
102
+ value: 'open',
103
+ });
104
+ await new Promise((resolve) => setTimeout(resolve));
105
+
106
+ expect(router.currentRoute.value.query.filters).toContain('open');
107
+ });
108
+
109
+ it('keeps searchMode out of the route query', async () => {
110
+ const store = await setUpStore();
111
+
112
+ store.updateSearchMode('subject');
113
+ await new Promise((resolve) => setTimeout(resolve));
114
+
115
+ expect(router.currentRoute.value.query.searchMode).toBeUndefined();
116
+ expect(localStorage.getItem(`${namespace}/searchMode`)).toBe('subject');
117
+ });
118
+ });
119
+
120
+ describe('syncPersistence', () => {
121
+ it('publishes the applied filters', async () => {
122
+ const store = await setUpStore();
123
+ store.addFilter({
124
+ name: 'status',
125
+ value: 'open',
126
+ });
127
+ await new Promise((resolve) => setTimeout(resolve));
128
+ await router.push('/cases');
129
+
130
+ await runWithRouter(() => store.syncPersistence());
131
+
132
+ expect(router.currentRoute.value.query.filters).toContain('open');
133
+ });
134
+
135
+ it('publishes nothing while no filter is applied', async () => {
136
+ const store = await setUpStore();
137
+
138
+ await runWithRouter(() => store.syncPersistence());
139
+
140
+ expect(router.currentRoute.value.query).toEqual({});
141
+ });
142
+ });
143
+ });
@@ -1,7 +1,10 @@
1
1
  import { computed, reactive, ref } from 'vue';
2
2
 
3
3
  import { createDatalistStore } from '../_shared/createDatalistStore';
4
- import { PersistedStorageType } from '../persist/PersistedStorage.types';
4
+ import {
5
+ type PersistedStorageController,
6
+ PersistedStorageType,
7
+ } from '../persist/PersistedStorage.types';
5
8
  import { usePersistedStorage } from '../persist/usePersistedStorage';
6
9
  import type { Identifiable } from '../types/createDatalistStore.types';
7
10
  import type { useTableStoreConfig } from '../types/tableStore.types';
@@ -40,8 +43,10 @@ export const tableFiltersStoreBody = (
40
43
 
41
44
  const filtersList = computed(() => filtersManager.getFiltersList());
42
45
 
46
+ let persistedStorageControllers: PersistedStorageController[] = [];
47
+
43
48
  const setupPersistence = () => {
44
- const { restore: restoreFilters } = usePersistedStorage({
49
+ const filtersStorage = usePersistedStorage({
45
50
  name: 'filters',
46
51
 
47
52
  value: computed(
@@ -75,7 +80,7 @@ export const tableFiltersStoreBody = (
75
80
  },
76
81
  });
77
82
 
78
- const { restore: restoreSearchMode } = usePersistedStorage({
83
+ const searchModeStorage = usePersistedStorage({
79
84
  name: 'searchMode',
80
85
  value: searchMode,
81
86
  storages: [
@@ -95,12 +100,24 @@ export const tableFiltersStoreBody = (
95
100
  },
96
101
  });
97
102
 
103
+ persistedStorageControllers = [
104
+ filtersStorage,
105
+ searchModeStorage,
106
+ ];
107
+
98
108
  return Promise.all([
99
- restoreFilters(),
100
- restoreSearchMode(),
109
+ filtersStorage.restore(),
110
+ searchModeStorage.restore(),
101
111
  ]);
102
112
  };
103
113
 
114
+ /* sequentially: every route write is a router.replace() on top of the current query */
115
+ const syncPersistence = async () => {
116
+ for (const controller of persistedStorageControllers) {
117
+ await controller.sync();
118
+ }
119
+ };
120
+
104
121
  return {
105
122
  filtersManager,
106
123
  isRestoring,
@@ -116,6 +133,7 @@ export const tableFiltersStoreBody = (
116
133
  updateSearchMode,
117
134
 
118
135
  setupPersistence,
136
+ syncPersistence,
119
137
  };
120
138
  };
121
139
 
@@ -0,0 +1,169 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { createApp } from 'vue';
3
+ import { createMemoryHistory, createRouter, type Router } from 'vue-router';
4
+
5
+ import type { DatalistTableHeader } from '../../types/tableStore.types';
6
+ import { tableHeadersStoreBody } from '../createTableHeadersStore';
7
+
8
+ const routes = [
9
+ {
10
+ path: '/cases',
11
+ name: 'cases',
12
+ component: {
13
+ template: '<div />',
14
+ },
15
+ },
16
+ ];
17
+
18
+ const id = 'cases/headers';
19
+
20
+ const rawHeaders = [
21
+ {
22
+ field: 'name',
23
+ show: true,
24
+ sort: null,
25
+ },
26
+ {
27
+ field: 'subject',
28
+ show: true,
29
+ sort: null,
30
+ },
31
+ {
32
+ field: 'createdAt',
33
+ show: false,
34
+ sort: null,
35
+ },
36
+ ] as DatalistTableHeader[];
37
+
38
+ /* route writes are queued, so several ticks are needed for all of them to land */
39
+ const flushWrites = async () => {
40
+ for (let i = 0; i < 5; i += 1) {
41
+ await new Promise((resolve) => setTimeout(resolve));
42
+ }
43
+ };
44
+
45
+ const createStore = () =>
46
+ tableHeadersStoreBody({
47
+ rawHeaders: rawHeaders.map((header) => ({
48
+ ...header,
49
+ })),
50
+ id,
51
+ });
52
+
53
+ describe('tableHeadersStoreBody', () => {
54
+ let router: Router;
55
+ let runWithRouter: <T>(fn: () => T) => T;
56
+
57
+ beforeEach(async () => {
58
+ localStorage.clear();
59
+
60
+ router = createRouter({
61
+ history: createMemoryHistory(),
62
+ routes,
63
+ });
64
+
65
+ const app = createApp({});
66
+ app.use(router);
67
+
68
+ await router.push('/cases');
69
+ await router.isReady();
70
+
71
+ runWithRouter = (fn) => app.runWithContext(fn);
72
+ });
73
+
74
+ const setUpStore = async () => {
75
+ const store = createStore();
76
+ await runWithRouter(() => store.setupPersistence());
77
+ return store;
78
+ };
79
+
80
+ it('exposes the shown headers and their fields', () => {
81
+ const store = createStore();
82
+
83
+ expect(store.shownHeaders.value.map(({ field }) => field)).toEqual([
84
+ 'name',
85
+ 'subject',
86
+ ]);
87
+ expect(store.fields.value).toEqual([
88
+ 'name',
89
+ 'subject',
90
+ ]);
91
+ });
92
+
93
+ it('has no sort by default', () => {
94
+ expect(createStore().sort.value).toBeNull();
95
+ });
96
+
97
+ it('encodes the sorted column into a sort query', () => {
98
+ const store = createStore();
99
+
100
+ store.updateSort(store.headers.value[0], 'desc');
101
+
102
+ expect(store.sort.value).toBe('-name');
103
+ });
104
+
105
+ it('collects the widths of resized columns', () => {
106
+ const store = createStore();
107
+
108
+ store.columnResize({
109
+ columnName: 'name',
110
+ columnWidth: '100px',
111
+ });
112
+
113
+ expect(store.columnWidths.value).toEqual({
114
+ name: '100px',
115
+ });
116
+ });
117
+
118
+ describe('persistence', () => {
119
+ it('restores shown fields from the route query', async () => {
120
+ await router.push({
121
+ name: 'cases',
122
+ query: {
123
+ fields: 'createdAt',
124
+ },
125
+ });
126
+
127
+ const store = await setUpStore();
128
+
129
+ expect(store.fields.value).toEqual([
130
+ 'createdAt',
131
+ ]);
132
+ });
133
+
134
+ it('writes a changed sort into the route query', async () => {
135
+ const store = await setUpStore();
136
+
137
+ store.updateSort(store.headers.value[0], 'asc');
138
+ await flushWrites();
139
+
140
+ expect(router.currentRoute.value.query.sort).toBe('+name');
141
+ });
142
+ });
143
+
144
+ describe('syncPersistence', () => {
145
+ it('publishes a customized column set', async () => {
146
+ const store = await setUpStore();
147
+ store.updateShownHeaders(
148
+ store.headers.value.map((header) => ({
149
+ ...header,
150
+ show: header.field === 'name',
151
+ })),
152
+ );
153
+ await flushWrites();
154
+ await router.push('/cases');
155
+
156
+ await runWithRouter(() => store.syncPersistence());
157
+
158
+ expect(router.currentRoute.value.query.fields).toBe('name');
159
+ });
160
+
161
+ it('publishes nothing while the columns are still default', async () => {
162
+ const store = await setUpStore();
163
+
164
+ await runWithRouter(() => store.syncPersistence());
165
+
166
+ expect(router.currentRoute.value.query).toEqual({});
167
+ });
168
+ });
169
+ });
@@ -4,7 +4,10 @@ import { SortSymbols } from '@webitel/ui-sdk/scripts/sortQueryAdapters';
4
4
  import { computed, nextTick, ref } from 'vue';
5
5
 
6
6
  import { createDatalistStore } from '../_shared/createDatalistStore';
7
- import { PersistedStorageType } from '../persist/PersistedStorage.types';
7
+ import {
8
+ type PersistedStorageController,
9
+ PersistedStorageType,
10
+ } from '../persist/PersistedStorage.types';
8
11
  import { usePersistedStorage } from '../persist/usePersistedStorage';
9
12
  import type { Identifiable } from '../types/createDatalistStore.types';
10
13
  import type {
@@ -223,8 +226,10 @@ export const tableHeadersStoreBody = ({
223
226
  });
224
227
  };
225
228
 
229
+ let persistedStorageControllers: PersistedStorageController[] = [];
230
+
226
231
  const setupPersistence = async () => {
227
- const { restore: restoreFields } = usePersistedStorage({
232
+ const fieldsStorage = usePersistedStorage({
228
233
  name: 'fields',
229
234
  value: fields,
230
235
  storages: [
@@ -247,12 +252,12 @@ export const tableHeadersStoreBody = ({
247
252
  },
248
253
  });
249
254
 
250
- const { restore: restoreSort } = usePersistedStorage({
255
+ const sortStorage = usePersistedStorage({
251
256
  name: 'sort',
252
257
  value: sort,
253
258
  });
254
259
 
255
- const { restore: restoreColumnWidths } = usePersistedStorage({
260
+ const columnWidthsStorage = usePersistedStorage({
256
261
  name: 'columnWidths',
257
262
  value: columnWidths,
258
263
  storages: [
@@ -278,13 +283,26 @@ export const tableHeadersStoreBody = ({
278
283
  },
279
284
  });
280
285
 
286
+ persistedStorageControllers = [
287
+ fieldsStorage,
288
+ sortStorage,
289
+ columnWidthsStorage,
290
+ ];
291
+
281
292
  return Promise.allSettled([
282
- restoreFields(),
283
- restoreSort(),
284
- restoreColumnWidths(),
293
+ fieldsStorage.restore(),
294
+ sortStorage.restore(),
295
+ columnWidthsStorage.restore(),
285
296
  ]);
286
297
  };
287
298
 
299
+ /* sequentially: every route write is a router.replace() on top of the current query */
300
+ const syncPersistence = async () => {
301
+ for (const controller of persistedStorageControllers) {
302
+ await controller.sync();
303
+ }
304
+ };
305
+
288
306
  const getHeaderByField = (field: string) => {
289
307
  return headers.value.find((header) => header.field === field);
290
308
  };
@@ -334,6 +352,7 @@ export const tableHeadersStoreBody = ({
334
352
  columnReorder,
335
353
 
336
354
  setupPersistence,
355
+ syncPersistence,
337
356
  $reset,
338
357
  };
339
358
  };
@@ -0,0 +1,134 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { createApp } from 'vue';
3
+ import { createMemoryHistory, createRouter, type Router } from 'vue-router';
4
+
5
+ import { tablePaginationStoreBody } from '../createTablePaginationStore';
6
+
7
+ const routes = [
8
+ {
9
+ path: '/cases',
10
+ name: 'cases',
11
+ component: {
12
+ template: '<div />',
13
+ },
14
+ },
15
+ ];
16
+
17
+ describe('tablePaginationStoreBody', () => {
18
+ let router: Router;
19
+ let runWithRouter: <T>(fn: () => T) => T;
20
+
21
+ beforeEach(async () => {
22
+ router = createRouter({
23
+ history: createMemoryHistory(),
24
+ routes,
25
+ });
26
+
27
+ const app = createApp({});
28
+ app.use(router);
29
+
30
+ await router.push('/cases');
31
+ await router.isReady();
32
+
33
+ runWithRouter = (fn) => app.runWithContext(fn);
34
+ });
35
+
36
+ const setUpStore = async () => {
37
+ const store = tablePaginationStoreBody();
38
+ await runWithRouter(() => store.setupPersistence());
39
+ return store;
40
+ };
41
+
42
+ it('starts on the first page with the default size', () => {
43
+ const store = tablePaginationStoreBody();
44
+
45
+ expect(store.page.value).toBe(1);
46
+ expect(store.size.value).toBe(10);
47
+ });
48
+
49
+ it('updates page and size', () => {
50
+ const store = tablePaginationStoreBody();
51
+
52
+ store.updatePage(3);
53
+ store.updateSize(20);
54
+
55
+ expect(store.page.value).toBe(3);
56
+ expect(store.size.value).toBe(20);
57
+ });
58
+
59
+ it('resets to the defaults', () => {
60
+ const store = tablePaginationStoreBody();
61
+ store.updatePage(3);
62
+ store.updateSize(20);
63
+ store.next.value = true;
64
+
65
+ store.$reset();
66
+
67
+ expect(store.page.value).toBe(1);
68
+ expect(store.size.value).toBe(10);
69
+ expect(store.next.value).toBe(false);
70
+ });
71
+
72
+ describe('persistence', () => {
73
+ it('restores page and size from the route query', async () => {
74
+ await router.push({
75
+ name: 'cases',
76
+ query: {
77
+ page: '3',
78
+ size: '20',
79
+ },
80
+ });
81
+
82
+ const store = await setUpStore();
83
+
84
+ expect(store.page.value).toBe(3);
85
+ expect(store.size.value).toBe(20);
86
+ });
87
+
88
+ it('writes a changed page into the route query', async () => {
89
+ const store = await setUpStore();
90
+
91
+ store.updatePage(3);
92
+ await new Promise((resolve) => setTimeout(resolve));
93
+
94
+ expect(router.currentRoute.value.query.page).toBe('3');
95
+ });
96
+ });
97
+
98
+ describe('syncPersistence', () => {
99
+ it('publishes state the user changed', async () => {
100
+ const store = await setUpStore();
101
+ store.updatePage(3);
102
+ await router.push('/cases');
103
+
104
+ await runWithRouter(() => store.syncPersistence());
105
+
106
+ expect(router.currentRoute.value.query.page).toBe('3');
107
+ });
108
+
109
+ it('publishes nothing while the state is still default', async () => {
110
+ const store = await setUpStore();
111
+
112
+ await runWithRouter(() => store.syncPersistence());
113
+
114
+ expect(router.currentRoute.value.query).toEqual({});
115
+ });
116
+
117
+ it('does not overwrite a value already in the query', async () => {
118
+ const store = await setUpStore();
119
+ store.updatePage(3);
120
+ /* let the watcher settle, so its write cannot land after the push below */
121
+ await new Promise((resolve) => setTimeout(resolve));
122
+ await router.push({
123
+ name: 'cases',
124
+ query: {
125
+ page: '5',
126
+ },
127
+ });
128
+
129
+ await runWithRouter(() => store.syncPersistence());
130
+
131
+ expect(router.currentRoute.value.query.page).toBe('5');
132
+ });
133
+ });
134
+ });