@controleonline/ui-default 1.0.263 → 1.0.265

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 (61) hide show
  1. package/AGENTS.md +47 -45
  2. package/package.json +20 -20
  3. package/src/react/components/errors/DefaultErrors.js +307 -325
  4. package/src/react/components/files/DefaultFile.js +46 -46
  5. package/src/react/components/filters/CompactFilterSelector.js +78 -24
  6. package/src/react/components/filters/CompactFilterSelector.styles.js +17 -5
  7. package/src/react/components/filters/DefaultColumnFilter.js +191 -57
  8. package/src/react/components/filters/DefaultExternalFilters.js +245 -182
  9. package/src/react/components/filters/DefaultSearch.js +131 -92
  10. package/src/react/components/filters/DefaultSearch.styles.js +65 -70
  11. package/src/react/components/form/DefaultForm.js +172 -151
  12. package/src/react/components/form/DefaultForm.styles.js +71 -66
  13. package/src/react/components/help/DefaultTooltip.js +87 -87
  14. package/src/react/components/help/DefaultTooltip.styles.js +61 -61
  15. package/src/react/components/inputs/DefaultDateInput.js +150 -0
  16. package/src/react/components/inputs/DefaultInput.js +162 -127
  17. package/src/react/components/inputs/DefaultInput.styles.js +6 -0
  18. package/src/react/components/inputs/DefaultSelect.js +320 -152
  19. package/src/react/components/inputs/defaultInputUtils.js +40 -19
  20. package/src/react/components/map/DefaultGoogleMap.styles.js +115 -9
  21. package/src/react/components/map/DefaultGoogleMap.web.js +560 -446
  22. package/src/react/components/map/DefaultMap.native.js +73 -71
  23. package/src/react/components/map/DefaultMap.styles.js +16 -16
  24. package/src/react/components/map/DefaultMap.web.js +68 -66
  25. package/src/react/components/map/DefaultNativeMap.native.js +307 -183
  26. package/src/react/components/map/DefaultNativeMap.shared.js +439 -340
  27. package/src/react/components/map/DefaultNativeMap.styles.js +156 -122
  28. package/src/react/components/table/DefaultColumnMenu.js +108 -0
  29. package/src/react/components/table/DefaultDebug.js +233 -0
  30. package/src/react/components/table/DefaultFiltersModal.js +142 -0
  31. package/src/react/components/table/DefaultModalButton.js +18 -0
  32. package/src/react/components/table/DefaultSearchModal.js +49 -0
  33. package/src/react/components/table/DefaultTable.js +345 -1846
  34. package/src/react/components/table/DefaultTable.styles.js +247 -136
  35. package/src/react/components/table/DefaultTable.utils.js +502 -0
  36. package/src/react/components/table/DefaultTableBody.js +26 -0
  37. package/src/react/components/table/DefaultTableCards.js +171 -0
  38. package/src/react/components/table/DefaultTableControls.js +208 -0
  39. package/src/react/components/table/DefaultTableEmptyState.js +22 -0
  40. package/src/react/components/table/DefaultTableFooter.js +172 -0
  41. package/src/react/components/table/DefaultTableInput.js +145 -0
  42. package/src/react/components/table/DefaultTableRows.js +340 -0
  43. package/src/react/components/table/DefaultTableSearch.js +100 -0
  44. package/src/react/components/table/DefaultTableToolbar.js +98 -0
  45. package/src/react/components/table/DefaultToolbarAction.js +56 -0
  46. package/src/react/components/table/useDefaultTablePagination.js +370 -0
  47. package/src/react/components/table/useDefaultTableSorting.js +126 -0
  48. package/src/react/components/table/useDefaultTableTheme.js +58 -0
  49. package/src/react/index.js +10 -10
  50. package/src/react/utils/tableVisibleColumnsPreferences.js +239 -211
  51. package/src/store/default/actions.js +51 -13
  52. package/src/store/default/getters.js +10 -8
  53. package/src/store/default/mutation_types.js +4 -2
  54. package/src/store/default/mutations.js +10 -1
  55. package/src/tests/react/components/errors/DefaultErrors.test.js +162 -162
  56. package/src/tests/react/components/files/DefaultFile.test.js +80 -80
  57. package/src/tests/react/components/form/DefaultForm.test.js +81 -0
  58. package/src/tests/react/components/inputs/DefaultSelect.test.js +185 -0
  59. package/src/tests/react/components/table/DefaultTable.test.js +1183 -0
  60. package/src/tests/react/store/defaultActions.test.js +59 -0
  61. package/src/tests/react/utils/tableVisibleColumnsPreferences.test.js +247 -168
