@controleonline/ui-default 1.0.275 → 1.0.277

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.
@@ -1,566 +1,2 @@
1
- import Formatter from '@controleonline/ui-common/src/utils/formatter.js';
2
- import { getDateRange } from '@controleonline/ui-common/src/react/utils/dateRangeFilter';
3
- import {
4
- getColumnKey,
5
- isDateLikeColumn,
6
- normalizeText,
7
- resolveCellText,
8
- resolveStoreNameFromList,
9
- } from '../inputs/defaultInputUtils';
10
- import styles from './DefaultTable.styles';
11
-
12
- export const DEFAULT_CELL_MIN_WIDTH = 118;
13
- export const DEFAULT_COMPACT_BREAKPOINT = 768;
14
- export const COLLAPSED_SEARCH_MAX_VIEWPORT_WIDTH = 400;
15
- export const COLLAPSED_SEARCH_MAX_CONTAINER_WIDTH = 350;
16
- export const END_REACHED_THRESHOLD = 0.75;
17
- export const IDENTITY_CELL_MIN_WIDTH = 76;
18
- export const MONEY_CELL_MIN_WIDTH = 132;
19
- export const ACTIONS_CELL_WIDTH = 60;
20
-
21
- const SUMMARY_OPERATIONS = ['sum', 'count', 'avg', 'min', 'max'];
22
- const COMPANY_SCOPED_LIST_STORES = new Set([
23
- 'categories',
24
- 'paymentType',
25
- 'wallet',
26
- ]);
27
-
28
- export const shouldIncludeColumn = column =>
29
- Boolean(getColumnKey(column)) &&
30
- column?.show !== false &&
31
- column?.visible !== false &&
32
- column?.table !== false;
33
-
34
- export const resolveListActionName = list =>
35
- normalizeText(list).split('/')[1] || 'getItems';
36
-
37
- const resolveListLoadParams = ({ currentCompanyId, listStoreName }) => {
38
- if (!currentCompanyId || !COMPANY_SCOPED_LIST_STORES.has(listStoreName)) {
39
- return {};
40
- }
41
-
42
- return listStoreName === 'categories'
43
- ? { company: currentCompanyId }
44
- : { people: currentCompanyId };
45
- };
46
-
47
- export const resolveColumnListLoadParams = ({
48
- column,
49
- currentCompanyId,
50
- requestParams = {},
51
- searchValue = '',
52
- }) => {
53
- const listStoreName = resolveStoreNameFromList(column?.list);
54
- const companyScopedParams = resolveListLoadParams({
55
- currentCompanyId,
56
- listStoreName,
57
- });
58
- const resolvedCustomParams = typeof column?.listRequestParams === 'function'
59
- ? column.listRequestParams({ currentCompanyId, requestParams })
60
- : column?.listRequestParams;
61
- const customParams =
62
- resolvedCustomParams &&
63
- typeof resolvedCustomParams === 'object' &&
64
- !Array.isArray(resolvedCustomParams)
65
- ? resolvedCustomParams
66
- : {};
67
-
68
- return {
69
- ...companyScopedParams,
70
- ...customParams,
71
- ...(normalizeText(searchValue)
72
- ? { [column?.listSearchParam || column?.searchParam || 'search']: normalizeText(searchValue) }
73
- : {}),
74
- };
75
- };
76
-
77
- export const isObject = value =>
78
- value !== null && typeof value === 'object' && !Array.isArray(value);
79
-
80
- export const getRowKey = (row, index = 0) =>
81
- String(row?.['@id'] || row?.id || index);
82
-
83
- export const shouldTriggerEndReachedFromScroll = event => {
84
- const nativeEvent = event?.nativeEvent || {};
85
- const viewportHeight = Number(nativeEvent.layoutMeasurement?.height);
86
- const contentOffsetY = Number(nativeEvent.contentOffset?.y);
87
- const contentHeight = Number(nativeEvent.contentSize?.height);
88
-
89
- if (
90
- !Number.isFinite(viewportHeight) ||
91
- !Number.isFinite(contentOffsetY) ||
92
- !Number.isFinite(contentHeight) ||
93
- viewportHeight <= 0 ||
94
- contentHeight <= viewportHeight
95
- ) {
96
- return false;
97
- }
98
-
99
- const distanceFromEnd = contentHeight - (contentOffsetY + viewportHeight);
100
-
101
- return distanceFromEnd <= viewportHeight * END_REACHED_THRESHOLD;
102
- };
103
-
104
- const normalizeRowIdentity = row => {
105
- const rawId = row?.['@id'] || row?.id;
106
- if (rawId === null || rawId === undefined || rawId === '') {
107
- return '';
108
- }
109
-
110
- return normalizeText(rawId).replace(/\D+/g, '');
111
- };
112
-
113
- export const mergeSortedDataWithLiveItems = ({ liveItems = [], sortedData = [] }) => {
114
- const resolvedSortedData = Array.isArray(sortedData) ? sortedData : [];
115
- const resolvedLiveItems = Array.isArray(liveItems) ? liveItems : [];
116
-
117
- if (resolvedLiveItems.length === 0) {
118
- return resolvedSortedData;
119
- }
120
-
121
- if (resolvedSortedData.length === 0) {
122
- return resolvedLiveItems;
123
- }
124
-
125
- const liveItemsById = new Map();
126
- resolvedLiveItems.forEach(item => {
127
- const itemId = normalizeRowIdentity(item);
128
- if (itemId) {
129
- liveItemsById.set(itemId, item);
130
- }
131
- });
132
-
133
- if (liveItemsById.size === 0) {
134
- return resolvedSortedData;
135
- }
136
-
137
- return resolvedSortedData.map(item => {
138
- const itemId = normalizeRowIdentity(item);
139
- const liveItem = itemId ? liveItemsById.get(itemId) : null;
140
-
141
- if (!liveItem) {
142
- return item;
143
- }
144
-
145
- return liveItem;
146
- });
147
- };
148
-
149
- export const stableSerialize = value => {
150
- if (value === null || value === undefined) {
151
- return 'null';
152
- }
153
-
154
- if (Array.isArray(value)) {
155
- return `[${value.map(item => stableSerialize(item)).join(',')}]`;
156
- }
157
-
158
- if (value instanceof Date) {
159
- return JSON.stringify(value.toISOString());
160
- }
161
-
162
- if (isObject(value)) {
163
- return `{${Object.keys(value)
164
- .sort()
165
- .map(key => `${JSON.stringify(key)}:${stableSerialize(value[key])}`)
166
- .join(',')}}`;
167
- }
168
-
169
- return JSON.stringify(value);
170
- };
171
-
172
- export const resolveDateRangeQuery = value => {
173
- if (!value || typeof value !== 'object') {
174
- return {};
175
- }
176
-
177
- const shortcut = value.shortcut || value.value || 'all';
178
- const customRange = value.customRange || { from: '', to: '' };
179
- const dateRange = getDateRange(shortcut, customRange, {
180
- relativeMode: 'rolling',
181
- useCurrentMoment: true,
182
- });
183
-
184
- return {
185
- after: dateRange?.after || '',
186
- before: dateRange?.before || '',
187
- };
188
- };
189
-
190
- export const resolveFilterQueryValue = value => {
191
- if (Array.isArray(value)) {
192
- return value
193
- .map(item => resolveFilterQueryValue(item))
194
- .filter(item => item !== '' && item !== null && item !== undefined);
195
- }
196
-
197
- if (value && typeof value === 'object') {
198
- return resolveFilterQueryValue(
199
- value.value ?? value.id ?? value['@id'] ?? value.key ?? '',
200
- );
201
- }
202
-
203
- return normalizeText(value);
204
- };
205
-
206
- export const normalizeCollectionItems = response => {
207
- if (Array.isArray(response)) return response;
208
- if (Array.isArray(response?.member)) return response.member;
209
- if (Array.isArray(response?.['hydra:member'])) return response['hydra:member'];
210
-
211
- return [];
212
- };
213
-
214
- export const resolveHasMore = ({ hasMore, dataLength, totalItems }) => {
215
- if (hasMore !== null && hasMore !== undefined) {
216
- return hasMore;
217
- }
218
-
219
- const resolvedTotalItems = Number(totalItems);
220
- if (!Number.isFinite(resolvedTotalItems)) {
221
- return false;
222
- }
223
-
224
- return dataLength < resolvedTotalItems;
225
- };
226
-
227
- const humanizeSummaryLabel = value =>
228
- normalizeText(value)
229
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
230
- .replace(/[._-]+/g, ' ')
231
- .replace(/\s+/g, ' ')
232
- .trim();
233
-
234
- const isMoneySummaryPath = path =>
235
- /(amount|price|total|value|paid|open|receivable|payable|pending)/i.test(
236
- Array.isArray(path) ? path.join('.') : normalizeText(path),
237
- );
238
-
239
- const normalizeSummaryMoneyValue = value => {
240
- if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
241
-
242
- const rawValue = normalizeText(value).replace(/[^0-9,.-]/g, '');
243
- if (!rawValue) return 0;
244
-
245
- const normalizedValue = rawValue.includes(',')
246
- ? rawValue.replace(/\./g, '').replace(',', '.')
247
- : rawValue;
248
- const parsedValue = Number(normalizedValue);
249
-
250
- return Number.isFinite(parsedValue) ? parsedValue : 0;
251
- };
252
-
253
- export const getSummaryOperations = column => {
254
- if (typeof column?.summary === 'string') return [column.summary];
255
- if (Array.isArray(column?.summary)) return column.summary;
256
- if (isObject(column?.summary)) {
257
- return Object.entries(column.summary)
258
- .filter(([, value]) => value)
259
- .map(([operation]) => operation);
260
- }
261
-
262
- return SUMMARY_OPERATIONS.filter(operation => column?.[operation] === true);
263
- };
264
-
265
- export const getSummaryField = (column, operation) => {
266
- if (isObject(column?.summary)) {
267
- const summaryField = column.summary[operation];
268
- if (typeof summaryField === 'string') return summaryField;
269
- }
270
-
271
- return getColumnKey(column);
272
- };
273
-
274
- export const formatSummaryValue = ({ column, columns, path, storeName, value }) => {
275
- const columnKey = column ? getColumnKey(column) : '';
276
- const shouldFormatMoney = isMoneySummaryPath(path) || isMoneySummaryPath(columnKey);
277
-
278
- if (shouldFormatMoney) {
279
- return Formatter.formatMoney(normalizeSummaryMoneyValue(value));
280
- }
281
-
282
- if (column) {
283
- return resolveCellText({
284
- column,
285
- columns,
286
- row: { [columnKey]: value },
287
- storeName,
288
- value,
289
- });
290
- }
291
-
292
- return normalizeText(value);
293
- };
294
-
295
- export const flattenSummaryEntries = ({
296
- path = [],
297
- summaryLabels = {},
298
- usedPaths,
299
- value,
300
- }) => {
301
- if (!isObject(value)) {
302
- const pathKey = path.join('.');
303
- if (!pathKey || usedPaths.has(pathKey)) return [];
304
-
305
- const fallbackLabel = humanizeSummaryLabel(path[path.length - 1] || pathKey);
306
-
307
- return [{
308
- key: pathKey,
309
- label: summaryLabels[pathKey] || summaryLabels[path[path.length - 1]] || fallbackLabel,
310
- path,
311
- value,
312
- }];
313
- }
314
-
315
- return Object.entries(value).flatMap(([key, childValue]) =>
316
- flattenSummaryEntries({
317
- path: [...path, key],
318
- summaryLabels,
319
- usedPaths,
320
- value: childValue,
321
- }),
322
- );
323
- };
324
-
325
- export const isSortableColumn = column => column?.sortable === true;
326
-
327
- export const getSortField = column => column?.sortField || getColumnKey(column);
328
-
329
- export const resolveDefaultSort = columns => {
330
- if (!Array.isArray(columns)) {
331
- return null;
332
- }
333
-
334
- for (const column of columns) {
335
- if (!column || column?.defaultSort === undefined || column?.defaultSort === null || column?.defaultSort === false) {
336
- continue;
337
- }
338
-
339
- const field = getSortField(column);
340
- const defaultSort = column.defaultSort;
341
-
342
- if (defaultSort && typeof defaultSort === 'object' && !Array.isArray(defaultSort)) {
343
- const resolvedDirection = normalizeText(defaultSort.direction || defaultSort.order || 'desc').toLowerCase();
344
- const resolvedField = normalizeText(
345
- defaultSort.field || defaultSort.sortField || defaultSort.key || defaultSort.name || field,
346
- );
347
-
348
- return {
349
- direction: resolvedDirection === 'asc' ? 'asc' : 'desc',
350
- field: resolvedField || field,
351
- };
352
- }
353
-
354
- if (typeof defaultSort === 'string') {
355
- const normalizedSort = normalizeText(defaultSort).toLowerCase();
356
- if (normalizedSort === 'asc' || normalizedSort === 'desc') {
357
- return {
358
- direction: normalizedSort,
359
- field,
360
- };
361
- }
362
-
363
- return {
364
- direction: 'desc',
365
- field: normalizeText(defaultSort) || field,
366
- };
367
- }
368
-
369
- if (defaultSort === true) {
370
- return {
371
- direction: 'asc',
372
- field,
373
- };
374
- }
375
- }
376
-
377
- return null;
378
- };
379
-
380
- export const sanitizeStoredSortPreference = ({
381
- columns = [],
382
- fallbackSort = null,
383
- sort = null,
384
- }) => {
385
- const normalizedFallback =
386
- fallbackSort &&
387
- typeof fallbackSort === 'object' &&
388
- typeof fallbackSort.field === 'string' &&
389
- fallbackSort.field.trim()
390
- ? {
391
- direction: fallbackSort.direction === 'asc' ? 'asc' : 'desc',
392
- field: fallbackSort.field.trim(),
393
- }
394
- : null;
395
-
396
- if (
397
- !sort ||
398
- typeof sort !== 'object' ||
399
- typeof sort.field !== 'string' ||
400
- !sort.field.trim()
401
- ) {
402
- return normalizedFallback;
403
- }
404
-
405
- const normalizedDirection = sort.direction === 'asc' ? 'asc' : 'desc';
406
- const normalizedField = sort.field.trim();
407
- const sortableFields = new Set(
408
- (Array.isArray(columns) ? columns : [])
409
- .filter(isSortableColumn)
410
- .map(column => getSortField(column))
411
- .filter(Boolean),
412
- );
413
-
414
- if (!sortableFields.has(normalizedField)) {
415
- return normalizedFallback;
416
- }
417
-
418
- return {
419
- direction: normalizedDirection,
420
- field: normalizedField,
421
- };
422
- };
423
-
424
- const readValueByPath = (object, path) => {
425
- if (!object || !path) return object;
426
-
427
- return String(path)
428
- .split('.')
429
- .reduce((currentValue, key) => {
430
- if (currentValue === null || currentValue === undefined) return currentValue;
431
- return currentValue?.[key];
432
- }, object);
433
- };
434
-
435
- export const normalizeSortText = value =>
436
- normalizeText(value)
437
- .normalize('NFD')
438
- .replace(/[\u0300-\u036f]/g, '')
439
- .toLowerCase();
440
-
441
- export const parseSortNumber = value => {
442
- if (typeof value === 'number' && Number.isFinite(value)) return value;
443
- if (value == null) return NaN;
444
-
445
- const raw = String(value).trim();
446
- if (!raw || !/[0-9]/.test(raw)) return NaN;
447
-
448
- const compact = raw.replace(/[^0-9,.-]/g, '');
449
- if (!compact) return NaN;
450
-
451
- const hasComma = compact.includes(',');
452
- const hasDot = compact.includes('.');
453
- let normalized = compact;
454
-
455
- if (hasComma && hasDot) {
456
- normalized = compact.lastIndexOf(',') > compact.lastIndexOf('.')
457
- ? compact.replace(/\./g, '').replace(',', '.')
458
- : compact.replace(/,/g, '');
459
- } else if (hasComma) {
460
- const parts = compact.split(',');
461
- normalized = parts.length === 2 && parts[1].length <= 2
462
- ? `${parts[0].replace(/\./g, '')}.${parts[1]}`
463
- : compact.replace(/,/g, '');
464
- } else if (hasDot) {
465
- const parts = compact.split('.');
466
- if (parts.length > 2) {
467
- normalized = compact.replace(/\./g, '');
468
- } else if (parts.length === 2 && parts[1].length === 3 && parts[0].length <= 3) {
469
- normalized = compact.replace(/\./g, '');
470
- }
471
- }
472
-
473
- const parsed = Number(normalized);
474
- return Number.isFinite(parsed) ? parsed : NaN;
475
- };
476
-
477
- export const resolveSortComparable = ({ column, row, storeName, columns }) => {
478
- const fieldName = getColumnKey(column);
479
- const sortField = getSortField(column);
480
- const rawValue = sortField === fieldName ? row?.[fieldName] : readValueByPath(row, sortField);
481
-
482
- if (isDateLikeColumn(column)) {
483
- const dateValue =
484
- rawValue && typeof rawValue === 'object'
485
- ? rawValue?.value ??
486
- rawValue?.date ??
487
- rawValue?.createdAt ??
488
- rawValue?.updatedAt ??
489
- rawValue?.['@id'] ??
490
- rawValue?.[fieldName] ??
491
- rawValue
492
- : rawValue;
493
- const parsedDate = Date.parse(dateValue);
494
- return Number.isFinite(parsedDate) ? parsedDate : Number.NEGATIVE_INFINITY;
495
- }
496
-
497
- const resolvedValue = sortField === fieldName
498
- ? resolveCellText({
499
- column,
500
- columns,
501
- row,
502
- storeName,
503
- })
504
- : normalizeText(rawValue ?? resolveCellText({
505
- column,
506
- columns,
507
- row,
508
- storeName,
509
- }));
510
-
511
- const normalizedNumber = parseSortNumber(resolvedValue);
512
-
513
- if (Number.isFinite(normalizedNumber)) {
514
- return normalizedNumber;
515
- }
516
-
517
- return normalizeSortText(resolvedValue);
518
- };
519
-
520
- export const getColumnStyle = column => {
521
- const key = getColumnKey(column);
522
- const customSize = {};
523
- const columnWidth = Number(column?.width);
524
- const columnMinWidth = Number(column?.minWidth);
525
- const columnFlexBasis = Number(column?.flexBasis);
526
-
527
- if (Number.isFinite(columnWidth) && columnWidth > 0) {
528
- customSize.width = columnWidth;
529
- customSize.flexBasis = columnWidth;
530
- }
531
-
532
- if (Number.isFinite(columnMinWidth) && columnMinWidth > 0) {
533
- customSize.minWidth = columnMinWidth;
534
- }
535
-
536
- if (Number.isFinite(columnFlexBasis) && columnFlexBasis > 0) {
537
- customSize.flexBasis = columnFlexBasis;
538
- }
539
-
540
- const columnSizeStyle = Object.keys(customSize).length ? customSize : null;
541
-
542
- if (column?.isIdentity) return [styles.cell, styles.identityCell, columnSizeStyle];
543
- if (['price', 'total', 'amount', 'value'].includes(key)) {
544
- return [styles.cell, styles.moneyCell, columnSizeStyle];
545
- }
546
- return [styles.cell, columnSizeStyle];
547
- };
548
-
549
- export const getColumnMinWidth = column => {
550
- const columnMinWidth = Number(column?.minWidth);
551
- if (Number.isFinite(columnMinWidth) && columnMinWidth > 0) {
552
- return columnMinWidth;
553
- }
554
-
555
- const columnWidth = Number(column?.width);
556
- if (Number.isFinite(columnWidth) && columnWidth > 0) {
557
- return columnWidth;
558
- }
559
-
560
- const key = getColumnKey(column);
561
- if (column?.isIdentity) return IDENTITY_CELL_MIN_WIDTH;
562
- if (['price', 'total', 'amount', 'value'].includes(key)) {
563
- return MONEY_CELL_MIN_WIDTH;
564
- }
565
- return DEFAULT_CELL_MIN_WIDTH;
566
- };
1
+ export * from './DefaultTable.dataUtils';
2
+ export * from './DefaultTable.layoutUtils';
@@ -54,6 +54,7 @@ const DefaultTableCards = ({ storeName }) => {
54
54
  const tableTextColor = palette.text;
55
55
  const hasBottomAddButton = configs.addButtonPlacement === 'bottom';
56
56
  const handleListScroll = event => {
57
+ flatListProps.onScroll?.(event);
57
58
  if (shouldTriggerEndReachedFromScroll(event)) {
58
59
  configs.onEndReached?.();
59
60
  }
@@ -10,7 +10,7 @@ const isReactComponentType = value =>
10
10
  export const hasDefaultTableRowActionsComponent = component =>
11
11
  typeof component === 'function' ||
12
12
  React.isValidElement(component) ||
13
- isReactComponentType(component);
13
+ Boolean(isReactComponentType(component));
14
14
 
15
15
  export const resolveDefaultTableRowActionsWidth = configs => {
16
16
  const rawWidth = configs?.rowActionsWidth ?? configs?.actionsColumnWidth;
@@ -113,6 +113,7 @@ const useAutoPageLoader = ({
113
113
  }
114
114
 
115
115
  const query = buildRequestQuery(page, append);
116
+ if (refresh && resolvedActions.catalogCache) query.__storeMeta = {catalogRefresh: true};
116
117
 
117
118
  return Promise.resolve(resolvedActions.getItems(query))
118
119
  .then(response => {
@@ -121,11 +122,11 @@ const useAutoPageLoader = ({
121
122
  }
122
123
 
123
124
  const pageItems = normalizeCollectionItems(response);
124
- autoPageRef.current = page;
125
+ autoPageRef.current = Number(response?.catalogPage) || page;
125
126
  autoLoadedQueryKeyRef.current = autoQuerySignature;
126
127
  autoErroredQueryKeyRef.current = '';
127
128
  setAutoHasLoaded(true);
128
- setAutoLastPageCount(pageItems.length);
129
+ setAutoLastPageCount(response?.catalogLastPageCount ?? pageItems.length);
129
130
  return pageItems;
130
131
  })
131
132
  .catch(error => {
@@ -25,13 +25,13 @@ const {
25
25
  useDefaultTableStoreSync,
26
26
  } = require('../../../../react/components/table/useDefaultTableStoreSync');
27
27
 
28
- function Harness({data, store, storeFilters, columnsForTable, tablePreferenceScope}) {
28
+ function Harness({data, store, storeFilters, columnsForTable, tablePreferenceScope, configsSignature = 'sig', configs = {}}) {
29
29
  useDefaultTableStoreSync({
30
30
  columns: [],
31
31
  columnsForTable: columnsForTable || [],
32
32
  data,
33
- defaultTableConfigs: {},
34
- defaultTableConfigsSignature: 'sig',
33
+ defaultTableConfigs: configs,
34
+ defaultTableConfigsSignature: configsSignature,
35
35
  store,
36
36
  storeColumnsLength: 0,
37
37
  storeFilters: storeFilters || {},
@@ -158,4 +158,40 @@ describe('useDefaultTableStoreSync', () => {
158
158
  expect(areTableFiltersEqual(a, {alterDate: {shortcut: 'all'}})).toBe(false);
159
159
  expect(areTableFiltersEqual({}, {})).toBe(true);
160
160
  });
161
+
162
+ it('publishes changed same-count table configs but ignores equivalent snapshots', () => {
163
+ const setConfigs = jest.fn();
164
+ const store = {actions: {setConfigs, setItems}, getters: {}};
165
+ const first = [{id: 'device:396', configs: [{id: 487}]}];
166
+ const second = [{id: 'device:403', configs: [{id: 510}]}];
167
+ let tree;
168
+ renderer.act(() => {
169
+ tree = renderer.create(React.createElement(Harness, {
170
+ data: first,
171
+ store,
172
+ configs: {sortedData: first},
173
+ configsSignature: JSON.stringify(first),
174
+ }));
175
+ });
176
+ expect(setConfigs).toHaveBeenCalledTimes(1);
177
+ renderer.act(() => {
178
+ tree.update(React.createElement(Harness, {
179
+ data: second,
180
+ store,
181
+ configs: {sortedData: second},
182
+ configsSignature: JSON.stringify(second),
183
+ }));
184
+ });
185
+ expect(setConfigs).toHaveBeenCalledTimes(2);
186
+ renderer.act(() => {
187
+ tree.update(React.createElement(Harness, {
188
+ data: [{id: 'device:403', configs: [{id: 510}]}],
189
+ store: {actions: {setConfigs, setItems}, getters: {}},
190
+ configs: {sortedData: second},
191
+ configsSignature: JSON.stringify(second),
192
+ }));
193
+ });
194
+ expect(setConfigs).toHaveBeenCalledTimes(2);
195
+ tree.unmount();
196
+ });
161
197
  });