@webitel/ui-datalist 26.8.4 → 26.8.6

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": "26.8.4",
3
+ "version": "26.8.6",
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",
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { useNestedTableList } from './modules/table/composables/useNestedTableList';
1
2
  import { createTableStore } from './modules/table/createTableStore.store';
2
3
  import type { DatalistTableHeader } from './modules/types/tableStore.types';
3
4
 
4
5
  export type { DatalistTableHeader };
5
- export { createTableStore };
6
+ export { createTableStore, useNestedTableList };
@@ -7,6 +7,10 @@ import { useCardAnyFieldEditedWatcher } from './useCardAnyFieldEditedWatcher';
7
7
  import { useCardIsNew } from './useCardIsNew';
8
8
  import { useCardRouting } from './useCardRouting';
9
9
  import { useCardSaveAction } from './useCardSaveAction';
10
+ import {
11
+ type NestedListsOwner,
12
+ provideCardStore,
13
+ } from './useCardStoreProvider';
10
14
  import { useCardValidation } from './useCardValidation';
11
15
  import { useItemCardSaveText } from './useItemCardSaveText';
12
16
 
@@ -31,6 +35,11 @@ export const useCardComponent = <
31
35
  }) => {
32
36
  const cardStore = useCardStore();
33
37
 
38
+ // the nested tabs register their lists here, and the card empties them.
39
+ // `useCardStore` is a bare StoreDefinition, as everywhere else in here, so
40
+ // the store's own shape has to be asserted rather than inferred
41
+ provideCardStore(cardStore as unknown as NestedListsOwner);
42
+
34
43
  const {
35
44
  itemId,
36
45
  originalItemInstance,
@@ -0,0 +1,26 @@
1
+ import { type InjectionKey, inject, provide } from 'vue';
2
+
3
+ import type { CardItemId, NestedList } from '../types/CardStore.types';
4
+
5
+ /**
6
+ * What a nested tab needs from the card page it lives in: the record it is a
7
+ * tab of, and somewhere to register its list so the card can empty it.
8
+ */
9
+ export interface NestedListsOwner {
10
+ itemId: CardItemId;
11
+ registerNestedList: (list: NestedList) => void;
12
+ }
13
+
14
+ export const CardStoreKey: InjectionKey<NestedListsOwner> =
15
+ Symbol('datalistCardStore');
16
+
17
+ /**
18
+ * `useCardComponent` provides the card's own store, so the tabs below it need
19
+ * no wiring of their own — see `useNestedTableList`.
20
+ */
21
+ export const provideCardStore = (store: NestedListsOwner) => {
22
+ provide(CardStoreKey, store);
23
+ };
24
+
25
+ /** `null` outside a card page, where a list has no owner to reset it */
26
+ export const useInjectedCardStore = () => inject(CardStoreKey, null);
@@ -1,6 +1,7 @@
1
1
  export * from './composables/useCardAnyFieldEditedWatcher';
2
2
  export * from './composables/useCardComponent';
3
3
  export * from './composables/useCardListNavigation';
4
+ export * from './composables/useCardStoreProvider';
4
5
  export * from './composables/useCardTabs';
5
6
  export * from './composables/useNestedCardComponent';
6
7
  export * from './stores/createCardStore';
@@ -0,0 +1,73 @@
1
+ import { createPinia, setActivePinia } from 'pinia';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { z } from 'zod/v4';
4
+
5
+ import { createCardStore } from '../createCardStore';
6
+
7
+ const cardStoreOf = (namespace: string) =>
8
+ createCardStore({
9
+ namespace,
10
+ apiModule: {
11
+ get: vi.fn(),
12
+ add: vi.fn(),
13
+ update: vi.fn(),
14
+ },
15
+ standardValidationSchema: z.object({
16
+ name: z.string().optional(),
17
+ }),
18
+ });
19
+
20
+ /*
21
+ The lists of a card's nested tabs live in stores shared by every card of their
22
+ kind, so the card that filled them is the one that has to empty them.
23
+
24
+ [WTEL-10350](https://webitel.atlassian.net/browse/WTEL-10350)
25
+ */
26
+ describe('createCardStore nested lists', () => {
27
+ beforeEach(() => {
28
+ setActivePinia(createPinia());
29
+ });
30
+
31
+ it('empties every registered list on reset', () => {
32
+ const store = cardStoreOf('cases/card')();
33
+ const lists = [
34
+ {
35
+ $reset: vi.fn(),
36
+ },
37
+ {
38
+ $reset: vi.fn(),
39
+ },
40
+ ];
41
+
42
+ for (const list of lists) store.registerNestedList(list);
43
+ store.$reset();
44
+
45
+ for (const list of lists) expect(list.$reset).toHaveBeenCalledOnce();
46
+ });
47
+
48
+ it('forgets them, so the next card resets only its own', () => {
49
+ const store = cardStoreOf('contacts/card')();
50
+ const list = {
51
+ $reset: vi.fn(),
52
+ };
53
+
54
+ store.registerNestedList(list);
55
+ store.$reset();
56
+ store.$reset();
57
+
58
+ expect(list.$reset).toHaveBeenCalledOnce();
59
+ });
60
+
61
+ it('registers a list once, however many times its tab is mounted', () => {
62
+ const store = cardStoreOf('queues/card')();
63
+ const list = {
64
+ $reset: vi.fn(),
65
+ };
66
+
67
+ store.registerNestedList(list);
68
+ store.registerNestedList(list);
69
+ store.$reset();
70
+
71
+ expect(list.$reset).toHaveBeenCalledOnce();
72
+ });
73
+ });
@@ -10,7 +10,11 @@ import type { MaybeRef } from 'vue';
10
10
  import { effectScope, ref, toRaw, toValue, watch } from 'vue';
11
11
  import type { z } from 'zod/v4';
12
12
 
13
- import type { CardItemId, CardParentId } from '../types/CardStore.types';
13
+ import type {
14
+ CardItemId,
15
+ CardParentId,
16
+ NestedList,
17
+ } from '../types/CardStore.types';
14
18
 
15
19
  const defaultRegleValidationOptions: RegleSchemaBehaviourOptions &
16
20
  RegleBehaviourOptions = {
@@ -167,6 +171,19 @@ export const createCardStore = <
167
171
  }
168
172
  };
169
173
 
174
+ /**
175
+ * The lists of the card's nested tabs. They live in stores of their own,
176
+ * shared by every card of this kind and outliving all of them, so the card
177
+ * that filled them is the one that has to empty them.
178
+ *
179
+ * [WTEL-10350](https://webitel.atlassian.net/browse/WTEL-10350)
180
+ */
181
+ const nestedLists = new Set<NestedList>();
182
+
183
+ const registerNestedList = (list: NestedList) => {
184
+ nestedLists.add(list);
185
+ };
186
+
170
187
  const initialize = ({
171
188
  itemId: initialItemId,
172
189
  parentId: initialParentId,
@@ -186,6 +203,9 @@ export const createCardStore = <
186
203
  };
187
204
 
188
205
  const $reset = () => {
206
+ for (const list of nestedLists) list.$reset();
207
+ nestedLists.clear();
208
+
189
209
  itemId.value = null;
190
210
  parentId.value = null;
191
211
  createValidationSchema();
@@ -212,6 +232,7 @@ export const createCardStore = <
212
232
  initialize,
213
233
  saveItem,
214
234
  $reset,
235
+ registerNestedList,
215
236
  };
216
237
  });
