@webitel/ui-datalist 26.8.2 → 26.8.4

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.2",
3
+ "version": "26.8.4",
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,146 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { effectScope, nextTick, type Ref, reactive, ref } from 'vue';
3
+
4
+ import type { CardItemId } from '../../types/CardStore.types';
5
+ import { useCardRouting } from '../useCardRouting';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ route: {
9
+ params: {},
10
+ } as {
11
+ params: Record<string, string>;
12
+ },
13
+ replace: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('vue-router', () => ({
17
+ useRoute: () => mocks.route,
18
+ useRouter: () => ({
19
+ replace: mocks.replace,
20
+ }),
21
+ }));
22
+
23
+ /**
24
+ * A nested card lives inside its parent's card page, so the parent can be
25
+ * created underneath it: `route.params.id` goes from `'new'` to a real id while
26
+ * the popup is already mounted. A `parentId` captured as a plain string at
27
+ * setup keeps addressing `'new'`, and every nested request goes to
28
+ * `<entity>/new/<nested>`.
29
+ *
30
+ * [WTEL-10348](https://webitel.atlassian.net/browse/WTEL-10348)
31
+ */
32
+ describe('useCardRouting', () => {
33
+ const run = (options: {
34
+ itemId: Ref<CardItemId>;
35
+ routeParamName?: string;
36
+ parentId?: (() => CardItemId) | string;
37
+ }) => {
38
+ const scope = effectScope(true);
39
+ const routing = scope.run(() => useCardRouting(options));
40
+
41
+ if (!routing) throw new Error('useCardRouting returned nothing');
42
+
43
+ return routing;
44
+ };
45
+
46
+ beforeEach(() => {
47
+ mocks.replace.mockReset();
48
+ mocks.replace.mockResolvedValue(undefined);
49
+ mocks.route = reactive({
50
+ params: {},
51
+ });
52
+ });
53
+
54
+ it('resolves a getter parent on read instead of capturing it', () => {
55
+ mocks.route.params = {
56
+ id: 'new',
57
+ };
58
+
59
+ const { parentId } = run({
60
+ itemId: ref(null),
61
+ routeParamName: 'bucketId',
62
+ parentId: () => mocks.route.params.id,
63
+ });
64
+
65
+ expect(parentId.value).toBe('new');
66
+
67
+ mocks.route.params = {
68
+ id: '42',
69
+ };
70
+
71
+ expect(parentId.value).toBe('42');
72
+ });
73
+
74
+ it('keeps accepting a plain string parent', () => {
75
+ const { parentId } = run({
76
+ itemId: ref(null),
77
+ routeParamName: 'bucketId',
78
+ parentId: '42',
79
+ });
80
+
81
+ expect(parentId.value).toBe('42');
82
+ });
83
+
84
+ it('writes the server id into the route of a newly created nested item', async () => {
85
+ mocks.route.params = {
86
+ id: '42',
87
+ bucketId: 'new',
88
+ };
89
+ const itemId = ref<CardItemId>(null);
90
+
91
+ run({
92
+ itemId,
93
+ routeParamName: 'bucketId',
94
+ parentId: () => mocks.route.params.id,
95
+ });
96
+
97
+ itemId.value = 7;
98
+ await nextTick();
99
+
100
+ expect(mocks.replace).toHaveBeenCalledWith({
101
+ params: {
102
+ id: '42',
103
+ bucketId: 7,
104
+ },
105
+ });
106
+ });
107
+
108
+ it('leaves the route alone for a nested item that already had an id', async () => {
109
+ mocks.route.params = {
110
+ id: '42',
111
+ bucketId: '7',
112
+ };
113
+ const itemId = ref<CardItemId>(null);
114
+
115
+ run({
116
+ itemId,
117
+ routeParamName: 'bucketId',
118
+ parentId: () => mocks.route.params.id,
119
+ });
120
+
121
+ itemId.value = 7;
122
+ await nextTick();
123
+
124
+ expect(mocks.replace).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it('writes the server id into the route of a top-level card', async () => {
128
+ mocks.route.params = {
129
+ id: 'new',
130
+ };
131
+ const itemId = ref<CardItemId>(null);
132
+
133
+ run({
134
+ itemId,
135
+ });
136
+
137
+ itemId.value = 42;
138
+ await nextTick();
139
+
140
+ expect(mocks.replace).toHaveBeenCalledWith({
141
+ params: {
142
+ id: 42,
143
+ },
144
+ });
145
+ });
146
+ });
@@ -1,35 +1,41 @@
1
- import { computed, type Ref, watch } from 'vue';
1
+ import { computed, type MaybeRefOrGetter, type Ref, toValue, watch } from 'vue';
2
2
  import { useRoute, useRouter } from 'vue-router';
3
3
 
4
- import type { CardItemId } from '../types/CardStore.types';
4
+ import type { CardItemId, CardParentId } from '../types/CardStore.types';
5
5
 
6
6
  /**
7
7
  * Routing logic shared by top-level and nested card components.
8
8
  *
9
9
  * - `routeParamName` — which `route.params` key holds the item ID (default: `'id'`)
10
10
  * - `parentId` — when provided, the card is treated as nested: URL is updated
11
- * only when a newly created item receives its server-assigned ID
11
+ * only when a newly created item receives its server-assigned ID. Pass a ref
12
+ * or a getter whenever the parent can be created while the card is already
13
+ * mounted — a popup living inside its parent's card page watches
14
+ * `route.params.id` go from `'new'` to a real id underneath it, and a plain
15
+ * string captured at setup would keep addressing `'new'`.
12
16
  */
13
17
  export const useCardRouting = ({
14
18
  itemId,
15
19
  routeParamName = 'id',
16
- parentId,
20
+ parentId: rawParentId,
17
21
  manualSetup = false,
18
22
  }: {
19
23
  itemId: Ref<CardItemId>;
20
24
  routeParamName?: string;
21
- parentId?: string;
25
+ parentId?: MaybeRefOrGetter<CardParentId>;
22
26
  manualSetup?: boolean;
23
27
  }) => {
24
28
  const router = useRouter();
25
29
  const route = useRoute();
26
30
 
27
31
  const routeId = computed(() => route.params[routeParamName]);
32
+ /** resolved on read, so a nested card always addresses the current parent */
33
+ const parentId = computed(() => toValue(rawParentId));
28
34
 
29
35
  if (!manualSetup) {
30
36
  const unwatch = watch(itemId, async (next, prev) => {
31
37
  if (next && !prev) {
32
- if (!parentId || routeId.value === 'new') {
38
+ if (!parentId.value || routeId.value === 'new') {
33
39
  await router.replace({
34
40
  params: {
35
41
  ...route.params,
@@ -1,6 +1,7 @@
1
1
  import { type StoreDefinition, storeToRefs } from 'pinia';
2
- import { watch } from 'vue';
2
+ import { type MaybeRefOrGetter, watch } from 'vue';
3
3
 
4
+ import type { CardParentId } from '../types/CardStore.types';
4
5
  import { useCardComponent } from './useCardComponent';
5
6
  import { useCardRouting } from './useCardRouting';
6
7
 
@@ -9,7 +10,9 @@ import { useCardRouting } from './useCardRouting';
9
10
  *
10
11
  * Wraps `useCardComponent` with `manualSetup: true` and delegates
11
12
  * routing to `useCardRouting`. Pass `parentId` from the component —
12
- * its presence indicates a nested card context.
13
+ * its presence indicates a nested card context. A ref or getter is read on
14
+ * every `initialize`, so a popup mounted while its parent was still `'new'`
15
+ * follows the parent's real id instead of addressing `'new'` forever.
13
16
  *
14
17
  * @example
15
18
  * ```ts
@@ -34,7 +37,7 @@ export const useNestedCardComponent = <
34
37
  useCardStore: StoreDefinition;
35
38
  onLoadErrorHandler?: (err: unknown) => void;
36
39
  routeParamName: string;
37
- parentId?: string;
40
+ parentId?: MaybeRefOrGetter<CardParentId>;
38
41
  }) => {
39
42
  const cardSetup = useCardComponent<CardEntity>({
40
43
  useCardStore,
@@ -46,7 +49,7 @@ export const useNestedCardComponent = <
46
49
  const { itemId } = storeToRefs(cardStore);
47
50
  const { initialize, $reset } = cardStore;
48
51
 
49
- const { routeId } = useCardRouting({
52
+ const { routeId, parentId: currentParentId } = useCardRouting({
50
53
  itemId,
51
54
  routeParamName,
52
55
  parentId,
@@ -58,7 +61,7 @@ export const useNestedCardComponent = <
58
61
  if (value) {
59
62
  initialize({
60
63
  itemId: value === 'new' ? null : value,
61
- parentId,
64
+ parentId: currentParentId.value,
62
65
  });
63
66
  } else {
64
67
  $reset();
@@ -172,7 +172,7 @@ export const createCardStore = <
172
172
  parentId: initialParentId,
173
173
  }: {
174
174
  itemId?: string | number;
175
- parentId?: string | number;
175
+ parentId?: CardParentId;
176
176
  } = {}) => {
177
177
  if (initialParentId) {
178
178
  parentId.value = initialParentId;
@@ -0,0 +1,186 @@
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
+ });
@@ -1,6 +1,6 @@
1
1
  import deepEqual from 'deep-equal';
2
2
  import set from 'lodash/set';
3
- import { type Ref, ref, toRaw, watch } from 'vue';
3
+ import { nextTick, type Ref, ref, toRaw, watch } from 'vue';
4
4
 
5
5
  import {
6
6
  createDatalistStore,
@@ -247,6 +247,53 @@ export const tableStoreBody = <Entity extends Identifiable>(
247
247
  }
248
248
  };
249
249
 
250
+ let loadingAfterFiltersChange = false;
251
+
252
+ watch(
253
+ [
254
+ () => filtersManager.value.getAllValues(),
255
+ sort,
256
+ fields,
257
+ size,
258
+ ],
259
+ async () => {
260
+ if (!isStoreSetUp.value) {
261
+ return;
262
+ }
263
+
264
+ /*
265
+ * @author @Lera24
266
+ * https://webitel.atlassian.net/browse/WTEL-7597?focusedCommentId=697115
267
+ * */
268
+ if (isReorderingColumn.value) {
269
+ return;
270
+ }
271
+ loadingAfterFiltersChange = true;
272
+ updatePage(1);
273
+ await loadDataList();
274
+ loadingAfterFiltersChange = false;
275
+ },
276
+ /* filtersManager requires deep watching for its values */
277
+ {
278
+ deep: true,
279
+ },
280
+ );
281
+
282
+ watch(
283
+ [
284
+ page,
285
+ ],
286
+ () => {
287
+ if (!isStoreSetUp.value) {
288
+ return;
289
+ }
290
+
291
+ if (!loadingAfterFiltersChange && !isAppendDataList) {
292
+ return loadDataList();
293
+ }
294
+ },
295
+ );
296
+
250
297
  const setupStore = async () => {
251
298
  if (isStoreSetUp.value) {
252
299
  return;
@@ -260,44 +307,13 @@ export const tableStoreBody = <Entity extends Identifiable>(
260
307
  ]);
261
308
  }
262
309
 
263
- let loadingAfterFiltersChange = false;
264
-
265
- watch(
266
- [
267
- () => filtersManager.value.getAllValues(),
268
- sort,
269
- fields,
270
- size,
271
- ],
272
- async () => {
273
- /*
274
- * @author @Lera24
275
- * https://webitel.atlassian.net/browse/WTEL-7597?focusedCommentId=697115
276
- * */
277
- if (isReorderingColumn.value) {
278
- return;
279
- }
280
- loadingAfterFiltersChange = true;
281
- updatePage(1);
282
- await loadDataList();
283
- loadingAfterFiltersChange = false;
284
- },
285
- /* filtersManager requires deep watching for its values */
286
- {
287
- deep: true,
288
- },
289
- );
290
-
291
- watch(
292
- [
293
- page,
294
- ],
295
- () => {
296
- if (!loadingAfterFiltersChange && !isAppendDataList) {
297
- return loadDataList();
298
- }
299
- },
300
- );
310
+ /*
311
+ * lets any reactive updates the restore above just made (e.g. to
312
+ * sort/fields/filters) finish flushing through the watchers above
313
+ * while `isStoreSetUp` is still false, before they start reacting to
314
+ * real changes
315
+ */
316
+ await nextTick();
301
317
 
302
318
  isStoreSetUp.value = true;
303
319
  };