@webitel/ui-datalist 26.8.3 → 26.8.5

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 (28) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +2 -1
  3. package/src/modules/card/composables/__tests__/useCardRouting.spec.ts +146 -0
  4. package/src/modules/card/composables/useCardComponent.ts +9 -0
  5. package/src/modules/card/composables/useCardRouting.ts +12 -6
  6. package/src/modules/card/composables/useCardStoreProvider.ts +26 -0
  7. package/src/modules/card/composables/useNestedCardComponent.ts +8 -5
  8. package/src/modules/card/index.ts +1 -0
  9. package/src/modules/card/stores/__tests__/createCardStore.spec.ts +73 -0
  10. package/src/modules/card/stores/createCardStore.ts +23 -2
  11. package/src/modules/card/types/CardStore.types.ts +8 -0
  12. package/src/modules/table/__tests__/createTableStore.spec.ts +262 -0
  13. package/src/modules/table/__tests__/useNestedTableList.spec.ts +218 -0
  14. package/src/modules/table/composables/useNestedTableList.ts +57 -0
  15. package/src/modules/table/createTableStore.store.ts +37 -0
  16. package/types/.tsbuildinfo +1 -1
  17. package/types/index.d.ts +2 -1
  18. package/types/modules/card/composables/useCardRouting.d.ts +10 -6
  19. package/types/modules/card/composables/useCardStoreProvider.d.ts +18 -0
  20. package/types/modules/card/composables/useNestedCardComponent.d.ts +6 -2
  21. package/types/modules/card/index.d.ts +1 -0
  22. package/types/modules/card/stores/createCardStore.d.ts +19 -10
  23. package/types/modules/card/types/CardStore.types.d.ts +7 -0
  24. package/types/modules/filter-presets/stores/createFilterPresetsStore.d.ts +2 -0
  25. package/types/modules/filters/modules/filterConfig/components/case-service/config.d.ts +1 -1
  26. package/types/modules/permissions-page/stores/createPermissionsStore.d.ts +2 -0
  27. package/types/modules/table/composables/useNestedTableList.d.ts +27 -0
  28. package/types/modules/table/createTableStore.store.d.ts +4 -0