217
238
  };
@@ -1,2 +1,10 @@
1
1
  export type CardItemId = string | number | null;
2
2
  export type CardParentId = CardItemId;
3
+
4
+ /**
5
+ * What a card store needs from the list of one of its nested tabs: the ability
6
+ * to empty it when the card page goes away.
7
+ */
8
+ export interface NestedList {
9
+ $reset: () => void;
10
+ }
@@ -183,4 +183,80 @@ describe('tableStoreBody', () => {
183
183
  expect(getList).toHaveBeenCalled();
184
184
  });
185
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
+ });
186
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,61 @@
1
+ import { computed, type MaybeRefOrGetter, toValue, watch } from 'vue';
2
+
3
+ import { useInjectedCardStore } from '../../card/composables/useCardStoreProvider';
4
+ import type { CardItemId, NestedList } from '../../card/types/CardStore.types';
5
+
6
+ /** what this needs of a table store, which `createTableStore` satisfies */
7
+ interface NestedTableListStore extends NestedList {
8
+ initialize: (options: { parentId: string | number }) => unknown;
9
+ }
10
+
11
+ /**
12
+ * The list of a card page's nested tab.
13
+ *
14
+ * The store is shared by every card of its kind and outlives all of them, so a
15
+ * tab that read it directly showed the rows of whichever record was opened
16
+ * before — and a card for an unsaved record, having no id to initialize with,
17
+ * showed them until it was saved.
18
+ *
19
+ * Registering the list with the card store makes the card responsible for
20
+ * emptying it, and the parent to load comes from the card's own `itemId`: it
21
+ * arrives late for a record created from a nested tab, and the list follows.
22
+ *
23
+ * ```ts
24
+ * const tableStore = useNestedTableList({ useTableStore: useQueueBucketsStore });
25
+ * const { dataList, isLoading } = storeToRefs(tableStore);
26
+ * ```
27
+ *
28
+ * [WTEL-10350](https://webitel.atlassian.net/browse/WTEL-10350)
29
+ */
30
+ export const useNestedTableList = <Store extends NestedTableListStore>({
31
+ useTableStore,
32
+ parentId,
33
+ }: {
34
+ useTableStore: () => Store;
35
+ /** only where the parent is not the card's own record */
36
+ parentId?: MaybeRefOrGetter<CardItemId>;
37
+ }) => {
38
+ const tableStore = useTableStore();
39
+ const cardStore = useInjectedCardStore();
40
+
41
+ cardStore?.registerNestedList(tableStore);
42
+
43
+ const parent = computed<CardItemId>(
44
+ () => toValue(parentId) ?? cardStore?.itemId ?? null,
45
+ );
46
+
47
+ watch(
48
+ parent,
49
+ (id) => {
50
+ if (id)
51
+ tableStore.initialize({
52
+ parentId: id,
53
+ });
54
+ },
55
+ {
56
+ immediate: true,
57
+ },
58
+ );
59
+
60
+ return tableStore;
61
+ };
@@ -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,