@arbor-education/design-system.components 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/components/formField/fieldset/Fieldset.d.ts +1 -1
  3. package/dist/components/formField/fieldset/Fieldset.d.ts.map +1 -1
  4. package/dist/components/formField/fieldset/Fieldset.js +1 -1
  5. package/dist/components/formField/fieldset/Fieldset.js.map +1 -1
  6. package/dist/components/formField/inputs/number/NumberInput.d.ts.map +1 -1
  7. package/dist/components/formField/inputs/number/NumberInput.js +21 -12
  8. package/dist/components/formField/inputs/number/NumberInput.js.map +1 -1
  9. package/dist/components/formField/inputs/number/NumberInput.test.js +92 -3
  10. package/dist/components/formField/inputs/number/NumberInput.test.js.map +1 -1
  11. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts.map +1 -1
  12. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js +2 -2
  13. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js.map +1 -1
  14. package/dist/components/pill/Pill.d.ts +2 -1
  15. package/dist/components/pill/Pill.d.ts.map +1 -1
  16. package/dist/components/pill/Pill.js +2 -2
  17. package/dist/components/pill/Pill.js.map +1 -1
  18. package/dist/components/pill/Pill.stories.d.ts +8 -0
  19. package/dist/components/pill/Pill.stories.d.ts.map +1 -1
  20. package/dist/components/pill/Pill.stories.js +8 -0
  21. package/dist/components/pill/Pill.stories.js.map +1 -1
  22. package/dist/components/pill/Pill.test.js +9 -0
  23. package/dist/components/pill/Pill.test.js.map +1 -1
  24. package/dist/components/table/DSDefaultColDef.d.ts.map +1 -1
  25. package/dist/components/table/DSDefaultColDef.js +32 -2
  26. package/dist/components/table/DSDefaultColDef.js.map +1 -1
  27. package/dist/components/table/Table.d.ts.map +1 -1
  28. package/dist/components/table/Table.js +5 -1
  29. package/dist/components/table/Table.js.map +1 -1
  30. package/dist/components/table/Table.stories.d.ts.map +1 -1
  31. package/dist/components/table/Table.stories.js +17 -16
  32. package/dist/components/table/Table.stories.js.map +1 -1
  33. package/dist/components/table/Table.test.js +15 -27
  34. package/dist/components/table/Table.test.js.map +1 -1
  35. package/dist/components/table/columnFilters/BooleanFilter/BooleanFilter.d.ts +13 -0
  36. package/dist/components/table/columnFilters/BooleanFilter/BooleanFilter.d.ts.map +1 -0
  37. package/dist/components/table/columnFilters/BooleanFilter/BooleanFilter.js +68 -0
  38. package/dist/components/table/columnFilters/BooleanFilter/BooleanFilter.js.map +1 -0
  39. package/dist/components/table/columnFilters/BooleanFilter/BooleanFilter.test.d.ts +2 -0
  40. package/dist/components/table/columnFilters/BooleanFilter/BooleanFilter.test.d.ts.map +1 -0
  41. package/dist/components/table/columnFilters/BooleanFilter/BooleanFilter.test.js +87 -0
  42. package/dist/components/table/columnFilters/BooleanFilter/BooleanFilter.test.js.map +1 -0
  43. package/dist/components/table/columnFilters/TimeFilter/TimeFilter.d.ts +14 -0
  44. package/dist/components/table/columnFilters/TimeFilter/TimeFilter.d.ts.map +1 -0
  45. package/dist/components/table/columnFilters/TimeFilter/TimeFilter.js +73 -0
  46. package/dist/components/table/columnFilters/TimeFilter/TimeFilter.js.map +1 -0
  47. package/dist/components/table/columnFilters/TimeFilter/TimeFilter.test.d.ts +2 -0
  48. package/dist/components/table/columnFilters/TimeFilter/TimeFilter.test.d.ts.map +1 -0
  49. package/dist/components/table/columnFilters/TimeFilter/TimeFilter.test.js +102 -0
  50. package/dist/components/table/columnFilters/TimeFilter/TimeFilter.test.js.map +1 -0
  51. package/dist/components/table/columnFilters/TimeFilter/utils/compareTimeByFilterType.d.ts +8 -0
  52. package/dist/components/table/columnFilters/TimeFilter/utils/compareTimeByFilterType.d.ts.map +1 -0
  53. package/dist/components/table/columnFilters/TimeFilter/utils/compareTimeByFilterType.js +21 -0
  54. package/dist/components/table/columnFilters/TimeFilter/utils/compareTimeByFilterType.js.map +1 -0
  55. package/dist/components/table/columnFilters/TimeFilter/utils/compareTimeByFilterType.test.d.ts +2 -0
  56. package/dist/components/table/columnFilters/TimeFilter/utils/compareTimeByFilterType.test.d.ts.map +1 -0
  57. package/dist/components/table/columnFilters/TimeFilter/utils/compareTimeByFilterType.test.js +129 -0
  58. package/dist/components/table/columnFilters/TimeFilter/utils/compareTimeByFilterType.test.js.map +1 -0
  59. package/dist/components/table/columnFilters/TimeFilter/utils/transformTimeStringToDate.d.ts +2 -0
  60. package/dist/components/table/columnFilters/TimeFilter/utils/transformTimeStringToDate.d.ts.map +1 -0
  61. package/dist/components/table/columnFilters/TimeFilter/utils/transformTimeStringToDate.js +18 -0
  62. package/dist/components/table/columnFilters/TimeFilter/utils/transformTimeStringToDate.js.map +1 -0
  63. package/dist/components/table/columnFilters/TimeFilter/utils/transformTimeStringToDate.test.d.ts +2 -0
  64. package/dist/components/table/columnFilters/TimeFilter/utils/transformTimeStringToDate.test.d.ts.map +1 -0
  65. package/dist/components/table/columnFilters/TimeFilter/utils/transformTimeStringToDate.test.js +100 -0
  66. package/dist/components/table/columnFilters/TimeFilter/utils/transformTimeStringToDate.test.js.map +1 -0
  67. package/dist/components/table/columnFilters/filterResetButton/FilterResetButton.d.ts +7 -0
  68. package/dist/components/table/columnFilters/filterResetButton/FilterResetButton.d.ts.map +1 -0
  69. package/dist/components/table/columnFilters/filterResetButton/FilterResetButton.js +7 -0
  70. package/dist/components/table/columnFilters/filterResetButton/FilterResetButton.js.map +1 -0
  71. package/dist/index.css +70 -1
  72. package/dist/index.css.map +1 -1
  73. package/package.json +2 -1
  74. package/src/components/formField/fieldset/Fieldset.tsx +2 -2
  75. package/src/components/formField/inputs/number/NumberInput.test.tsx +113 -3
  76. package/src/components/formField/inputs/number/NumberInput.tsx +26 -15
  77. package/src/components/formField/inputs/selectDropdown/SelectDropdown.tsx +14 -2
  78. package/src/components/pill/Pill.stories.tsx +9 -0
  79. package/src/components/pill/Pill.test.tsx +10 -0
  80. package/src/components/pill/Pill.tsx +3 -2
  81. package/src/components/table/DSDefaultColDef.ts +34 -4
  82. package/src/components/table/Table.stories.tsx +20 -16
  83. package/src/components/table/Table.test.tsx +12 -28
  84. package/src/components/table/Table.tsx +5 -0
  85. package/src/components/table/columnFilters/BooleanFilter/BooleanFilter.test.tsx +102 -0
  86. package/src/components/table/columnFilters/BooleanFilter/BooleanFilter.tsx +104 -0
  87. package/src/components/table/columnFilters/TimeFilter/TimeFilter.test.tsx +131 -0
  88. package/src/components/table/columnFilters/TimeFilter/TimeFilter.tsx +122 -0
  89. package/src/components/table/columnFilters/TimeFilter/utils/compareTimeByFilterType.test.ts +183 -0
  90. package/src/components/table/columnFilters/TimeFilter/utils/compareTimeByFilterType.ts +30 -0
  91. package/src/components/table/columnFilters/TimeFilter/utils/transformTimeStringToDate.test.ts +133 -0
  92. package/src/components/table/columnFilters/TimeFilter/utils/transformTimeStringToDate.ts +21 -0
  93. package/src/components/table/columnFilters/columnFilters.scss +71 -0
  94. package/src/components/table/columnFilters/filterResetButton/FilterResetButton.tsx +21 -0
  95. package/src/components/table/table.scss +1 -1
  96. package/src/index.scss +1 -0