@@ -0,0 +1,262 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { createPinia, setActivePinia } from 'pinia';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { defineComponent, h } from 'vue';
5
+ import { createMemoryHistory, createRouter, type Router } from 'vue-router';
6
+
7
+ import type { DatalistTableHeader } from '../../types/tableStore.types';
8
+ import { createTableStore } from '../createTableStore.store';
9
+
10
+ const routes = [
11
+ {
12
+ path: '/cases',
13
+ name: 'cases',
14
+ component: {
15
+ template: '<div />',
16
+ },
17
+ },
18
+ ];
19
+
20
+ const headers = [
21
+ {
22
+ value: 'name',
23
+ field: 'name',
24
+ show: true,
25
+ sort: null,
26
+ },
27
+ {
28
+ value: 'subject',
29
+ field: 'subject',
30
+ show: true,
31
+ sort: null,
32
+ },
33
+ ] as DatalistTableHeader[];
34
+
35
+ const flush = async () => {
36
+ for (let i = 0; i < 5; i += 1) {
37
+ await new Promise((resolve) => setTimeout(resolve));
38
+ }
39
+ };
40
+
41
+ /*
42
+ `setupStore` runs once per store, and the watchers it depends on must outlive
43
+ every component that renders the table. Registering them from the caller left
44
+ them owned by that component's effect scope, so a card tab that had been
45
+ walked away from came back inert — it loaded once and then ignored sort,
46
+ pagination, page size, column visibility and filters
47
+ ([WTEL-10308](https://webitel.atlassian.net/browse/WTEL-10308), fixed by
48
+ [WTEL-10277](https://webitel.atlassian.net/browse/WTEL-10277)).
49
+
50
+ Tables that keep persistence were spared by accident back then: their setup
51
+ awaits the restore, so the watchers landed with no active scope. Both settings
52
+ are covered here, because that difference was invisible until it broke.
53
+ */
54
+ describe('tableStoreBody', () => {
55
+ let router: Router;
56
+ let getList: ReturnType<typeof vi.fn>;
57
+
58
+ beforeEach(async () => {
59
+ localStorage.clear();
60
+ sessionStorage.clear();
61
+ setActivePinia(createPinia());
62
+
63
+ router = createRouter({
64
+ history: createMemoryHistory(),
65
+ routes,
66
+ });
67
+ await router.push('/cases');
68
+ await router.isReady();
69
+
70
+ getList = vi.fn().mockResolvedValue({
71
+ items: [],
72
+ next: false,
73
+ });
74
+ });
75
+
76
+ /*
77
+ a tab on a card page: the table component is unmounted and mounted back as
78
+ the user walks the tabs, and the store outlives all of it
79
+ */
80
+ const mountTab = (useStore: ReturnType<typeof createTableStore>) => {
81
+ const Tab = defineComponent({
82
+ setup() {
83
+ const store = useStore();
84
+ /* a nested list is initialized with the card's id */
85
+ store.initialize({
86
+ parentId: '42',
87
+ });
88
+ return () => h('div');
89
+ },
90
+ });
91
+
92
+ return mount(Tab, {
93
+ global: {
94
+ plugins: [
95
+ router,
96
+ ],
97
+ },
98
+ });
99
+ };
100
+
101
+ describe.each([
102
+ [
103
+ 'with persistence',
104
+ false,
105
+ ],
106
+ [
107
+ 'without persistence',
108
+ true,
109
+ ],
110
+ ])('%s', (_label, disablePersistence) => {
111
+ let useStore: ReturnType<typeof createTableStore>;
112
+
113
+ beforeEach(async () => {
114
+ useStore = createTableStore(`cases-${disablePersistence}/datalist`, {
115
+ apiModule: {
116
+ getList,
117
+ },
118
+ headers,
119
+ disablePersistence,
120
+ });
121
+
122
+ const first = mountTab(useStore);
123
+ await flush();
124
+ first.unmount();
125
+ await flush();
126
+
127
+ mountTab(useStore);
128
+ await flush();
129
+
130
+ getList.mockClear();
131
+ });
132
+
133
+ it('reloads on sort after the component is remounted', async () => {
134
+ const store = useStore();
135
+
136
+ store.updateSort(store.headers[0]);
137
+ await flush();
138
+
139
+ expect(getList).toHaveBeenCalled();
140
+ });
141
+
142
+ it('reloads on size change after the component is remounted', async () => {
143
+ const store = useStore();
144
+
145
+ store.updateSize(20);
146
+ await flush();
147
+
148
+ expect(getList).toHaveBeenCalled();
149
+ });
150
+
151
+ it('reloads on page change after the component is remounted', async () => {
152
+ const store = useStore();
153
+
154
+ store.updatePage(2);
155
+ await flush();
156
+
157
+ expect(getList).toHaveBeenCalled();
158
+ });
159
+
160
+ it('reloads on search after the component is remounted', async () => {
161
+ const store = useStore();
162
+
163
+ store.addFilter({
164
+ name: 'search',
165
+ value: 'lorem',
166
+ });
167
+ await flush();
168
+
169
+ expect(getList).toHaveBeenCalled();
170
+ });
171
+
172
+ it('reloads on a hidden column after the component is remounted', async () => {
173
+ const store = useStore();
174
+
175
+ store.updateShownHeaders(
176
+ store.headers.map((header, index) => ({
177
+ ...header,
178
+ show: index === 0,
179
+ })),
180
+ );
181
+ await flush();
182
+
183
+ expect(getList).toHaveBeenCalled();
184
+ });
185
+ });
186
+ /*
187
+ A nested list is emptied by the card store it is registered with, and until
188
+ it has a parent again it must not load: the store keeps the parent it was
189
+ initialized with, so a reload in between queried the previous card's record.
190
+
191
+ [WTEL-10350](https://webitel.atlassian.net/browse/WTEL-10350)
192
+ */
193
+ describe('$reset of a nested list', () => {
194
+ let useStore: ReturnType<typeof createTableStore>;
195
+
196
+ beforeEach(async () => {
197
+ useStore = createTableStore('cases-reset/datalist', {
198
+ apiModule: {
199
+ getList,
200
+ },
201
+ headers,
202
+ disablePersistence: true,
203
+ });
204
+
205
+ mountTab(useStore);
206
+ await flush();
207
+ getList.mockClear();
208
+ });
209
+
210
+ it('drops the rows, the parent and the pagination', async () => {
211
+ const store = useStore();
212
+ store.updatePage(3);
213
+ await flush();
214
+
215
+ store.$reset();
216
+
217
+ expect(store.dataList).toEqual([]);
218
+ expect(store.selected).toEqual([]);
219
+ expect(store.error).toBeNull();
220
+ expect(store.page).toBe(1);
221
+ });
222
+
223
+ it('keeps the headers, which are the user column choice', async () => {
224
+ const store = useStore();
225
+ store.updateShownHeaders(
226
+ store.headers.map((header, index) => ({
227
+ ...header,
228
+ show: index === 0,
229
+ })),
230
+ );
231
+ await flush();
232
+ const shown = store.headers.map((header) => header.show);
233
+
234
+ store.$reset();
235
+
236
+ expect(store.headers.map((header) => header.show)).toEqual(shown);
237
+ });
238
+
239
+ it('loads nothing until it has a parent again', async () => {
240
+ const store = useStore();
241
+ store.$reset();
242
+ getList.mockClear();
243
+
244
+ await store.loadDataList();
245
+ store.updatePage(2);
246
+ await flush();
247
+
248
+ expect(getList).not.toHaveBeenCalled();
249
+
250
+ store.initialize({
251
+ parentId: '43',
252
+ });
253
+ await flush();
254
+
255
+ expect(getList).toHaveBeenCalledWith(
256
+ expect.objectContaining({
257
+ parentId: '43',
258
+ }),
259
+ );
260
+ });
261
+ });
262
+ });
@@ -0,0 +1,218 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { createPinia, setActivePinia } from 'pinia';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { defineComponent, h, ref } from 'vue';
5
+ import { createMemoryHistory, createRouter, type Router } from 'vue-router';
6
+
7
+ import { provideCardStore } from '../../card/composables/useCardStoreProvider';
8
+ import type { CardItemId } from '../../card/types/CardStore.types';
9
+ import type { DatalistTableHeader } from '../../types/tableStore.types';
10
+ import { useNestedTableList } from '../composables/useNestedTableList';
11
+ import { createTableStore } from '../createTableStore.store';
12
+
13
+ const routes = [
14
+ {
15
+ path: '/queues',
16
+ name: 'queues',
17
+ component: {
18
+ template: '<div />',
19
+ },
20
+ },
21
+ ];
22
+
23
+ const headers = [
24
+ {
25
+ value: 'name',
26
+ field: 'name',
27
+ show: true,
28
+ sort: null,
29
+ },
30
+ ] as DatalistTableHeader[];
31
+
32
+ const flush = async () => {
33
+ for (let i = 0; i < 5; i += 1) {
34
+ await new Promise((resolve) => setTimeout(resolve));
35
+ }
36
+ };
37
+
38
+ /*
39
+ A nested list store is shared by every card of its kind and outlives all of
40
+ them, so a tab that read it directly showed the rows of whichever record was
41
+ opened before — and a card for an unsaved record showed them until saved.
42
+
43
+ [WTEL-10350](https://webitel.atlassian.net/browse/WTEL-10350)
44
+ */
45
+ describe('useNestedTableList', () => {
46
+ let router: Router;
47
+ let getList: ReturnType<typeof vi.fn>;
48
+ let useStore: ReturnType<typeof createTableStore>;
49
+ let namespace = 0;
50
+
51
+ beforeEach(async () => {
52
+ localStorage.clear();
53
+ sessionStorage.clear();
54
+ setActivePinia(createPinia());
55
+
56
+ router = createRouter({
57
+ history: createMemoryHistory(),
58
+ routes,
59
+ });
60
+ await router.push('/queues');
61
+ await router.isReady();
62
+
63
+ getList = vi.fn().mockImplementation(({ parentId }) =>
64
+ Promise.resolve({
65
+ items: [
66
+ {
67
+ id: 1,
68
+ name: `row of ${parentId}`,
69
+ },
70
+ ],
71
+ next: false,
72
+ }),
73
+ );
74
+
75
+ namespace += 1;
76
+ useStore = createTableStore(`queue-buckets-${namespace}/datalist`, {
77
+ apiModule: {
78
+ getList,
79
+ },
80
+ headers,
81
+ disablePersistence: true,
82
+ });
83
+ });
84
+
85
+ /** a card page with one nested tab in it */
86
+ const openCard = (itemId: CardItemId) => {
87
+ const cardItemId = ref<CardItemId>(itemId);
88
+ const nestedLists = new Set<{
89
+ $reset: () => void;
90
+ }>();
91
+
92
+ const Tab = defineComponent({
93
+ setup() {
94
+ useNestedTableList({
95
+ useTableStore: useStore,
96
+ });
97
+ return () => h('div');
98
+ },
99
+ });
100
+
101
+ const Card = defineComponent({
102
+ setup() {
103
+ provideCardStore({
104
+ get itemId() {
105
+ return cardItemId.value;
106
+ },
107
+ registerNestedList: (list) => nestedLists.add(list),
108
+ });
109
+
110
+ return () => h(Tab);
111
+ },
112
+ });
113
+
114
+ const card = mount(Card, {
115
+ global: {
116
+ plugins: [
117
+ router,
118
+ ],
119
+ },
120
+ });
121
+
122
+ /** what the card page does on unmount */
123
+ const close = () => {
124
+ for (const list of nestedLists) list.$reset();
125
+ nestedLists.clear();
126
+ card.unmount();
127
+ };
128
+
129
+ return {
130
+ card,
131
+ close,
132
+ cardItemId,
133
+ nestedLists,
134
+ };
135
+ };
136
+
137
+ it('loads the list of the card it is opened in', async () => {
138
+ openCard('42');
139
+ await flush();
140
+
141
+ expect(getList).toHaveBeenCalledWith(
142
+ expect.objectContaining({
143
+ parentId: '42',
144
+ }),
145
+ );
146
+ expect(useStore().dataList).toEqual([
147
+ {
148
+ id: 1,
149
+ name: 'row of 42',
150
+ },
151
+ ]);
152
+ });
153
+
154
+ it('registers itself with the card, which empties it on close', async () => {
155
+ const first = openCard('42');
156
+ await flush();
157
+ expect(first.nestedLists.size).toBe(1);
158
+
159
+ first.close();
160
+ await flush();
161
+
162
+ expect(useStore().dataList).toEqual([]);
163
+ expect(first.nestedLists.size).toBe(0);
164
+ });
165
+
166
+ it('shows no rows of the previously opened card', async () => {
167
+ const first = openCard('42');
168
+ await flush();
169
+ first.close();
170
+ await flush();
171
+
172
+ getList.mockClear();
173
+ const second = openCard(null); // a record that is not saved yet
174
+ await flush();
175
+
176
+ expect(getList).not.toHaveBeenCalled();
177
+ expect(useStore().dataList).toEqual([]);
178
+ second.close();
179
+ });
180
+
181
+ it('loads once the unsaved record gets its id', async () => {
182
+ const card = openCard(null);
183
+ await flush();
184
+ expect(getList).not.toHaveBeenCalled();
185
+
186
+ card.cardItemId.value = '43';
187
+ await flush();
188
+
189
+ expect(getList).toHaveBeenCalledWith(
190
+ expect.objectContaining({
191
+ parentId: '43',
192
+ }),
193
+ );
194
+ expect(useStore().dataList).toEqual([
195
+ {
196
+ id: 1,
197
+ name: 'row of 43',
198
+ },
199
+ ]);
200
+ });
201
+
202
+ it('does not reload the previous parent when the store reloads itself', async () => {
203
+ const first = openCard('42');
204
+ await flush();
205
+ first.close();
206
+ await flush();
207
+
208
+ getList.mockClear();
209
+ const store = useStore();
210
+
211
+ // what a pagination change does, with no card to belong to
212
+ store.updatePage(2);
213
+ await flush();
214
+
215
+ expect(getList).not.toHaveBeenCalled();
216
+ expect(store.dataList).toEqual([]);
217
+ });
218
+ });
@@ -0,0 +1,57 @@
1
+ import type { StoreDefinition } from 'pinia';
2
+ import { computed, type MaybeRefOrGetter, toValue, watch } from 'vue';
3
+
4
+ import { useInjectedCardStore } from '../../card/composables/useCardStoreProvider';
5
+ import type { CardItemId } from '../../card/types/CardStore.types';
6
+
7
+ /**
8
+ * The list of a card page's nested tab.
9
+ *
10
+ * The store is shared by every card of its kind and outlives all of them, so a
11
+ * tab that read it directly showed the rows of whichever record was opened
12
+ * before — and a card for an unsaved record, having no id to initialize with,
13
+ * showed them until it was saved.
14
+ *
15
+ * Registering the list with the card store makes the card responsible for
16
+ * emptying it, and the parent to load comes from the card's own `itemId`: it
17
+ * arrives late for a record created from a nested tab, and the list follows.
18
+ *
19
+ * ```ts
20
+ * const tableStore = useNestedTableList({ useTableStore: useQueueBucketsStore });
21
+ * const { dataList, isLoading } = storeToRefs(tableStore);
22
+ * ```
23
+ *
24
+ * [WTEL-10350](https://webitel.atlassian.net/browse/WTEL-10350)
25
+ */
26
+ export const useNestedTableList = ({
27
+ useTableStore,
28
+ parentId,
29
+ }: {
30
+ useTableStore: StoreDefinition;
31
+ /** only where the parent is not the card's own record */
32
+ parentId?: MaybeRefOrGetter<CardItemId>;
33
+ }) => {
34
+ const tableStore = useTableStore();
35
+ const cardStore = useInjectedCardStore();
36
+
37
+ cardStore?.registerNestedList(tableStore);
38
+
39
+ const parent = computed<CardItemId>(
40
+ () => toValue(parentId) ?? cardStore?.itemId ?? null,
41
+ );
42
+
43
+ watch(
44
+ parent,
45
+ (id) => {
46
+ if (id)
47
+ tableStore.initialize({
48
+ parentId: id,
49
+ });
50
+ },
51
+ {
52
+ immediate: true,
53
+ },
54
+ );
55
+
56
+ return tableStore;
57
+ };
@@ -34,6 +34,13 @@ export const tableStoreBody = <Entity extends Identifiable>(
34
34
  const useFiltersStore = createTableFiltersStore(namespace, config);
35
35
 
36
36
  const parentId = ref();
37
+ /**
38
+ * A list initialized with a `parentId` is nested in a card page, and stays
39
+ * nested for the store's lifetime — the api module addresses its parent.
40
+ * `parentId` is dropped on `$reset`, this is not: it is what tells a reset
41
+ * store apart from a registry one that never had a parent.
42
+ */
43
+ const isNested = ref(false);
37
44
 
38
45
  const paginationStore = usePaginationStore();
39
46
  const { page, size, next } = makeThisToRefs<typeof paginationStore>(
@@ -129,6 +136,15 @@ export const tableStoreBody = <Entity extends Identifiable>(
129
136
  const loadDataList = async ({
130
137
  withLoading = true,
131
138
  }: LoadDataListOptions = {}) => {
139
+ /*
140
+ a nested list with no parent has nothing to address: either its card was
141
+ left (the card store reset it) or the record is not saved yet. Loading
142
+ here would query whatever parent the store held before.
143
+
144
+ [WTEL-10350](https://webitel.atlassian.net/browse/WTEL-10350)
145
+ */
146
+ if (isNested.value && !parentId.value) return;
147
+
132
148
  if (withLoading) {
133
149
  isLoading.value = true;
134
150
  }
@@ -344,6 +360,7 @@ export const tableStoreBody = <Entity extends Identifiable>(
344
360
  } = {}) => {
345
361
  if (storeParentId) {
346
362
  parentId.value = storeParentId;
363
+ isNested.value = true;
347
364
  }
348
365
 
349
366
  const isStoreAlreadySetUp = isStoreSetUp.value;
@@ -365,6 +382,24 @@ export const tableStoreBody = <Entity extends Identifiable>(
365
382
  return loadDataList();
366
383
  };
367
384
 
385
+ /**
386
+ * Drops everything that belonged to one parent record. Called by the card
387
+ * store this list is registered with, when the card page goes away.
388
+ *
389
+ * Headers are left alone — they are the user's column choice, restored once
390
+ * per app lifetime — and so is `isStoreSetUp`, for the same reason.
391
+ */
392
+ const $reset = () => {
393
+ dataList.value = [];
394
+ selected.value = [];
395
+ error.value = null;
396
+ isLoading.value = false;
397
+ parentId.value = undefined;
398
+
399
+ paginationStore.$reset();
400
+ filtersManager.value.reset();
401
+ };
402
+
368
403
  const resetInfiniteScrollTableParamsToDefaults = () => {
369
404
  paginationStore.$reset();
370
405
  filtersManager.value.reset();
@@ -373,6 +408,7 @@ export const tableStoreBody = <Entity extends Identifiable>(
373
408
 
374
409
  return {
375
410
  isStoreSetUp, // internal export for pinia devtools
411
+ isNested, // internal export for pinia devtools
376
412
 
377
413
  dataList,
378
414
  selected,
@@ -395,6 +431,7 @@ export const tableStoreBody = <Entity extends Identifiable>(
395
431
 
396
432
  setupStore, // only setup, no data loading
397
433
  initialize, // setup + load data
434
+ $reset, // drop the data and the parent binding
398
435
  syncPersistence, // republish store state into the route query
399
436
 
400
437
  loadDataList,