@controleonline/ui-default 1.0.264 → 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 (46) hide show
  1. package/AGENTS.md +3 -2
  2. package/package.json +21 -18
  3. package/src/react/components/errors/DefaultErrors.js +64 -82
  4. package/src/react/components/filters/CompactFilterSelector.js +58 -23
  5. package/src/react/components/filters/CompactFilterSelector.styles.js +17 -5
  6. package/src/react/components/filters/DefaultColumnFilter.js +177 -45
  7. package/src/react/components/filters/DefaultExternalFilters.js +201 -143
  8. package/src/react/components/filters/DefaultSearch.js +27 -25
  9. package/src/react/components/form/DefaultForm.js +72 -53
  10. package/src/react/components/form/DefaultForm.styles.js +71 -66
  11. package/src/react/components/inputs/DefaultDateInput.js +150 -0
  12. package/src/react/components/inputs/DefaultInput.js +44 -11
  13. package/src/react/components/inputs/DefaultInput.styles.js +6 -0
  14. package/src/react/components/inputs/DefaultSelect.js +200 -43
  15. package/src/react/components/inputs/defaultInputUtils.js +40 -19
  16. package/src/react/components/table/DefaultColumnMenu.js +108 -0
  17. package/src/react/components/table/DefaultDebug.js +233 -0
  18. package/src/react/components/table/DefaultFiltersModal.js +142 -0
  19. package/src/react/components/table/DefaultModalButton.js +18 -0
  20. package/src/react/components/table/DefaultSearchModal.js +49 -0
  21. package/src/react/components/table/DefaultTable.js +350 -2025
  22. package/src/react/components/table/DefaultTable.styles.js +217 -97
  23. package/src/react/components/table/DefaultTable.utils.js +502 -0
  24. package/src/react/components/table/DefaultTableBody.js +26 -0
  25. package/src/react/components/table/DefaultTableCards.js +171 -0
  26. package/src/react/components/table/DefaultTableControls.js +208 -0
  27. package/src/react/components/table/DefaultTableEmptyState.js +22 -0
  28. package/src/react/components/table/DefaultTableFooter.js +172 -0
  29. package/src/react/components/table/DefaultTableInput.js +145 -0
  30. package/src/react/components/table/DefaultTableRows.js +340 -0
  31. package/src/react/components/table/DefaultTableSearch.js +100 -0
  32. package/src/react/components/table/DefaultTableToolbar.js +98 -0
  33. package/src/react/components/table/DefaultToolbarAction.js +56 -0
  34. package/src/react/components/table/useDefaultTablePagination.js +370 -0
  35. package/src/react/components/table/useDefaultTableSorting.js +126 -0
  36. package/src/react/components/table/useDefaultTableTheme.js +58 -0
  37. package/src/react/utils/tableVisibleColumnsPreferences.js +239 -211
  38. package/src/store/default/actions.js +51 -13
  39. package/src/store/default/getters.js +10 -8
  40. package/src/store/default/mutation_types.js +4 -2
  41. package/src/store/default/mutations.js +10 -1
  42. package/src/tests/react/components/form/DefaultForm.test.js +81 -0
  43. package/src/tests/react/components/inputs/DefaultSelect.test.js +106 -14
  44. package/src/tests/react/components/table/DefaultTable.test.js +945 -96
  45. package/src/tests/react/store/defaultActions.test.js +59 -0
  46. package/src/tests/react/utils/tableVisibleColumnsPreferences.test.js +247 -168
@@ -1,24 +1,45 @@
1
- const React = require('react');
2
- const renderer = require('react-test-renderer');
3
- const {jest} = require('@jest/globals');
4
-
5
- const {afterEach, beforeEach, describe, expect, it} = global;
1
+ const React = require('react');
2
+ const renderer = require('react-test-renderer');
3
+
4
+ const {afterEach, beforeEach, describe, expect, it} = global;
6
5
 
7
6
  global.IS_REACT_ACT_ENVIRONMENT = true;
8
7
 