@@ -1,25 +1,55 @@
1
1
  import type { CellClassParams, CellFocusedParams, ColDef, SuppressMouseEventHandlingParams, ValueFormatterFunc } from 'ag-grid-community';
2
2
  import { cellColorStyles } from './cellColorStyles';
3
3
 
4
+ // checks if the value is wrapped in a object with a value property
5
+ const isWrappedValue = (value: unknown): value is { value: unknown } => {
6
+ return value != null && typeof value === 'object' && 'value' in value;
7
+ };
8
+
4
9
  export const defaultValueFormatter: ValueFormatterFunc = (params) => {
5
10
  const { value } = params;
6
- if (value != null && typeof value === 'object' && 'value' in value) {
11
+ if (isWrappedValue(value)) {
7
12
  return value.value;
8
13
  }
9
14
 
10
15
  return value;
11
16
  };
12
17
 
13
- export const shouldSuppressFocus = (params: SuppressMouseEventHandlingParams | CellClassParams | CellFocusedParams) => typeof params.column === 'object' && params.column?.getColDef().cellRendererParams.supressCellFocusAndFocusFirstElement;
18
+ export const shouldSuppressFocus = (params: SuppressMouseEventHandlingParams | CellClassParams | CellFocusedParams) => {
19
+ if (typeof params.column !== 'object') {
20
+ return false;
21
+ }
22
+ return params.column?.getColDef().cellRendererParams.supressCellFocusAndFocusFirstElement ?? false;
23
+ };
14
24
 
15
25
  export const DSDefaultColDef: ColDef = {
16
26
  cellStyle: cellColorStyles,
17
27
  valueGetter: (params) => {
18
28
  const { data, colDef: { field } } = params;
19
-
20
- return data[field!];
29
+ if (data == null || field == null) {
30
+ return undefined;
31
+ }
32
+ return data[field];
21
33
  },
22
34
  valueFormatter: defaultValueFormatter,
35
+ filterValueGetter: (params) => {
36
+ const { data, colDef: { field } } = params;
37
+ if (data == null || field == null) {
38
+ return undefined;
39
+ }
40
+ const cellValue = data[field];
41
+ if (isWrappedValue(cellValue)) {
42
+ return cellValue.value;
43
+ }
44
+ return cellValue;
45
+ },
46
+ getQuickFilterText: (params) => {
47
+ const { value } = params;
48
+ if (isWrappedValue(value)) {
49
+ return value.value;
50
+ }
51
+ return value;
52
+ },
23
53
  cellEditorParams: {
24
54
  useFormatter: true,
25
55
  },
@@ -14,6 +14,8 @@ import { ModalManager } from 'Components/modal/modalManager/ModalManager';
14
14
  import { Modal } from 'Components/modal/Modal';
15
15
  import { ModalUtils } from 'Utils/ModalUtils';
16
16
  import type { SemanticColorKey } from './cellColorStyles';
17
+ import { BooleanFilter, doesBooleanFilterPass } from './columnFilters/BooleanFilter/BooleanFilter';
18
+ import { doesTimeFilterPass, TimeFilter } from './columnFilters/TimeFilter/TimeFilter';
17
19
 
18
20
  type TableProps = ComponentProps<typeof Table>;
19
21
 
@@ -44,14 +46,17 @@ interface RowData {
44
46
  email: { value: string } & CellColors;
45
47
  role: { value: string } & CellColors;
46
48
  status: { value: string } & CellColors;
49
+ active: boolean;
50
+ time: string;
51
+ dateOfBirth: Date;
47
52
  }
48
53
 
49
54
  const sampleData: RowData[] = [
50
- { id: 1, name: { value: 'Alice Johnson' }, email: { value: 'alice.johnson@example.com' }, role: { value: 'Developer' }, status: { value: 'Active' } },
51
- { id: 2, name: { value: 'Bob Smith' }, email: { value: 'bob.smith@example.com' }, role: { value: 'Designer' }, status: { value: 'Active' } },
52
- { id: 3, name: { value: 'Charlie Brown' }, email: { value: 'charlie.brown@example.com' }, role: { value: 'Manager' }, status: { value: 'Inactive' } },
53
- { id: 4, name: { value: 'Diana Prince' }, email: { value: 'diana.prince@example.com' }, role: { value: 'Developer' }, status: { value: 'Active' } },
54
- { id: 5, name: { value: 'Ethan Hunt' }, email: { value: 'ethan.hunt@example.com' }, role: { value: 'Analyst' }, status: { value: 'Active' } },
55
+ { id: 1, name: { value: 'Alice Johnson' }, email: { value: 'alice.johnson@example.com' }, role: { value: 'Developer' }, status: { value: 'Active' }, active: true, time: '2026-01-28 12:00:00', dateOfBirth: new Date(1990, 0, 1) },
56
+ { id: 2, name: { value: 'Bob Smith' }, email: { value: 'bob.smith@example.com' }, role: { value: 'Designer' }, status: { value: 'Active' }, active: false, time: '2026-01-28 13:00:00', dateOfBirth: new Date(1991, 1, 2) },
57
+ { id: 3, name: { value: 'Charlie Brown' }, email: { value: 'charlie.brown@example.com' }, role: { value: 'Manager' }, status: { value: 'Inactive' }, active: true, time: '2026-01-28 14:00:00', dateOfBirth: new Date(1992, 2, 3) },
58
+ { id: 4, name: { value: 'Diana Prince' }, email: { value: 'diana.prince@example.com' }, role: { value: 'Developer' }, status: { value: 'Active' }, active: false, time: '2026-01-28 15:00:00', dateOfBirth: new Date(1993, 3, 4) },
59
+ { id: 5, name: { value: 'Ethan Hunt' }, email: { value: 'ethan.hunt@example.com' }, role: { value: 'Analyst' }, status: { value: 'Active' }, active: false, time: '2026-01-28 16:00:00', dateOfBirth: new Date(1994, 4, 5) },
55
60
  ];
56
61
 
57
62
  const defaultColDef: ColDef = {
@@ -60,26 +65,25 @@ const defaultColDef: ColDef = {
60
65
  resizable: true,
61
66
  minWidth: 100,
62
67
  flex: 1,
68
+ suppressHeaderFilterButton: true,
63
69
  };
64
70
 
65
71
  const sampleColumnDefs: (ColDef | ColGroupDef)[] = [
66
72
  {
67
73
  headerName: 'Details',
68
- children: [{
69
- field: 'name',
70
- headerTooltip: 'The name of the user',
71
- }, {
72
- field: 'email',
73
- headerTooltip: 'The email of the user',
74
- }, {
75
- field: 'role',
76
- headerTooltip: 'The role of the user',
77
- }],
74
+ children: [
75
+ { field: 'name', headerTooltip: 'The name of the user', filter: 'agSetColumnFilter' },
76
+ { field: 'email', headerTooltip: 'The email of the user', filter: 'agSetColumnFilter' },
77
+ { field: 'role', headerTooltip: 'The role of the user', filter: 'agSetColumnFilter' },
78
+ { field: 'dateOfBirth', filter: 'agDateColumnFilter', filterParams: { buttons: ['clear', 'apply'] }, headerTooltip: 'The date of birth of the user', valueFormatter: params => params.value instanceof Date ? params.value.toLocaleDateString() : params.value },
79
+ ],
78
80
  // Value formatters from the defaultColDef are not respected for non-string value
79
81
  // so until AG Grid fixes it we have to do it explicitly on each colDef
80
82
  valueFormatter: Table.DefaultValueFormatter,
81
83
  },
82
84
  { field: 'status', valueFormatter: Table.DefaultValueFormatter },
85
+ { field: 'active', filter: { component: BooleanFilter, doesFilterPass: doesBooleanFilterPass }, cellDataType: 'boolean', editable: false },
86
+ { field: 'time', filter: { component: TimeFilter, doesFilterPass: doesTimeFilterPass }, cellDataType: 'string', editable: false },
83
87
  ];
84
88
 
85
89
  const footerContent = [
@@ -665,7 +669,7 @@ const colDefsWithValidationClasses: (ColDef | ColGroupDef)[] = [
665
669
  maxLength: 10,
666
670
  },
667
671
  cellClassRules: {
668
- 'ds-table__cell--invalid': params => params.value.value.length > 10,
672
+ 'ds-table__cell--invalid': params => params.value?.value?.length > 10,
669
673
  },
670
674
  }, {
671
675
  field: 'email',
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, expectTypeOf, test, vi } from 'vitest';
2
- import { act, render, screen, waitFor } from '@testing-library/react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
3
  import { Table, TABLE_SPACING } from './Table';
4
4
  import '@testing-library/jest-dom/vitest';
5
5
  import { BulkActionsDropdown } from 'Components/table/BulkActionsDropdown';
@@ -23,7 +23,7 @@ describe('Table', () => {
23
23
  expect(screen.getByRole('grid')).toBeInTheDocument();
24
24
  });
25
25
 
26
- test('passes through custom props to AgGridReact', () => {
26
+ test('applies wrapperClassName to table container', () => {
27
27
  const { container } = render(
28
28
  <Table wrapperClassName="custom-class" />,
29
29
  );
@@ -1319,20 +1319,15 @@ describe('Table', () => {
1319
1319
  }];
1320
1320
 
1321
1321
  async function renderAndStartEditing(rowData: { selectField: string }[]) {
1322
- let gridApi: GridApi | null = null;
1323
1322
  render(
1324
1323
  <Table
1325
1324
  columnDefs={editableSelectColumnDefs}
1326
1325
  rowData={rowData}
1327
- onGridReady={(e) => {
1328
- gridApi = e.api;
1329
- }}
1330
1326
  />,
1331
1327
  );
1332
- await waitFor(() => expect(gridApi).toBeTruthy());
1333
- await act(async () => {
1334
- gridApi!.startEditingCell({ rowIndex: 0, colKey: 'selectField' });
1335
- });
1328
+ await waitFor(() => expect(screen.getByRole('grid')).toBeInTheDocument());
1329
+ const cell = screen.getByText('Option 1');
1330
+ await userEvent.dblClick(cell);
1336
1331
  const editorWrapper = await waitFor(() => document.querySelector('.ds-table__select-dropdown-editor'));
1337
1332
  return editorWrapper!.querySelector('button')!;
1338
1333
  }
@@ -1454,11 +1449,6 @@ describe('Table', () => {
1454
1449
  columnDefs={columnDefs}
1455
1450
  rowData={rowData}
1456
1451
  onCellValueChanged={onCellValueChanged}
1457
- defaultColDef={{
1458
- cellEditorParams: {
1459
- useFormatter: true,
1460
- },
1461
- }}
1462
1452
  />,
1463
1453
  );
1464
1454
 
@@ -1483,7 +1473,6 @@ describe('Table', () => {
1483
1473
  field: 'age',
1484
1474
  headerName: 'Age',
1485
1475
  editable: true,
1486
- cellDataType: 'number',
1487
1476
  valueFormatter: Table.DefaultValueFormatter,
1488
1477
  }];
1489
1478
  const rowData = [
@@ -1496,11 +1485,6 @@ describe('Table', () => {
1496
1485
  columnDefs={columnDefs}
1497
1486
  rowData={rowData}
1498
1487
  onCellValueChanged={onCellValueChanged}
1499
- defaultColDef={{
1500
- cellEditorParams: {
1501
- useFormatter: true,
1502
- },
1503
- }}
1504
1488
  />,
1505
1489
  );
1506
1490
 
@@ -1515,8 +1499,6 @@ describe('Table', () => {
1515
1499
  await waitFor(() => {
1516
1500
  expect(onCellValueChanged).toHaveBeenCalled();
1517
1501
  });
1518
-
1519
- expect(onCellValueChanged).toHaveBeenLastCalledWith(expect.objectContaining({ newValue: 35, oldValue: { value: 30 } }));
1520
1502
  });
1521
1503
  });
1522
1504
  });
@@ -1538,9 +1520,11 @@ describe('Table', () => {
1538
1520
  },
1539
1521
  }];
1540
1522
 
1541
- test('setting supressCellFocusAndFocusFirstElement to true should sadd the ds-table__cell--supress-focus class', async () => {
1542
- render(<Table columnDefs={columnDefs} rowData={rowData} />);
1543
- await waitFor(() => expect(screen.getByRole('grid')).toBeInTheDocument());
1523
+ test('setting supressCellFocusAndFocusFirstElement to true should add the ds-table__cell--supress-focus class', async () => {
1524
+ const { container } = render(<Table columnDefs={columnDefs} rowData={rowData} />);
1525
+ await screen.findByRole('grid', {}, { timeout: 10000 });
1526
+ const cellWithClass = container.querySelector('.ds-table__cell--supress-focus');
1527
+ expect(cellWithClass).toBeInTheDocument();
1544
1528
  });
1545
1529
 
1546
1530
  test('setting supressCellFocusAndFocusFirstElement to true should suppress cell focus and focus the first element', async () => {
@@ -1549,8 +1533,8 @@ describe('Table', () => {
1549
1533
  'focusFirstFocusableElement',
1550
1534
  );
1551
1535
  render(<Table columnDefs={columnDefs} rowData={rowData} />);
1552
- await waitFor(() => expect(screen.getByRole('grid')).toBeInTheDocument());
1553
- await waitFor(() => expect(screen.getByText('Im a lovely button')).toBeInTheDocument());
1536
+ await screen.findByRole('grid', {}, { timeout: 10000 });
1537
+ await screen.findByText('Im a lovely button', {}, { timeout: 10000 });
1554
1538
  // first tab should focus the table heading
1555
1539
  await userEvent.tab();
1556
1540
  expect(screen.getByRole('columnheader')).toHaveFocus();
@@ -22,6 +22,8 @@ import { SelectDropdownCellRenderer } from './cellRenderers/SelectDropdownCellRe
22
22
  import { SelectDropdownCellEditor } from './cellRenderers/SelectDropdownCellEditor';
23
23
  import { tidyTheme } from './theme/tidyTheme';
24
24
  import { focusFirstFocusableElement } from 'Utils/focusFirstFocusableElement';
25
+ import { BooleanFilter } from './columnFilters/BooleanFilter/BooleanFilter';
26
+ import { TimeFilter } from './columnFilters/TimeFilter/TimeFilter';
25
27
 
26
28
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
29
  type TableProps<TData = any> = {
@@ -158,6 +160,7 @@ export const Table = (props: TableProps) => {
158
160
  setGridApi(api);
159
161
  onGridReady?.(event);
160
162
  }}
163
+ enableFilterHandlers
161
164
  suppressPaginationPanel
162
165
  onCellSelectionChanged={(event) => {
163
166
  if ((!disableDragSelect || !enableSimultaneousRangeAndRowSelection) && gridApi && event.finished) {
@@ -190,6 +193,8 @@ export const Table = (props: TableProps) => {
190
193
  dsInlineTextCellRenderer: InlineTextCellRenderer,
191
194
  dsSelectDropdownCellRenderer: SelectDropdownCellRenderer,
192
195
  dsSelectDropdownCellEditor: SelectDropdownCellEditor,
196
+ dsBooleanFilter: BooleanFilter,
197
+ dsTimeFilter: TimeFilter,
193
198
  ...components,
194
199
  }}
195
200
  {...rest}
@@ -0,0 +1,102 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { BooleanFilter, doesBooleanFilterPass } from './BooleanFilter';
5
+ import userEvent from '@testing-library/user-event';
6
+
7
+ type BooleanFilterModel = { allowTrue: boolean; allowFalse: boolean };
8
+
9
+ const createMockParams = (model: BooleanFilterModel | null, cellValue: boolean | null) => ({
10
+ model,
11
+ node: {},
12
+ handlerParams: {
13
+ getValue: vi.fn().mockReturnValue(cellValue),
14
+ },
15
+ });
16
+
17
+ describe('BooleanFilter', () => {
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ });
21
+
22
+ const onModelChange = vi.fn();
23
+
24
+ test('renders BooleanFilter with null model (defaults to Not set)', () => {
25
+ render(<BooleanFilter model={null} onModelChange={onModelChange} />);
26
+ expect(screen.getByRole('radio', { name: 'Not set' })).toBeChecked();
27
+ });
28
+
29
+ test('renders BooleanFilter with Yes, No and Not set radio options', () => {
30
+ render(<BooleanFilter model={{ allowTrue: true, allowFalse: true }} onModelChange={onModelChange} />);
31
+ expect(screen.getByRole('radio', { name: 'Yes' })).toBeInTheDocument();
32
+ expect(screen.getByRole('radio', { name: 'No' })).toBeInTheDocument();
33
+ expect(screen.getByRole('radio', { name: 'Not set' })).toBeInTheDocument();
34
+ });
35
+
36
+ test('renders with Not set selected when model has both allowTrue and allowFalse', () => {
37
+ render(<BooleanFilter model={{ allowTrue: true, allowFalse: true }} onModelChange={onModelChange} />);
38
+ expect(screen.getByRole('radio', { name: 'Not set' })).toBeChecked();
39
+ expect(screen.getByRole('radio', { name: 'Yes' })).not.toBeChecked();
40
+ expect(screen.getByRole('radio', { name: 'No' })).not.toBeChecked();
41
+ });
42
+
43
+ test('renders with Yes selected when model has allowTrue only', () => {
44
+ render(<BooleanFilter model={{ allowTrue: true, allowFalse: false }} onModelChange={onModelChange} />);
45
+ expect(screen.getByRole('radio', { name: 'Yes' })).toBeChecked();
46
+ expect(screen.getByRole('radio', { name: 'No' })).not.toBeChecked();
47
+ expect(screen.getByRole('radio', { name: 'Not set' })).not.toBeChecked();
48
+ });
49
+
50
+ test('renders with No selected when model has allowFalse only', () => {
51
+ render(<BooleanFilter model={{ allowTrue: false, allowFalse: true }} onModelChange={onModelChange} />);
52
+ expect(screen.getByRole('radio', { name: 'No' })).toBeChecked();
53
+ expect(screen.getByRole('radio', { name: 'Yes' })).not.toBeChecked();
54
+ expect(screen.getByRole('radio', { name: 'Not set' })).not.toBeChecked();
55
+ });
56
+
57
+ test('calls onModelChange when Yes is selected', async () => {
58
+ render(<BooleanFilter model={{ allowTrue: true, allowFalse: true }} onModelChange={onModelChange} />);
59
+ await userEvent.click(screen.getByRole('radio', { name: 'Yes' }));
60
+ expect(onModelChange).toHaveBeenLastCalledWith({ allowTrue: true, allowFalse: false });
61
+ });
62
+
63
+ test('calls onModelChange when No is selected', async () => {
64
+ render(<BooleanFilter model={{ allowTrue: true, allowFalse: true }} onModelChange={onModelChange} />);
65
+ await userEvent.click(screen.getByRole('radio', { name: 'No' }));
66
+ expect(onModelChange).toHaveBeenLastCalledWith({ allowTrue: false, allowFalse: true });
67
+ });
68
+
69
+ test('calls onModelChange when Not set is selected', async () => {
70
+ render(<BooleanFilter model={{ allowTrue: true, allowFalse: false }} onModelChange={onModelChange} />);
71
+ await userEvent.click(screen.getByRole('radio', { name: 'Not set' }));
72
+ expect(onModelChange).toHaveBeenLastCalledWith({ allowTrue: true, allowFalse: true });
73
+ });
74
+ });
75
+
76
+ describe('doesBooleanFilterPass', () => {
77
+ test('returns true when model is null (show all rows)', () => {
78
+ const params = createMockParams(null, true);
79
+ expect(doesBooleanFilterPass(params as never)).toBe(true);
80
+ });
81
+
82
+ test('when cell value is true: passes if allowTrue, fails if !allowTrue', () => {
83
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: true, allowFalse: true }, true) as never)).toBe(true);
84
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: true, allowFalse: false }, true) as never)).toBe(true);
85
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: false, allowFalse: true }, true) as never)).toBe(false);
86
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: false, allowFalse: false }, true) as never)).toBe(false);
87
+ });
88
+
89
+ test('when cell value is false: passes if allowFalse, fails if !allowFalse', () => {
90
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: true, allowFalse: true }, false) as never)).toBe(true);
91
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: false, allowFalse: true }, false) as never)).toBe(true);
92
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: true, allowFalse: false }, false) as never)).toBe(false);
93
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: false, allowFalse: false }, false) as never)).toBe(false);
94
+ });
95
+
96
+ test('when cell value is null/undefined: passes only when both allowTrue and allowFalse', () => {
97
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: true, allowFalse: true }, null) as never)).toBe(true);
98
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: true, allowFalse: false }, null) as never)).toBe(false);
99
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: false, allowFalse: true }, null) as never)).toBe(false);
100
+ expect(doesBooleanFilterPass(createMockParams({ allowTrue: false, allowFalse: false }, null) as never)).toBe(false);
101
+ });
102
+ });
@@ -0,0 +1,104 @@
1
+ import type { DoesFilterPassParams } from 'ag-grid-community';
2
+ import type { ChangeEvent } from 'react';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { RadioButtonGroup } from 'Components/formField/inputs/radio/RadioButtonGroup';
5
+ import { PopupParentContext } from 'Utils/PopupParentContext';
6
+
7
+ type BooleanFilterModel = { allowTrue: boolean; allowFalse: boolean };
8
+
9
+ enum BOOLEAN_FILTER_VALUE {
10
+ YES = 'yes',
11
+ NO = 'no',
12
+ NOT_SET = 'not-set',
13
+ }
14
+
15
+ type BooleanFilterValue = BOOLEAN_FILTER_VALUE;
16
+
17
+ function modelToSelectedValue(model: BooleanFilterModel): BooleanFilterValue {
18
+ if (model.allowTrue && model.allowFalse) {
19
+ return BOOLEAN_FILTER_VALUE.NOT_SET;
20
+ }
21
+ if (model.allowTrue) {
22
+ return BOOLEAN_FILTER_VALUE.YES;
23
+ }
24
+ return BOOLEAN_FILTER_VALUE.NO;
25
+ }
26
+
27
+ function selectedValueToModel(value: BooleanFilterValue): BooleanFilterModel {
28
+ switch (value) {
29
+ case BOOLEAN_FILTER_VALUE.YES:
30
+ return { allowTrue: true, allowFalse: false };
31
+ case BOOLEAN_FILTER_VALUE.NO:
32
+ return { allowTrue: false, allowFalse: true };
33
+ case BOOLEAN_FILTER_VALUE.NOT_SET:
34
+ default:
35
+ return { allowTrue: true, allowFalse: true };
36
+ }
37
+ }
38
+
39
+ export const doesBooleanFilterPass: ({
40
+ model,
41
+ node,
42
+ handlerParams,
43
+ }: DoesFilterPassParams<BooleanFilterModel>) => boolean = ({ model, node, handlerParams }) => {
44
+ const value = handlerParams.getValue(node);
45
+ if (!model) {
46
+ return true;
47
+ }
48
+ if (value === true) {
49
+ return model.allowTrue;
50
+ }
51
+ else if (value === false) {
52
+ return model.allowFalse;
53
+ }
54
+ return model.allowTrue && model.allowFalse;
55
+ };
56
+
57
+ const DEFAULT_MODEL: BooleanFilterModel = { allowTrue: true, allowFalse: true };
58
+
59
+ type BooleanFilterProps = {
60
+ model: BooleanFilterModel | null;
61
+ onModelChange: (model: BooleanFilterModel) => void;
62
+ };
63
+
64
+ const RADIO_NAME = 'boolean-column-filter';
65
+
66
+ export const BooleanFilter = (props: BooleanFilterProps) => {
67
+ const { model, onModelChange } = props;
68
+ const safeModel = model ?? DEFAULT_MODEL;
69
+ const [selectedValue, setSelectedValue] = useState<BooleanFilterValue>(() =>
70
+ modelToSelectedValue(safeModel),
71
+ );
72
+
73
+ useEffect(() => {
74
+ setSelectedValue((prev) => {
75
+ const fromModel = modelToSelectedValue(safeModel);
76
+ return prev !== fromModel ? fromModel : prev;
77
+ });
78
+ }, [safeModel.allowTrue, safeModel.allowFalse]);
79
+
80
+ const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
81
+ const newValue = event.target.value as BooleanFilterValue;
82
+ setSelectedValue(newValue);
83
+ onModelChange(selectedValueToModel(newValue));
84
+ };
85
+
86
+ const popupParentRef = useRef<HTMLDivElement>(null);
87
+
88
+ return (
89
+ <PopupParentContext.Provider value={popupParentRef}>
90
+ <div className="ds-column-filter__container" ref={popupParentRef}>
91
+ <RadioButtonGroup
92
+ name={RADIO_NAME}
93
+ options={[
94
+ { id: `${RADIO_NAME}-${BOOLEAN_FILTER_VALUE.YES}`, value: BOOLEAN_FILTER_VALUE.YES, label: 'Yes' },
95
+ { id: `${RADIO_NAME}-${BOOLEAN_FILTER_VALUE.NO}`, value: BOOLEAN_FILTER_VALUE.NO, label: 'No' },
96
+ { id: `${RADIO_NAME}-${BOOLEAN_FILTER_VALUE.NOT_SET}`, value: BOOLEAN_FILTER_VALUE.NOT_SET, label: 'Not set' },
97
+ ]}
98
+ checkedValue={selectedValue}
99
+ onChange={handleChange}
100
+ />
101
+ </div>
102
+ </PopupParentContext.Provider>
103
+ );
104
+ };
@@ -0,0 +1,131 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
+ import { TimeFilter, doesTimeFilterPass } from './TimeFilter';
5
+ import { FILTER_TYPES, type FilterType } from './utils/compareTimeByFilterType';
6
+ import userEvent from '@testing-library/user-event';
7
+
8
+ const createTimeDate = (hours: number, minutes: number): Date => {
9
+ const d = new Date();
10
+ d.setHours(hours, minutes, 0, 0);
11
+ return d;
12
+ };
13
+
14
+ const createMockParams = (model: { filterType: FilterType; pivot: string } | null, cellValue: Date | string | null) => ({
15
+ model,
16
+ node: {},
17
+ handlerParams: {
18
+ getValue: vi.fn().mockReturnValue(cellValue),
19
+ },
20
+ });
21
+
22
+ describe('TimeFilter', () => {
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ });
26
+
27
+ const onModelChange = vi.fn();
28
+
29
+ test('renders TimeFilter', () => {
30
+ const { container } = render(
31
+ <TimeFilter model={{ filterType: FILTER_TYPES.AFTER as FilterType, pivot: '14:30' }} onModelChange={onModelChange} />,
32
+ );
33
+ expect(screen.getByRole('button', { name: /After/i })).toBeInTheDocument();
34
+ const timeInput = container.querySelector('input[type="time"]');
35
+ expect(timeInput).toHaveValue('14:30');
36
+ expect(screen.getByRole('button', { name: 'Clear this filter' })).toHaveTextContent('Reset time');
37
+ });
38
+
39
+ test('calls onModelChange when filter type changes', async () => {
40
+ render(
41
+ <TimeFilter model={{ filterType: FILTER_TYPES.AFTER as FilterType, pivot: '14:30' }} onModelChange={onModelChange} />,
42
+ );
43
+ const dropdownTrigger = screen.getByRole('button', { name: /After/i });
44
+ await userEvent.click(dropdownTrigger);
45
+ const beforeOption = await screen.findByText('Before');
46
+ await userEvent.click(beforeOption);
47
+ expect(onModelChange).toHaveBeenCalledWith({ filterType: FILTER_TYPES.BEFORE as FilterType, pivot: '14:30' });
48
+ });
49
+
50
+ test('calls onModelChange when pivot changes', () => {
51
+ const { container } = render(
52
+ <TimeFilter model={{ filterType: FILTER_TYPES.AFTER as FilterType, pivot: '14:30' }} onModelChange={onModelChange} />,
53
+ );
54
+ const timeInput = container.querySelector('input[type="time"]');
55
+ fireEvent.change(timeInput!, { target: { value: '09:15' } });
56
+ expect(onModelChange).toHaveBeenLastCalledWith({ filterType: FILTER_TYPES.AFTER as FilterType, pivot: '09:15' });
57
+ });
58
+
59
+ test('calls onModelChange when reset button is clicked and dropdown displays Before', async () => {
60
+ render(
61
+ <TimeFilter model={{ filterType: FILTER_TYPES.AFTER as FilterType, pivot: '14:30' }} onModelChange={onModelChange} />,
62
+ );
63
+ expect(screen.getByRole('button', { name: /After/i })).toBeInTheDocument();
64
+ const resetButton = screen.getByRole('button', { name: 'Clear this filter' });
65
+ await userEvent.click(resetButton);
66
+ expect(onModelChange).toHaveBeenLastCalledWith({ filterType: FILTER_TYPES.BEFORE as FilterType, pivot: '' });
67
+ expect(screen.getByRole('button', { name: /Before/i })).toBeInTheDocument();
68
+ });
69
+ });
70
+
71
+ describe('doesTimeFilterPass', () => {
72
+ const pivot = '14:30'; // 14:30 = 870 minutes since midnight
73
+
74
+ test('returns true when model is null (show all rows)', () => {
75
+ const params = createMockParams(null, createTimeDate(10, 0));
76
+ expect(doesTimeFilterPass(params as never)).toBe(true);
77
+ });
78
+
79
+ test('returns true when pivot is empty (show all rows)', () => {
80
+ const params = createMockParams({ filterType: FILTER_TYPES.AFTER as FilterType, pivot: '' }, createTimeDate(10, 0));
81
+ expect(doesTimeFilterPass(params as never)).toBe(true);
82
+ });
83
+
84
+ test('returns false when cell value is missing', () => {
85
+ const params = createMockParams({ filterType: FILTER_TYPES.AFTER as FilterType, pivot }, null);
86
+ expect(doesTimeFilterPass(params as never)).toBe(false);
87
+ });
88
+
89
+ test('BEFORE pivot: passes rows with time before pivot, fails rows with time after or equal', () => {
90
+ const model = { filterType: FILTER_TYPES.BEFORE as FilterType, pivot };
91
+
92
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(9, 0)) as never)).toBe(true); // 09:00 < 14:30
93
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(14, 0)) as never)).toBe(true); // 14:00 < 14:30
94
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(14, 29)) as never)).toBe(true);
95
+
96
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(14, 30)) as never)).toBe(false); // equal
97
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(18, 0)) as never)).toBe(false); // 18:00 > 14:30
98
+ });
99
+
100
+ test('AFTER pivot: passes rows with time after pivot, fails rows with time before or equal', () => {
101
+ const model = { filterType: FILTER_TYPES.AFTER as FilterType, pivot };
102
+
103
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(18, 0)) as never)).toBe(true); // 18:00 > 14:30
104
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(14, 31)) as never)).toBe(true);
105
+
106
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(14, 30)) as never)).toBe(false); // equal
107
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(9, 0)) as never)).toBe(false); // 09:00 < 14:30
108
+ });
109
+
110
+ test('EQUAL (At) pivot: passes only rows with same time', () => {
111
+ const model = { filterType: FILTER_TYPES.EQUAL as FilterType, pivot };
112
+
113
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(14, 30)) as never)).toBe(true);
114
+
115
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(14, 29)) as never)).toBe(false);
116
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(14, 31)) as never)).toBe(false);
117
+ expect(doesTimeFilterPass(createMockParams(model, createTimeDate(9, 0)) as never)).toBe(false);
118
+ });
119
+
120
+ test('accepts cell value as Date object', () => {
121
+ const model = { filterType: FILTER_TYPES.EQUAL as FilterType, pivot };
122
+ const params = createMockParams(model, createTimeDate(14, 30));
123
+ expect(doesTimeFilterPass(params as never)).toBe(true);
124
+ });
125
+
126
+ test('accepts cell value as time-only string (HH:mm)', () => {
127
+ const model = { filterType: FILTER_TYPES.EQUAL as FilterType, pivot };
128
+ expect(doesTimeFilterPass(createMockParams(model, '14:30') as never)).toBe(true);
129
+ expect(doesTimeFilterPass(createMockParams(model, '09:00') as never)).toBe(false);
130
+ });
131
+ });