@@ -0,0 +1,1183 @@
1
+ const React = require('react');
2
+ const renderer = require('react-test-renderer');
3
+
4
+ const {afterEach, beforeEach, describe, expect, it} = global;
5
+
6
+ global.IS_REACT_ACT_ENVIRONMENT = true;
7
+
8
+ let consoleErrorSpy = null;
9
+ let mockStores = {};
10
+ let mockWindowDimensions = {width: 480, height: 800};
11
+ let mockCapturedColumnFilterProps = [];
12
+ let mockCapturedDefaultInputProps = [];
13
+
14
+ const createLocalStorageMock = () => {
15
+ let storage = {};
16
+
17
+ return {
18
+ clear: () => {
19
+ storage = {};
20
+ },
21
+ getItem: key => (key in storage ? storage[key] : null),
22
+ removeItem: key => {
23
+ delete storage[key];
24
+ },
25
+ setItem: (key, value) => {
26
+ storage[key] = String(value);
27
+ },
28
+ };
29
+ };
30
+
31
+ jest.mock('@store', () => ({
32
+ getAllStores: jest.fn(() => ({})),
33
+ useStore: jest.fn(name => mockStores[name] || {actions: {}, getters: {}}),
34
+ }));
35
+
36
+ jest.mock('@react-navigation/native', () => ({
37
+ useIsFocused: jest.fn(() => false),
38
+ useRoute: jest.fn(() => ({
39
+ key: 'OrderHistoryPage-key',
40
+ name: 'OrderHistoryPage',
41
+ })),
42
+ }));
43
+
44
+ jest.mock('@controleonline/ui-common/src/react/components/MessageService', () => ({
45
+ useMessage: jest.fn(() => ({
46
+ showError: jest.fn(),
47
+ })),
48
+ }));
49
+
50
+ jest.mock('react-native-vector-icons/Feather', () => {
51
+ const React = require('react');
52
+ return props => React.createElement('icon', props, props.children);
53
+ });
54
+
55
+ jest.mock('react-native', () => {
56
+ const React = require('react');
57
+ const createComponent = name => props =>
58
+ React.createElement(name, props, props.children);
59
+
60
+ const FlatList = props => {
61
+ const items = Array.isArray(props.data) ? props.data : [];
62
+
63
+ return React.createElement(
64
+ 'FlatList',
65
+ props,
66
+ items.map((item, index) =>
67
+ props.renderItem ? props.renderItem({item, index}) : null,
68
+ ),
69
+ items.length === 0 && props.ListEmptyComponent ? props.ListEmptyComponent : null,
70
+ props.ListFooterComponent || null,
71
+ );
72
+ };
73
+
74
+ return {
75
+ FlatList,
76
+ Modal: createComponent('Modal'),
77
+ Platform: {
78
+ select: value => value.web || value.default || null,
79
+ },
80
+ ScrollView: createComponent('ScrollView'),
81
+ StyleSheet: {create: value => value},
82
+ Text: createComponent('Text'),
83
+ TouchableOpacity: createComponent('TouchableOpacity'),
84
+ View: createComponent('View'),
85
+ useWindowDimensions: jest.fn(() => mockWindowDimensions),
86
+ };
87
+ });
88
+
89
+ jest.mock('../../../../react/components/filters/DefaultColumnFilter', () => props => {
90
+ const React = require('react');
91
+ mockCapturedColumnFilterProps.push(props);
92
+ return React.createElement('DefaultColumnFilter', props);
93
+ });
94
+
95
+ jest.mock('../../../../react/components/filters/DefaultSearch', () => props => {
96
+ const React = require('react');
97
+ return React.createElement('DefaultSearch', props);
98
+ });
99
+
100
+ jest.mock('../../../../react/components/form/DefaultForm', () => () => {
101
+ const React = require('react');
102
+ return React.createElement('DefaultForm');
103
+ });
104
+
105
+ jest.mock('../../../../react/components/inputs/DefaultInput', () => props => {
106
+ const React = require('react');
107
+ mockCapturedDefaultInputProps.push(props);
108
+ return React.createElement('DefaultInput', props);
109
+ });
110
+
111
+ jest.mock('@controleonline/ui-common/src/react/components/StateStore', () => props => {
112
+ const React = require('react');
113
+ return React.createElement('StateStore', props, props.children);
114
+ });
115
+
116
+ const {
117
+ default: DefaultTable,
118
+ mergeSortedDataWithLiveItems,
119
+ resolveColumnListLoadParams,
120
+ shouldTriggerEndReachedFromScroll,
121
+ } = require('../../../../react/components/table/DefaultTable');
122
+ const {
123
+ DEFAULT_TABLE_PREFERENCES_STORAGE_KEY,
124
+ } = require('../../../../react/utils/tableVisibleColumnsPreferences');
125
+ const reactNavigation = require('@react-navigation/native');
126
+ const {getAllStores} = require('@store');
127
+ const STORE_ACTION_META_KEY = '__storeMeta';
128
+
129
+ const flattenStyle = style => {
130
+ if (Array.isArray(style)) {
131
+ return style.reduce(
132
+ (acc, item) => ({
133
+ ...acc,
134
+ ...flattenStyle(item),
135
+ }),
136
+ {},
137
+ );
138
+ }
139
+
140
+ return style && typeof style === 'object' ? style : {};
141
+ };
142
+
143
+ describe('resolveColumnListLoadParams', () => {
144
+ it('uses company for category stores and people for financial owner stores', () => {
145
+ expect(resolveColumnListLoadParams({
146
+ column: {list: 'categories/getItems'},
147
+ currentCompanyId: 21,
148
+ })).toEqual({company: 21});
149
+ expect(resolveColumnListLoadParams({
150
+ column: {list: 'wallet/getItems'},
151
+ currentCompanyId: 21,
152
+ })).toEqual({people: 21});
153
+ expect(resolveColumnListLoadParams({
154
+ column: {list: 'paymentType/getItems'},
155
+ currentCompanyId: 21,
156
+ })).toEqual({people: 21});
157
+ });
158
+
159
+ it('resolves contextual list params without losing the company scope', () => {
160
+ expect(resolveColumnListLoadParams({
161
+ column: {
162
+ list: 'categories/getItems',
163
+ listRequestParams: ({requestParams}) => ({context: requestParams.context}),
164
+ },
165
+ currentCompanyId: 21,
166
+ requestParams: {context: 'receive'},
167
+ })).toEqual({company: 21, context: 'receive'});
168
+ });
169
+ });
170
+
171
+ describe('mergeSortedDataWithLiveItems', () => {
172
+ it('keeps the sorted order and replaces stale rows with live store items', () => {
173
+ const sortedData = [
174
+ {
175
+ '@id': '/invoices/332',
176
+ id: 332,
177
+ status: {id: 32, status: 'open'},
178
+ },
179
+ {
180
+ '@id': '/invoices/333',
181
+ id: 333,
182
+ status: {id: 33, status: 'paid'},
183
+ },
184
+ ];
185
+ const liveItems = [
186
+ {
187
+ id: 333,
188
+ status: {id: 33, status: 'paid'},
189
+ },
190
+ {
191
+ id: 332,
192
+ status: {id: 33, status: 'paid'},
193
+ },
194
+ {
195
+ id: 334,
196
+ status: {id: 33, status: 'paid'},
197
+ },
198
+ ];
199
+
200
+ const mergedData = mergeSortedDataWithLiveItems({liveItems, sortedData});
201
+
202
+ expect(mergedData.map(item => item.id)).toEqual([332, 333]);
203
+ expect(mergedData[0].status.status).toBe('paid');
204
+ });
205
+ });
206
+
207
+ describe('shouldTriggerEndReachedFromScroll', () => {
208
+ it('returns true before the scroll reaches the exact end', () => {
209
+ expect(shouldTriggerEndReachedFromScroll({
210
+ nativeEvent: {
211
+ contentOffset: {y: 650},
212
+ contentSize: {height: 1200},
213
+ layoutMeasurement: {height: 400},
214
+ },
215
+ })).toBe(true);
216
+
217
+ expect(shouldTriggerEndReachedFromScroll({
218
+ nativeEvent: {
219
+ contentOffset: {y: 100},
220
+ contentSize: {height: 1200},
221
+ layoutMeasurement: {height: 400},
222
+ },
223
+ })).toBe(false);
224
+ });
225
+ });
226
+
227
+ describe('DefaultTable', () => {
228
+ beforeEach(() => {
229
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
230
+ mockWindowDimensions = {width: 480, height: 800};
231
+ mockCapturedColumnFilterProps = [];
232
+ mockCapturedDefaultInputProps = [];
233
+ getAllStores.mockImplementation(() => ({}));
234
+ global.localStorage = createLocalStorageMock();
235
+ global.t = {
236
+ t: jest.fn((store, type, key) => {
237
+ if (type === 'label' && key === 'search') return 'Buscar';
238
+ if (type === 'label' && key === 'items') return 'registros';
239
+ if (type === 'label' && key === 'loading') return 'Carregando...';
240
+ if (type === 'label' && key === 'empty') return 'Nenhum registro encontrado';
241
+ if (type === 'label' && key === 'filters') return 'Filtros';
242
+ if (type === 'label' && key === 'select') return 'Selecionar';
243
+ if (type === 'button' && key === 'add') return 'Adicionar';
244
+ if (type === 'button' && key === 'edit') return 'Editar';
245
+ if (type === 'button' && key === 'apply') return 'Aplicar';
246
+ if (type === 'button' && key === 'clear') return 'Limpar';
247
+ if (type === 'input' && key === 'search') return 'Buscar...';
248
+ return undefined;
249
+ }),
250
+ };
251
+ reactNavigation.useIsFocused.mockImplementation(() => false);
252
+
253
+ mockStores = {
254
+ people: {
255
+ getters: {
256
+ currentCompany: {
257
+ theme: {
258
+ colors: {},
259
+ },
260
+ },
261
+ },
262
+ },
263
+ theme: {
264
+ getters: {
265
+ colors: {},
266
+ },
267
+ },
268
+ };
269
+ });
270
+
271
+ afterEach(() => {
272
+ delete global.t;
273
+ if (consoleErrorSpy) {
274
+ consoleErrorSpy.mockRestore();
275
+ consoleErrorSpy = null;
276
+ }
277
+ });
278
+
279
+ it('keeps the list/card toggle visible on compact layouts even when cards are forced', () => {
280
+ let tree;
281
+
282
+ renderer.act(() => {
283
+ tree = renderer.create(
284
+ React.createElement(DefaultTable, {
285
+ columns: [{key: 'name', label: 'Nome'}],
286
+ data: [],
287
+ showColumnFiltersButton: false,
288
+ }),
289
+ );
290
+ });
291
+
292
+ const iconNames = tree.root.findAllByType('icon').map(node => node.props.name);
293
+
294
+ expect(iconNames).toEqual(expect.arrayContaining(['grid', 'columns']));
295
+ });
296
+
297
+ it('respects the compact grid/list toggle in the table body', () => {
298
+ mockWindowDimensions = {width: 480, height: 800};
299
+ mockStores.orders = {
300
+ actions: {},
301
+ getters: {
302
+ columns: [{key: 'name', label: 'Nome'}],
303
+ items: [{id: 1, name: 'Pedido'}],
304
+ visibleColumns: {},
305
+ },
306
+ };
307
+ let tree;
308
+
309
+ renderer.act(() => {
310
+ tree = renderer.create(
311
+ React.createElement(DefaultTable, {
312
+ data: [{id: 1, name: 'Pedido'}],
313
+ showColumnFiltersButton: false,
314
+ storeName: 'orders',
315
+ }),
316
+ );
317
+ });
318
+
319
+ expect(
320
+ tree.root.findAllByType('ScrollView').some(node => node.props.horizontal === true),
321
+ ).toBe(false);
322
+
323
+ const toggleViewButton = tree.root
324
+ .findAllByType('TouchableOpacity')
325
+ .find(node => node.findAllByType('icon').some(icon => icon.props.name === 'grid'));
326
+
327
+ renderer.act(() => {
328
+ toggleViewButton.props.onPress();
329
+ tree.update(
330
+ React.createElement(DefaultTable, {
331
+ data: [{id: 1, name: 'Pedido'}],
332
+ showColumnFiltersButton: false,
333
+ storeName: 'orders',
334
+ }),
335
+ );
336
+ });
337
+
338
+ expect(
339
+ tree.root.findAllByType('ScrollView').some(node => node.props.horizontal === true),
340
+ ).toBe(true);
341
+ expect(tree.root.findAllByType('icon').map(node => node.props.name)).toContain('list');
342
+ });
343
+
344
+ it('pins identity and action columns on table view', () => {
345
+ mockWindowDimensions = {width: 1200, height: 800};
346
+ mockStores.orders = {
347
+ actions: {},
348
+ getters: {
349
+ columns: [
350
+ {key: 'id', label: 'ID', isIdentity: true},
351
+ {key: 'name', label: 'Nome'},
352
+ ],
353
+ items: [{id: 1, name: 'Pedido'}],
354
+ visibleColumns: {},
355
+ },
356
+ };
357
+ const rowActionsComponent = () => React.createElement('row-actions');
358
+ let tree;
359
+
360
+ renderer.act(() => {
361
+ tree = renderer.create(
362
+ React.createElement(DefaultTable, {
363
+ rowActionsComponent,
364
+ storeName: 'orders',
365
+ }),
366
+ );
367
+ });
368
+
369
+ const renderedStyles = [
370
+ ...tree.root.findAllByType('View'),
371
+ ...tree.root.findAllByType('TouchableOpacity'),
372
+ ].map(node => flattenStyle(node.props.style));
373
+
374
+ expect(renderedStyles).toEqual(
375
+ expect.arrayContaining([
376
+ expect.objectContaining({position: 'sticky', left: 0}),
377
+ expect.objectContaining({position: 'sticky', right: 0}),
378
+ ]),
379
+ );
380
+
381
+ const horizontalScroll = tree.root
382
+ .findAllByType('ScrollView')
383
+ .find(node => node.props.horizontal);
384
+
385
+ renderer.act(() => {
386
+ horizontalScroll.props.onLayout({nativeEvent: {layout: {width: 300}}});
387
+ horizontalScroll.props.onContentSizeChange(600, 200);
388
+ horizontalScroll.props.onScroll({nativeEvent: {contentOffset: {x: 120}}});
389
+ });
390
+
391
+ const scrolledStyles = [
392
+ ...tree.root.findAllByType('View'),
393
+ ...tree.root.findAllByType('TouchableOpacity'),
394
+ ].map(node => flattenStyle(node.props.style));
395
+
396
+ expect(scrolledStyles).toEqual(
397
+ expect.arrayContaining([
398
+ expect.objectContaining({transform: [{translateX: 120}]}),
399
+ expect.objectContaining({transform: [{translateX: -180}]}),
400
+ ]),
401
+ );
402
+ });
403
+
404
+ it('keeps the complete search aligned in the toolbar when there is enough room', () => {
405
+ mockWindowDimensions = {width: 430, height: 932};
406
+ mockStores.orders = {
407
+ actions: {},
408
+ getters: {
409
+ columns: [{key: 'name', label: 'Nome', searchable: true}],
410
+ },
411
+ };
412
+ let tree;
413
+
414
+ renderer.act(() => {
415
+ tree = renderer.create(
416
+ React.createElement(DefaultTable, {
417
+ columns: [{key: 'name', label: 'Nome'}],
418
+ data: [],
419
+ storeName: 'orders',
420
+ }),
421
+ );
422
+ });
423
+
424
+ expect(tree.root.findAllByType('DefaultSearch')).toHaveLength(1);
425
+ expect(tree.root.findAllByType('icon').map(node => node.props.name)).not.toContain('search');
426
+ });
427
+
428
+ it('collapses search into an icon on narrow toolbars and opens the search modal', () => {
429
+ mockWindowDimensions = {width: 375, height: 667};
430
+ mockStores.orders = {
431
+ actions: {},
432
+ getters: {
433
+ columns: [{key: 'name', label: 'Nome', searchable: true}],
434
+ },
435
+ };
436
+ let tree;
437
+
438
+ renderer.act(() => {
439
+ tree = renderer.create(
440
+ React.createElement(DefaultTable, {
441
+ columns: [{key: 'name', label: 'Nome'}],
442
+ data: [],
443
+ storeName: 'orders',
444
+ }),
445
+ );
446
+ });
447
+
448
+ expect(tree.root.findAllByType('DefaultSearch')).toHaveLength(0);
449
+ const searchButton = tree.root
450
+ .findAllByType('TouchableOpacity')
451
+ .find(node => node.findAllByType('icon').some(icon => icon.props.name === 'search'));
452
+
453
+ expect(searchButton.props.accessibilityRole).toBe('button');
454
+ expect(searchButton.props.accessibilityLabel).toBe('Buscar');
455
+
456
+ renderer.act(() => searchButton.props.onPress());
457
+
458
+ expect(tree.root.findAllByType('DefaultSearch')).toHaveLength(1);
459
+ expect(tree.root.findByType('DefaultSearch').props.autoFocus).toBe(true);
460
+ });
461
+
462
+ it('gives collapsed search a stable accessible name without a placeholder', () => {
463
+ mockWindowDimensions = {width: 375, height: 667};
464
+ mockStores.orders = {
465
+ actions: {},
466
+ getters: {
467
+ columns: [{key: 'name', label: 'Nome', searchable: true}],
468
+ },
469
+ };
470
+ let tree;
471
+
472
+ renderer.act(() => {
473
+ tree = renderer.create(
474
+ React.createElement(DefaultTable, {
475
+ columns: [{key: 'name', label: 'Nome'}],
476
+ data: [],
477
+ storeName: 'orders',
478
+ }),
479
+ );
480
+ });
481
+
482
+ const searchButton = tree.root
483
+ .findAllByType('TouchableOpacity')
484
+ .find(node => node.findAllByType('icon').some(icon => icon.props.name === 'search'));
485
+
486
+ expect(searchButton.props.accessibilityRole).toBe('button');
487
+ expect(searchButton.props.accessibilityLabel).toBe('Buscar');
488
+ });
489
+
490
+ it('keeps the financial compact toolbar aligned when the total is already in the footer', () => {
491
+ mockWindowDimensions = {width: 375, height: 667};
492
+ mockStores.orders = {
493
+ actions: {},
494
+ getters: {
495
+ columns: [{key: 'name', label: 'Nome', searchable: true}],
496
+ totalItems: 27,
497
+ },
498
+ };
499
+ let tree;
500
+
501
+ renderer.act(() => {
502
+ tree = renderer.create(
503
+ React.createElement(DefaultTable, {
504
+ columns: [{key: 'name', label: 'Nome'}],
505
+ data: [],
506
+ showTotalItemsInCompactToolbar: true,
507
+ showTotalItemsInFooter: true,
508
+ storeName: 'orders',
509
+ }),
510
+ );
511
+ });
512
+
513
+ const iconNames = tree.root.findAllByType('icon').map(node => node.props.name);
514
+
515
+ expect(tree.root.findAllByType('DefaultSearch')).toHaveLength(0);
516
+ expect(iconNames).toEqual(expect.arrayContaining(['search', 'filter', 'grid', 'columns']));
517
+ expect(
518
+ tree.root.findAllByType('Text').filter(node => node.props.children === '27 registros'),
519
+ ).toHaveLength(1);
520
+ });
521
+
522
+ it('opens the filters modal with filterable columns only', () => {
523
+ mockWindowDimensions = {width: 375, height: 667};
524
+ mockStores.orders = {
525
+ actions: {},
526
+ getters: {
527
+ columns: [
528
+ {key: 'name', label: 'Nome'},
529
+ {key: 'ignored', label: 'Ignorado', filter: false},
530
+ {
531
+ key: 'status',
532
+ label: 'Status',
533
+ list: [{value: 'pos', label: 'POS'}],
534
+ },
535
+ ],
536
+ filters: {},
537
+ },
538
+ };
539
+ let tree;
540
+
541
+ renderer.act(() => {
542
+ tree = renderer.create(
543
+ React.createElement(DefaultTable, {
544
+ data: [],
545
+ storeName: 'orders',
546
+ }),
547
+ );
548
+ });
549
+
550
+ expect(tree.root.findAllByType('DefaultColumnFilter')).toHaveLength(0);
551
+ const filterButton = tree.root
552
+ .findAllByType('TouchableOpacity')
553
+ .find(node => node.findAllByType('icon').some(icon => icon.props.name === 'filter'));
554
+
555
+ renderer.act(() => filterButton.props.onPress());
556
+
557
+ expect(tree.root.findAllByType('DefaultColumnFilter')).toHaveLength(2);
558
+ expect(mockCapturedColumnFilterProps.map(props => props.column.key)).toEqual(['name', 'status']);
559
+ });
560
+
561
+ it('persists filters and sort under default-table[store][route]', () => {
562
+ const setFilters = jest.fn();
563
+ mockWindowDimensions = {width: 1024, height: 800};
564
+ mockStores.orders = {
565
+ actions: {
566
+ setFilters,
567
+ },
568
+ getters: {
569
+ columns: [
570
+ {key: 'status', label: 'Status', sortable: true},
571
+ {key: 'price', label: 'Preco'},
572
+ ],
573
+ filters: {},
574
+ },
575
+ };
576
+
577
+ let tree;
578
+
579
+ renderer.act(() => {
580
+ tree = renderer.create(
581
+ React.createElement(DefaultTable, {
582
+ storeName: 'orders',
583
+ }),
584
+ );
585
+ });
586
+
587
+ const filterButton = tree.root
588
+ .findAllByType('TouchableOpacity')
589
+ .find(node => node.findAllByType('icon').some(icon => icon.props.name === 'filter'));
590
+
591
+ renderer.act(() => filterButton.props.onPress());
592
+
593
+ const statusFilterProps = mockCapturedColumnFilterProps
594
+ .find(props => props.column.key === 'status');
595
+
596
+ renderer.act(() => {
597
+ statusFilterProps.onChange('status', 'paid');
598
+ });
599
+
600
+ const statusHeader = tree.root
601
+ .findAllByType('TouchableOpacity')
602
+ .find(node => node.findAllByType('Text').some(text => text.props.children === 'Status'));
603
+
604
+ renderer.act(() => {
605
+ statusHeader.props.onPress();
606
+ });
607
+
608
+ expect(setFilters).toHaveBeenCalledWith({status: 'paid'});
609
+ expect(
610
+ JSON.parse(
611
+ global.localStorage.getItem(DEFAULT_TABLE_PREFERENCES_STORAGE_KEY),
612
+ ),
613
+ ).toEqual({
614
+ orders: {
615
+ OrderHistoryPage: {
616
+ filters: {
617
+ status: 'paid',
618
+ },
619
+ sort: {
620
+ direction: 'asc',
621
+ field: 'status',
622
+ },
623
+ },
624
+ },
625
+ });
626
+ });
627
+
628
+ it('hydrates the saved sort using a stable preference key on auto tables', async () => {
629
+ const getItems = jest.fn(() => Promise.resolve([]));
630
+ reactNavigation.useIsFocused.mockImplementation(() => true);
631
+ global.localStorage.setItem(
632
+ DEFAULT_TABLE_PREFERENCES_STORAGE_KEY,
633
+ JSON.stringify({
634
+ orders: {
635
+ 'order-history-page': {
636
+ sort: {
637
+ direction: 'desc',
638
+ field: 'status',
639
+ },
640
+ },
641
+ },
642
+ }),
643
+ );
644
+ mockStores.orders = {
645
+ actions: {
646
+ getItems,
647
+ },
648
+ getters: {
649
+ columns: [
650
+ {key: 'status', label: 'Status', sortable: true},
651
+ {key: 'price', label: 'Preco'},
652
+ ],
653
+ filters: {},
654
+ items: [],
655
+ },
656
+ };
657
+
658
+ await renderer.act(async () => {
659
+ renderer.create(
660
+ React.createElement(DefaultTable, {
661
+ storeName: 'orders',
662
+ visibleColumnsPreferenceKey: 'order-history-page',
663
+ }),
664
+ );
665
+ await Promise.resolve();
666
+ });
667
+
668
+ expect(getItems).toHaveBeenCalledWith(
669
+ expect.objectContaining({
670
+ 'order[status]': 'desc',
671
+ itemsPerPage: 50,
672
+ page: 1,
673
+ }),
674
+ );
675
+ });
676
+
677
+ it('hydrates and persists controlled filters under default-table[store][route]', () => {
678
+ const onFilterChange = jest.fn();
679
+ const setFilters = jest.fn();
680
+ global.location = {pathname: '/product-showcases-page'};
681
+ global.localStorage.setItem(
682
+ DEFAULT_TABLE_PREFERENCES_STORAGE_KEY,
683
+ JSON.stringify({
684
+ product_showcase_items: {
685
+ 'product-showcases-page': {
686
+ filters: {
687
+ integration: 'pos',
688
+ },
689
+ },
690
+ },
691
+ }),
692
+ );
693
+ mockStores.product_showcase_items = {
694
+ actions: {
695
+ setFilters,
696
+ },
697
+ getters: {
698
+ columns: [
699
+ {key: 'integration', label: 'Integration', externalFilter: true},
700
+ {key: 'price', label: 'Price'},
701
+ ],
702
+ filters: {},
703
+ },
704
+ };
705
+
706
+ let tree;
707
+
708
+ renderer.act(() => {
709
+ tree = renderer.create(
710
+ React.createElement(DefaultTable, {
711
+ filters: {},
712
+ onFilterChange,
713
+ storeName: 'product_showcase_items',
714
+ }),
715
+ );
716
+ });
717
+
718
+ expect(onFilterChange).toHaveBeenCalledWith({integration: 'pos'});
719
+
720
+ renderer.act(() => {
721
+ tree.update(
722
+ React.createElement(DefaultTable, {
723
+ filters: {integration: 'ifood'},
724
+ onFilterChange,
725
+ storeName: 'product_showcase_items',
726
+ }),
727
+ );
728
+ });
729
+
730
+ expect(
731
+ JSON.parse(
732
+ global.localStorage.getItem(DEFAULT_TABLE_PREFERENCES_STORAGE_KEY),
733
+ ),
734
+ ).toEqual({
735
+ product_showcase_items: {
736
+ 'product-showcases-page': {
737
+ filters: {
738
+ integration: 'ifood',
739
+ },
740
+ },
741
+ },
742
+ });
743
+
744
+ delete global.location;
745
+ });
746
+
747
+ it('preserves the compact toolbar total when the footer total is disabled', () => {
748
+ mockWindowDimensions = {width: 375, height: 667};
749
+ mockStores.orders = {
750
+ actions: {},
751
+ getters: {
752
+ totalItems: 27,
753
+ },
754
+ };
755
+ let tree;
756
+
757
+ renderer.act(() => {
758
+ tree = renderer.create(
759
+ React.createElement(DefaultTable, {
760
+ columns: [{key: 'name', label: 'Nome'}],
761
+ data: [],
762
+ showTotalItemsInCompactToolbar: true,
763
+ showTotalItemsInFooter: false,
764
+ storeName: 'orders',
765
+ }),
766
+ );
767
+ });
768
+
769
+ expect(tree.root.findAllByType('Text').map(node => node.props.children)).toContain('27 registros');
770
+ });
771
+
772
+ it('forces cards when entering compact mode and still toggles between cards and list', () => {
773
+ const props = {
774
+ columns: [{key: 'name', label: 'Nome'}],
775
+ data: [{id: 1, name: 'Pedido 1'}],
776
+ showColumnFiltersButton: false,
777
+ };
778
+ let tree;
779
+
780
+ mockWindowDimensions = {width: 1024, height: 800};
781
+
782
+ renderer.act(() => {
783
+ tree = renderer.create(
784
+ React.createElement(DefaultTable, props),
785
+ );
786
+ });
787
+
788
+ expect(tree.root.findAllByType('ScrollView')).toHaveLength(1);
789
+
790
+ mockWindowDimensions = {width: 480, height: 800};
791
+
792
+ renderer.act(() => {
793
+ tree.update(React.createElement(DefaultTable, props));
794
+ });
795
+
796
+ expect(tree.root.findAllByType('ScrollView')).toHaveLength(0);
797
+ expect(tree.root.findByProps({name: 'grid'})).toBeTruthy();
798
+
799
+ renderer.act(() => {
800
+ tree.root.findByProps({name: 'grid'}).parent.props.onPress();
801
+ });
802
+
803
+ expect(tree.root.findAllByType('ScrollView')).toHaveLength(1);
804
+ expect(tree.root.findByProps({name: 'list'})).toBeTruthy();
805
+
806
+ renderer.act(() => {
807
+ tree.root.findByProps({name: 'list'}).parent.props.onPress();
808
+ });
809
+
810
+ expect(tree.root.findAllByType('ScrollView')).toHaveLength(0);
811
+ expect(tree.root.findByProps({name: 'grid'})).toBeTruthy();
812
+ });
813
+
814
+ it('applies a custom rowStyle to rendered table rows', () => {
815
+ let tree;
816
+ const rowStyle = jest.fn(() => ({
817
+ borderLeftColor: '#DC2626',
818
+ borderLeftWidth: 4,
819
+ }));
820
+
821
+ mockWindowDimensions = {width: 1024, height: 800};
822
+
823
+ renderer.act(() => {
824
+ tree = renderer.create(
825
+ React.createElement(DefaultTable, {
826
+ columns: [{key: 'name', label: 'Nome'}],
827
+ data: [{id: 1, name: 'Pedido 1'}],
828
+ onRowPress: () => {},
829
+ rowStyle,
830
+ showColumnFiltersButton: false,
831
+ }),
832
+ );
833
+ });
834
+
835
+ const row = tree.root
836
+ .findAllByType('TouchableOpacity')
837
+ .find(node => node.props?.activeOpacity === 0.84);
838
+
839
+ expect(rowStyle).toHaveBeenCalledWith(
840
+ expect.objectContaining({id: 1, name: 'Pedido 1'}),
841
+ 0,
842
+ );
843
+ expect(Array.isArray(row.props.style)).toBe(true);
844
+ expect(
845
+ row.props.style.some(
846
+ style => style && style.borderLeftWidth === 4 && style.borderLeftColor === '#DC2626',
847
+ ),
848
+ ).toBe(true);
849
+ });
850
+
851
+ it('blocks row navigation while an inline cell is editing or saving', async () => {
852
+ let resolveSave;
853
+ const onRowPress = jest.fn();
854
+ const save = jest.fn(() => new Promise(resolve => {
855
+ resolveSave = resolve;
856
+ }));
857
+ let tree;
858
+
859
+ mockWindowDimensions = {width: 1024, height: 800};
860
+
861
+ renderer.act(() => {
862
+ tree = renderer.create(
863
+ React.createElement(DefaultTable, {
864
+ actions: {save},
865
+ columns: [{key: 'name', label: 'Nome', editable: true}],
866
+ data: [{id: 1, name: 'Cliente'}],
867
+ onRowPress,
868
+ showColumnFiltersButton: false,
869
+ showRowActions: false,
870
+ }),
871
+ );
872
+ });
873
+
874
+ const findRow = () => tree.root
875
+ .findAllByType('TouchableOpacity')
876
+ .find(node => node.props?.activeOpacity === 0.84);
877
+ const latestInput = () => mockCapturedDefaultInputProps[mockCapturedDefaultInputProps.length - 1];
878
+
879
+ renderer.act(() => {
880
+ findRow().props.onPress();
881
+ });
882
+ expect(onRowPress).toHaveBeenCalledTimes(1);
883
+ onRowPress.mockClear();
884
+
885
+ renderer.act(() => {
886
+ latestInput().onStartEditing();
887
+ });
888
+ renderer.act(() => {
889
+ findRow().props.onPress();
890
+ });
891
+ expect(onRowPress).not.toHaveBeenCalled();
892
+
893
+ let savePromise;
894
+ renderer.act(() => {
895
+ savePromise = latestInput().onSave('Cliente alterado');
896
+ });
897
+ renderer.act(() => {
898
+ latestInput().onCancelEditing();
899
+ });
900
+ renderer.act(() => {
901
+ findRow().props.onPress();
902
+ });
903
+ expect(onRowPress).not.toHaveBeenCalled();
904
+
905
+ await renderer.act(async () => {
906
+ resolveSave({id: 1, name: 'Cliente alterado'});
907
+ await savePromise;
908
+ });
909
+
910
+ renderer.act(() => {
911
+ findRow().props.onPress();
912
+ });
913
+ expect(onRowPress).toHaveBeenCalledTimes(1);
914
+ });
915
+
916
+ it('protects custom card openRow while a card field is editing', () => {
917
+ const onRowPress = jest.fn();
918
+ let tree;
919
+
920
+ renderer.act(() => {
921
+ tree = renderer.create(
922
+ React.createElement(DefaultTable, {
923
+ columns: [{key: 'name', label: 'Nome', editable: true}],
924
+ data: [{id: 1, name: 'Cliente'}],
925
+ onRowPress,
926
+ renderCard: ({openRow, renderField}) => React.createElement(
927
+ 'CustomCard',
928
+ {openRow},
929
+ renderField('name'),
930
+ ),
931
+ showColumnFiltersButton: false,
932
+ showRowActions: false,
933
+ }),
934
+ );
935
+ });
936
+
937
+ const latestInput = () => mockCapturedDefaultInputProps[mockCapturedDefaultInputProps.length - 1];
938
+
939
+ renderer.act(() => {
940
+ latestInput().onStartEditing();
941
+ });
942
+ renderer.act(() => {
943
+ tree.root.findByType('CustomCard').props.openRow();
944
+ });
945
+ expect(onRowPress).not.toHaveBeenCalled();
946
+
947
+ renderer.act(() => {
948
+ latestInput().onCancelEditing();
949
+ });
950
+ renderer.act(() => {
951
+ tree.root.findByType('CustomCard').props.openRow();
952
+ });
953
+ expect(onRowPress).toHaveBeenCalledTimes(1);
954
+ });
955
+
956
+ it('renders a custom footer component when provided', () => {
957
+ let tree;
958
+ mockStores.orders = {
959
+ actions: {},
960
+ getters: {
961
+ totalItems: 7,
962
+ },
963
+ };
964
+
965
+ const FooterComponent = props =>
966
+ React.createElement('CustomFooter', {
967
+ resolvedTotalItemsText: props.resolvedTotalItemsText,
968
+ });
969
+
970
+ mockWindowDimensions = {width: 1024, height: 800};
971
+
972
+ renderer.act(() => {
973
+ tree = renderer.create(
974
+ React.createElement(DefaultTable, {
975
+ columns: [{key: 'name', label: 'Nome'}],
976
+ data: [{id: 1, name: 'Pedido 1'}],
977
+ footerComponent: FooterComponent,
978
+ showColumnFiltersButton: false,
979
+ storeName: 'orders',
980
+ }),
981
+ );
982
+ });
983
+
984
+ const footer = tree.root.findByType('CustomFooter');
985
+
986
+ expect(footer.props.resolvedTotalItemsText).toBe('7 registros');
987
+ });
988
+
989
+ it('keeps infinite scroll loading transparent over existing rows', () => {
990
+ let tree;
991
+ const getItems = jest.fn(() => Promise.resolve({member: []}));
992
+ mockWindowDimensions = {width: 1024, height: 800};
993
+ mockStores.invoices = {
994
+ actions: {
995
+ getItems,
996
+ },
997
+ getters: {
998
+ columns: [{key: 'status', label: 'Situação'}],
999
+ items: [{id: 332, status: 'Pago'}],
1000
+ totalItems: 1394,
1001
+ },
1002
+ };
1003
+
1004
+ renderer.act(() => {
1005
+ tree = renderer.create(
1006
+ React.createElement(DefaultTable, {
1007
+ showColumnFiltersButton: false,
1008
+ showRowActions: false,
1009
+ storeName: 'invoices',
1010
+ }),
1011
+ );
1012
+ });
1013
+
1014
+ expect(tree.root.findAllByType('StateStore')).toHaveLength(0);
1015
+ const flatList = tree.root.findByType('FlatList');
1016
+ expect(flatList.props.onEndReachedThreshold).toBeGreaterThanOrEqual(0.75);
1017
+ expect(tree.root.findByType('ScrollView').props.contentContainerStyle).toEqual(
1018
+ expect.objectContaining({minWidth: '100%'}),
1019
+ );
1020
+
1021
+ renderer.act(() => {
1022
+ flatList.props.onScroll({
1023
+ nativeEvent: {
1024
+ contentOffset: {y: 650},
1025
+ contentSize: {height: 1200},
1026
+ layoutMeasurement: {height: 400},
1027
+ },
1028
+ });
1029
+ });
1030
+
1031
+ expect(getItems).toHaveBeenCalledWith(expect.objectContaining({append: true}));
1032
+ });
1033
+
1034
+ it('uses the configured summary and labels before the store summary', () => {
1035
+ let tree;
1036
+ mockWindowDimensions = {width: 1024, height: 800};
1037
+ mockStores.invoice = {
1038
+ actions: {},
1039
+ getters: {
1040
+ summary: {
1041
+ financial: {
1042
+ paidAmount: 0,
1043
+ },
1044
+ },
1045
+ totalItems: 1,
1046
+ },
1047
+ };
1048
+
1049
+ renderer.act(() => {
1050
+ tree = renderer.create(
1051
+ React.createElement(DefaultTable, {
1052
+ columns: [{key: 'status', label: 'Situação'}],
1053
+ data: [{id: 332, status: 'Pago'}],
1054
+ showColumnFiltersButton: false,
1055
+ showRowActions: false,
1056
+ storeName: 'invoice',
1057
+ summary: {
1058
+ financial: {
1059
+ paidAmount: 80.25,
1060
+ },
1061
+ },
1062
+ summaryLabels: {
1063
+ 'financial.paidAmount': 'Valor pago',
1064
+ },
1065
+ }),
1066
+ );
1067
+ });
1068
+
1069
+ const textValues = tree.root.findAllByType('Text').map(node => node.props.children);
1070
+ expect(textValues).toContain('Valor pago');
1071
+ expect(textValues).toContain('R$ 80,25');
1072
+ });
1073
+
1074
+ it('opens a debug query modal when the store exposes debug.query', () => {
1075
+ mockWindowDimensions = {width: 1024, height: 800};
1076
+ mockStores.orders = {
1077
+ actions: {},
1078
+ getters: {
1079
+ columns: [{key: 'id', label: 'ID'}],
1080
+ debug: {
1081
+ interpolatedQuery: "SELECT * FROM orders WHERE provider_id = '2'",
1082
+ query: 'SELECT * FROM orders WHERE provider_id = 2',
1083
+ },
1084
+ filters: {
1085
+ provider: '/people/2',
1086
+ },
1087
+ items: [],
1088
+ },
1089
+ };
1090
+
1091
+ let tree;
1092
+
1093
+ renderer.act(() => {
1094
+ tree = renderer.create(
1095
+ React.createElement(DefaultTable, {
1096
+ requestParams: {itemsPerPage: 50},
1097
+ storeName: 'orders',
1098
+ }),
1099
+ );
1100
+ });
1101
+
1102
+ const debugButton = tree.root
1103
+ .findAllByType('TouchableOpacity')
1104
+ .find(node => node.props.accessibilityLabel === 'Debug query');
1105
+
1106
+ expect(debugButton).toBeTruthy();
1107
+
1108
+ renderer.act(() => debugButton.props.onPress());
1109
+
1110
+ const textValues = tree.root.findAllByType('Text').map(node => node.props.children);
1111
+ expect(textValues).toContain('Debug query');
1112
+ expect(textValues).toContain('SELECT * FROM orders WHERE provider_id = 2');
1113
+ expect(textValues).toContain('Query preenchida');
1114
+ expect(textValues).toContain("SELECT * FROM orders WHERE provider_id = '2'");
1115
+ expect(textValues.some(value => String(value).includes('"provider": "/people/2"'))).toBe(true);
1116
+ });
1117
+
1118
+ it('uses store actions when no explicit table actions are passed', async () => {
1119
+ const save = jest.fn(() => Promise.resolve({
1120
+ id: 1,
1121
+ jobTitle: '/categories/2',
1122
+ }));
1123
+
1124
+ mockStores.employee_profiles = {
1125
+ actions: {
1126
+ save,
1127
+ },
1128
+ getters: {
1129
+ columns: [],
1130
+ },
1131
+ };
1132
+
1133
+ await renderer.act(async () => {
1134
+ renderer.create(
1135
+ React.createElement(DefaultTable, {
1136
+ columns: [
1137
+ {
1138
+ key: 'jobTitle',
1139
+ label: 'Cargo',
1140
+ editable: true,
1141
+ list: 'categories/getItems',
1142
+ formatList: item => ({
1143
+ value: item.id,
1144
+ label: item.name,
1145
+ }),
1146
+ saveFormat: value => `/categories/${parseInt(value.value || value, 10)}`,
1147
+ },
1148
+ ],
1149
+ data: [{
1150
+ id: 1,
1151
+ jobTitle: '/categories/1',
1152
+ }],
1153
+ showColumnFiltersButton: false,
1154
+ showRowActions: false,
1155
+ storeName: 'employee_profiles',
1156
+ }),
1157
+ );
1158
+ });
1159
+
1160
+ expect(mockCapturedDefaultInputProps.length).toBeGreaterThan(0);
1161
+
1162
+ await renderer.act(async () => {
1163
+ await mockCapturedDefaultInputProps[0].onSave({
1164
+ value: '2',
1165
+ label: 'Departamento',
1166
+ object: {id: 2, name: 'Departamento'},
1167
+ });
1168
+ });
1169
+
1170
+ expect(save).toHaveBeenCalledWith({
1171
+ id: '1',
1172
+ jobTitle: '/categories/2',
1173
+ [STORE_ACTION_META_KEY]: {
1174
+ savedItemPatch: {
1175
+ jobTitle: {
1176
+ id: 2,
1177
+ name: 'Departamento',
1178
+ },
1179
+ },
1180
+ },
1181
+ });
1182
+ });
1183
+ });