9
- let consoleErrorSpy = null;
10
- let mockStores = {};
11
- let mockWindowDimensions = {width: 480, height: 800};
12
- let capturedDefaultInputProps = [];
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
+ };
13
30
 
14
- jest.mock('@store', () => ({
15
- getAllStores: jest.fn(() => ({})),
16
- useStore: jest.fn(name => mockStores[name] || {actions: {}, getters: {}}),
17
- }));
31
+ jest.mock('@store', () => ({
32
+ getAllStores: jest.fn(() => ({})),
33
+ useStore: jest.fn(name => mockStores[name] || {actions: {}, getters: {}}),
34
+ }));
18
35
 
19
- jest.mock('@react-navigation/native', () => ({
20
- useIsFocused: jest.fn(() => false),
21
- }));
36
+ jest.mock('@react-navigation/native', () => ({
37
+ useIsFocused: jest.fn(() => false),
38
+ useRoute: jest.fn(() => ({
39
+ key: 'OrderHistoryPage-key',
40
+ name: 'OrderHistoryPage',
41
+ })),
42
+ }));
22
43
 
23
44
  jest.mock('@controleonline/ui-common/src/react/components/MessageService', () => ({
24
45
  useMessage: jest.fn(() => ({
@@ -65,33 +86,61 @@ jest.mock('react-native', () => {
65
86
  };
66
87
  });
67
88
 
68
- jest.mock('../../../../react/components/filters/DefaultColumnFilter', () => () =>
69
- React.createElement('DefaultColumnFilter'),
70
- );
71
-
72
- jest.mock('../../../../react/components/filters/DefaultSearch', () => () =>
73
- React.createElement('DefaultSearch'),
74
- );
75
-
76
- jest.mock('../../../../react/components/form/DefaultForm', () => () =>
77
- React.createElement('DefaultForm'),
78
- );
79
-
80
- jest.mock('../../../../react/components/inputs/DefaultInput', () => props => {
81
- capturedDefaultInputProps.push(props);
82
- return React.createElement('DefaultInput', props);
83
- });
84
-
85
- jest.mock('@controleonline/ui-layout/src/react/components/StateStore', () => props =>
86
- React.createElement('StateStore', props, props.children),
87
- );
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
+ });
88
115
 
89
- const {
90
- default: DefaultTable,
91
- resolveColumnListLoadParams,
92
- } = require('../../../../react/components/table/DefaultTable');
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
+ };
93
142
 
94
- describe('resolveColumnListLoadParams', () => {
143
+ describe('resolveColumnListLoadParams', () => {
95
144
  it('uses company for category stores and people for financial owner stores', () => {
96
145
  expect(resolveColumnListLoadParams({
97
146
  column: {list: 'categories/getItems'},
@@ -116,19 +165,95 @@ describe('resolveColumnListLoadParams', () => {
116
165
  currentCompanyId: 21,
117
166
  requestParams: {context: 'receive'},
118
167
  })).toEqual({company: 21, context: 'receive'});
119
- });
120
- });
121
-
122
- describe('DefaultTable', () => {
123
- beforeEach(() => {
124
- consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
125
- mockWindowDimensions = {width: 480, height: 800};
126
- capturedDefaultInputProps = [];
127
-
128
- mockStores = {
129
- people: {
130
- getters: {
131
- currentCompany: {
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: {
132
257
  theme: {
133
258
  colors: {},
134
259
  },
@@ -143,18 +268,19 @@ describe('DefaultTable', () => {
143
268
  };
144
269
  });
145
270
 
146
- afterEach(() => {
147
- if (consoleErrorSpy) {
271
+ afterEach(() => {
272
+ delete global.t;
273
+ if (consoleErrorSpy) {
148
274
  consoleErrorSpy.mockRestore();
149
275
  consoleErrorSpy = null;
150
276
  }
151
277
  });
152
278
 
153
- it('keeps the list/card toggle visible on compact layouts even when cards are forced', () => {
154
- let tree;
155
-
156
- renderer.act(() => {
157
- tree = renderer.create(
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(
158
284
  React.createElement(DefaultTable, {
159
285
  columns: [{key: 'name', label: 'Nome'}],
160
286
  data: [],
@@ -164,11 +290,486 @@ describe('DefaultTable', () => {
164
290
  });
165
291
 
166
292
  const iconNames = tree.root.findAllByType('icon').map(node => node.props.name);
167
-
168
- expect(iconNames).toEqual(expect.arrayContaining(['grid', 'columns']));
169
- });
170
-
171
- it('forces cards when entering compact mode and still toggles between cards and list', () => {
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', () => {
172
773
  const props = {
173
774
  columns: [{key: 'name', label: 'Nome'}],
174
775
  data: [{id: 1, name: 'Pedido 1'}],
@@ -210,7 +811,7 @@ describe('DefaultTable', () => {
210
811
  expect(tree.root.findByProps({name: 'grid'})).toBeTruthy();
211
812
  });
212
813
 
213
- it('applies a custom rowStyle to rendered table rows', () => {
814
+ it('applies a custom rowStyle to rendered table rows', () => {
214
815
  let tree;
215
816
  const rowStyle = jest.fn(() => ({
216
817
  borderLeftColor: '#DC2626',
@@ -244,13 +845,124 @@ describe('DefaultTable', () => {
244
845
  row.props.style.some(
245
846
  style => style && style.borderLeftWidth === 4 && style.borderLeftColor === '#DC2626',
246
847
  ),
247
- ).toBe(true);
248
- });
249
-
250
- it('renders a custom footer component when provided', () => {
251
- let tree;
252
-
253
- const FooterComponent = props =>
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 =>
254
966
  React.createElement('CustomFooter', {
255
967
  resolvedTotalItemsText: props.resolvedTotalItemsText,
256
968
  });
@@ -260,21 +972,150 @@ describe('DefaultTable', () => {
260
972
  renderer.act(() => {
261
973
  tree = renderer.create(
262
974
  React.createElement(DefaultTable, {
263
- columns: [{key: 'name', label: 'Nome'}],
264
- data: [{id: 1, name: 'Pedido 1'}],
265
- footerComponent: FooterComponent,
266
- showColumnFiltersButton: false,
267
- totalItemsText: '7 pedidos',
268
- }),
269
- );
975
+ columns: [{key: 'name', label: 'Nome'}],
976
+ data: [{id: 1, name: 'Pedido 1'}],
977
+ footerComponent: FooterComponent,
978
+ showColumnFiltersButton: false,
979
+ storeName: 'orders',
980
+ }),
981
+ );
270
982
  });
271
983
 
272
984
  const footer = tree.root.findByType('CustomFooter');
273
-
274
- expect(footer.props.resolvedTotalItemsText).toBe('7 pedidos');
275
- });
276
-
277
- it('uses store actions when no explicit table actions are passed', async () => {
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 () => {
278
1119
  const save = jest.fn(() => Promise.resolve({
279
1120
  id: 1,
280
1121
  jobTitle: '/categories/2',
@@ -316,19 +1157,27 @@ describe('DefaultTable', () => {
316
1157
  );
317
1158
  });
318
1159
 
319
- expect(capturedDefaultInputProps.length).toBeGreaterThan(0);
320
-
321
- await renderer.act(async () => {
322
- await capturedDefaultInputProps[0].onSave({
1160
+ expect(mockCapturedDefaultInputProps.length).toBeGreaterThan(0);
1161
+
1162
+ await renderer.act(async () => {
1163
+ await mockCapturedDefaultInputProps[0].onSave({
323
1164
  value: '2',
324
1165
  label: 'Departamento',
325
1166
  object: {id: 2, name: 'Departamento'},
326
1167
  });
327
1168
  });
328
1169
 
329
- expect(save).toHaveBeenCalledWith({
330
- id: '1',
331
- jobTitle: '/categories/2',
332
- });
333
- });
334
- });
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
+ });