@linzjs/step-ag-grid 29.11.4 → 29.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@linzjs/step-ag-grid",
3
3
  "repository": "github:linz/step-ag-grid.git",
4
4
  "license": "MIT",
5
- "version": "29.11.4",
5
+ "version": "29.12.0",
6
6
  "keywords": [
7
7
  "aggrid",
8
8
  "ag-grid",
@@ -38,6 +38,7 @@
38
38
  "clsx": "^2.1.1",
39
39
  "debounce-promise": "^3.1.2",
40
40
  "matcher": "^5.0.0",
41
+ "natsort": "^2.0.3",
41
42
  "react-transition-state": "^2.3.1",
42
43
  "usehooks-ts": "^3.1.1",
43
44
  "uuid": "^13.0.0"
@@ -23,6 +23,7 @@ import {
23
23
  RowDragMoveEvent,
24
24
  SelectionChangedEvent,
25
25
  SelectionColumnDef,
26
+ ValueFormatterParams,
26
27
  } from 'ag-grid-community';
27
28
  import { AgGridReact } from 'ag-grid-react';
28
29
  import clsx from 'clsx';
@@ -32,7 +33,7 @@ import { useInterval } from 'usehooks-ts';
32
33
 
33
34
  import { AutoSizeColumnsResult, StartCellEditingProps, useGridContext } from '../contexts/GridContext';
34
35
  import { GridUpdatingContext } from '../contexts/GridUpdatingContext';
35
- import { fnOrVar, isNotEmpty } from '../utils/util';
36
+ import { compareNaturalInsensitive, fnOrVar, isNotEmpty } from '../utils/util';
36
37
  import { clickInputWhenContainingCellClicked } from './clickInputWhenContainingCellClicked';
37
38
  import { GridHeaderSelect } from './gridHeader';
38
39
  import { GridContextMenuComponent, useGridContextMenu } from './gridHook';
@@ -600,18 +601,51 @@ export const Grid = <TData extends GridBaseRow = GridBaseRow>({
600
601
 
601
602
  const adjustColDef = (colDef: ColDef<TData>): ColDef<TData> => {
602
603
  const flex = colDef.flex ?? (sizeColumns === 'fit' ? 1 : undefined);
603
- return {
604
+ const valueFormatter = colDef.valueFormatter;
605
+ const sortable = colDef.sortable && params.defaultColDef?.sortable !== false;
606
+ let comparator = colDef.comparator;
607
+ if (sortable && !comparator) {
608
+ comparator = (value1, value2, node1, node2) => {
609
+ let r = 0;
610
+ if (typeof valueFormatter === 'function') {
611
+ r = compareNaturalInsensitive(
612
+ valueFormatter({
613
+ data: node1.data,
614
+ value: value1,
615
+ node: node1,
616
+ colDef: adjustedColDef,
617
+ ...NotAGridValueFormatterCall,
618
+ } as ValueFormatterParams<TData>),
619
+ valueFormatter({
620
+ data: node2.data,
621
+ value: value2,
622
+ node: node2,
623
+ colDef: adjustedColDef,
624
+ ...NotAGridValueFormatterCall,
625
+ } as ValueFormatterParams<TData>),
626
+ );
627
+ } else {
628
+ r = compareNaturalInsensitive(value1, value2);
629
+ }
630
+ // secondary compare as primary sort column rows are equal
631
+ return r === 0 ? compareNaturalInsensitive(node1.data?.id, node2.data?.id) : r;
632
+ };
633
+ }
634
+ const adjustedColDef = {
604
635
  ...colDef,
605
636
  // You cannot pass a width to a flex
606
637
  width: !!colDef.flex ? undefined : colDef.width,
607
- flexAutoSizeWidth: colDef.width,
638
+ ...(!!colDef.flex && { flexAutoSizeWidth: colDef.width }),
608
639
  // If this is allowed flex columns don't size based on flex
609
640
  suppressSizeToFit: true,
610
641
  // Auto-sizing flex columns breaks everything
611
642
  flex,
612
643
  suppressAutoSize: !!flex,
613
- sortable: colDef.sortable && params.defaultColDef?.sortable !== false,
644
+ sortable,
645
+ comparator,
614
646
  } as ColDef<TData> & { flexAutoSizeWidth?: number };
647
+
648
+ return adjustedColDef;
615
649
  };
616
650
 
617
651
  return columnDefs.map((colDef) => adjustColDefOrGroup(colDef));
@@ -969,3 +1003,13 @@ const quickFilterParser = (filterStr: string) => {
969
1003
  // filter is exact matches exactly groups separated by commas
970
1004
  return filterStr.split(',').map((str) => str.trim());
971
1005
  };
1006
+
1007
+ /**
1008
+ * Columns to indicate to user when they debug why things are broken in the default comparator if their valueFormatter
1009
+ * is too complicated.
1010
+ */
1011
+ const NotAGridValueFormatterCall = {
1012
+ column: 'Default comparator has no access to column, write your own comparator' as unknown,
1013
+ api: 'Default comparator has no access to api, write your own comparator' as unknown,
1014
+ context: 'Default comparator has no access to context, write your own comparator' as unknown,
1015
+ };
@@ -439,59 +439,64 @@ export const GridContextProvider = <TData extends GridBaseRow>(props: PropsWithC
439
439
  return null;
440
440
  }
441
441
 
442
- const colIdsSet = colIds instanceof Set ? colIds : new Set(colIds);
442
+ try {
443
+ const colIdsSet = colIds instanceof Set ? colIds : new Set(colIds);
443
444
 
444
- const getVisibleColStates = () => {
445
- const colStates = gridApi.getColumnState();
446
- return colStates.filter((colState) => {
447
- const colId = colState.colId;
448
- return (isEmpty(colIdsSet) || colIdsSet.has(colId)) && !userSizedColIds?.has(colId);
445
+ const getVisibleColStates = () => {
446
+ const colStates = gridApi.getColumnState();
447
+ return colStates.filter((colState) => {
448
+ const colId = colState.colId;
449
+ return (isEmpty(colIdsSet) || colIdsSet.has(colId)) && !userSizedColIds?.has(colId);
450
+ });
451
+ };
452
+ const getFlexColStates = () => getVisibleColStates().filter(colStateFlexed);
453
+ const getNonFlexColStates = () => getVisibleColStates().filter(colStateNotFlexed);
454
+
455
+ // You cannot autosize flex columns it will break layout randomly
456
+ // So, a flex column is assumed to be 150 wide, unless width is provided
457
+ const flexColumns = getFlexColStates().map(colStateId);
458
+ let width = 0;
459
+ flexColumns.forEach((colId) => {
460
+ const colDef = gridApi.getColumnDef(colId) as { flexAutoSizeWidth?: number } | undefined;
461
+ width += colDef?.flexAutoSizeWidth ?? 200;
449
462
  });
450
- };
451
- const getFlexColStates = () => getVisibleColStates().filter(colStateFlexed);
452
- const getNonFlexColStates = () => getVisibleColStates().filter(colStateNotFlexed);
453
-
454
- // You cannot autosize flex columns it will break layout randomly
455
- // So, a flex column is assumed to be 150 wide, unless width is provided
456
- const flexColumns = getFlexColStates().map(colStateId);
457
- let width = 0;
458
- flexColumns.forEach((colId) => {
459
- const colDef = gridApi.getColumnDef(colId) as { flexAutoSizeWidth?: number } | undefined;
460
- width += colDef?.flexAutoSizeWidth ?? 200;
461
- });
462
463
 
463
- const nonFlexColumns = getNonFlexColStates();
464
- const nonFlexColumnIds = nonFlexColumns.map(colStateId);
465
- gridApi.autoSizeColumns({ colIds: nonFlexColumnIds, skipHeader });
464
+ const nonFlexColumns = getNonFlexColStates();
465
+ const nonFlexColumnIds = nonFlexColumns.map(colStateId);
466
+ gridApi.autoSizeColumns({ colIds: nonFlexColumnIds, skipHeader });
466
467
 
467
- const calcSubWidth = () => {
468
- const updatedFlexColumns = getVisibleColStates().filter((colState) =>
469
- nonFlexColumnIds.includes(colState.colId),
470
- );
471
- return sumBy(
472
- updatedFlexColumns.filter((col) => !col.hide),
473
- 'width',
474
- );
475
- };
468
+ const calcSubWidth = () => {
469
+ const updatedFlexColumns = getVisibleColStates().filter((colState) =>
470
+ nonFlexColumnIds.includes(colState.colId),
471
+ );
472
+ return sumBy(
473
+ updatedFlexColumns.filter((col) => !col.hide),
474
+ 'width',
475
+ );
476
+ };
476
477
 
477
- // ag-grid updates widths asynchronously, so we wait here for the default calculated width to change
478
- let lastSubWidth = calcSubWidth();
479
- const endTime = Date.now() + 1000;
480
- while (Date.now() < endTime) {
481
- await wait(40);
482
- const newSubWidth = calcSubWidth();
483
- if (lastSubWidth !== newSubWidth) {
484
- lastSubWidth = newSubWidth;
485
- break;
478
+ // ag-grid updates widths asynchronously, so we wait here for the default calculated width to change
479
+ let lastSubWidth = calcSubWidth();
480
+ const endTime = Date.now() + 1000;
481
+ while (Date.now() < endTime) {
482
+ await wait(40);
483
+ const newSubWidth = calcSubWidth();
484
+ if (lastSubWidth !== newSubWidth) {
485
+ lastSubWidth = newSubWidth;
486
+ break;
487
+ }
486
488
  }
487
- }
488
489
 
489
- width += lastSubWidth;
490
+ width += lastSubWidth;
490
491
 
491
- gridApi.sizeColumnsToFit();
492
- return {
493
- width,
494
- };
492
+ gridApi.sizeColumnsToFit();
493
+ return {
494
+ width,
495
+ };
496
+ } catch (ex) {
497
+ console.info('autosize failed', ex);
498
+ return null;
499
+ }
495
500
  },
496
501
  [gridApi],
497
502
  );
@@ -0,0 +1,189 @@
1
+ import '../../styles/GridTheme.scss';
2
+ import '../../styles/index.scss';
3
+ import '@linzjs/lui/dist/scss/base.scss';
4
+ import '@linzjs/lui/dist/fonts';
5
+
6
+ import { Meta, StoryFn } from '@storybook/react-vite';
7
+ import { useMemo, useState } from 'react';
8
+ import { expect } from 'storybook/test';
9
+
10
+ import {
11
+ ColDefT,
12
+ compareNaturalInsensitive,
13
+ Grid,
14
+ GridCell,
15
+ GridContextProvider,
16
+ GridProps,
17
+ GridUpdatingContextProvider,
18
+ GridWrapper,
19
+ wait,
20
+ } from '../..';
21
+ import {
22
+ clickColumnHeaderToSort,
23
+ gridColumnValues,
24
+ waitForGridReady,
25
+ waitForGridRows,
26
+ } from '../../utils/__tests__/storybookTestUtil';
27
+
28
+ export default {
29
+ title: 'Components / Grids',
30
+ component: Grid,
31
+ args: {
32
+ quickFilter: true,
33
+ quickFilterValue: '',
34
+ quickFilterPlaceholder: 'Quick filter...',
35
+ selectable: false,
36
+ rowSelection: 'single',
37
+ }, // Storybook hangs otherwise
38
+ parameters: {
39
+ docs: {
40
+ source: {
41
+ type: 'code',
42
+ },
43
+ },
44
+ },
45
+ decorators: [
46
+ (Story) => (
47
+ <div style={{ maxWidth: 1024, height: 400, display: 'flex', flexDirection: 'column' }}>
48
+ <GridUpdatingContextProvider>
49
+ <GridContextProvider>
50
+ <Story />
51
+ </GridContextProvider>
52
+ </GridUpdatingContextProvider>
53
+ </div>
54
+ ),
55
+ ],
56
+ } as Meta<typeof Grid>;
57
+
58
+ interface ITestRow {
59
+ id: number;
60
+ numeric?: number;
61
+ text?: string;
62
+ }
63
+
64
+ const GridReadOnlyTemplate: StoryFn<typeof Grid<ITestRow>> = (props: GridProps<ITestRow>) => {
65
+ const [externalSelectedItems, setExternalSelectedItems] = useState<any[]>([]);
66
+ const columnDefs: ColDefT<ITestRow>[] = useMemo(
67
+ () => [
68
+ GridCell({
69
+ field: 'id',
70
+ headerName: 'Id',
71
+ lockVisible: true,
72
+ }),
73
+ GridCell({
74
+ field: 'numeric',
75
+ headerName: 'Numeric',
76
+ }),
77
+ GridCell({
78
+ field: 'text',
79
+ headerName: 'Text',
80
+ }),
81
+ GridCell({
82
+ colId: 'customComparatorText',
83
+ headerName: 'Cust. comp. id as text',
84
+ valueFormatter: ({ data }) => data?.text ?? '',
85
+ comparator: (_v1, _v2, n1, n2) => compareNaturalInsensitive(String(n1.data?.id), String(n2.data?.id)) ?? 0,
86
+ }),
87
+ GridCell({
88
+ colId: 'customComparatorNumeric',
89
+ headerName: 'Cust. comp. abs number',
90
+ valueFormatter: ({ data }) => String(data?.numeric ?? ''),
91
+ comparator: (_v1, _v2, n1, n2) => {
92
+ return compareNaturalInsensitive(n1.data?.numeric, n2.data?.numeric) ?? 0;
93
+ },
94
+ }),
95
+ ],
96
+ [],
97
+ );
98
+
99
+ const [rowData] = useState<ITestRow[]>([
100
+ { id: 1009, numeric: -1.1, text: 'ade' },
101
+ { id: 1000, numeric: 1, text: 'ade' },
102
+ { id: 1001, numeric: 11, text: 'cdc' },
103
+ { id: 1002, numeric: 21, text: '2' },
104
+ { id: 1003, numeric: 2.1, text: '10' },
105
+ { id: 1004, numeric: 2.01, text: 'b' },
106
+ { id: 1005, numeric: 2.21, text: 'b' },
107
+ { id: 1006, numeric: 3, text: undefined },
108
+ { id: 1008, numeric: 3, text: 'e' },
109
+ { id: 1007, numeric: undefined, text: 'e' },
110
+ ]);
111
+
112
+ return (
113
+ <GridWrapper maxHeight={400}>
114
+ <Grid
115
+ data-testid={'readonly'}
116
+ {...props}
117
+ selectable={true}
118
+ enableClickSelection={true}
119
+ enableSelectionWithoutKeys={true}
120
+ autoSelectFirstRow={true}
121
+ externalSelectedItems={externalSelectedItems}
122
+ setExternalSelectedItems={setExternalSelectedItems}
123
+ columnDefs={columnDefs}
124
+ rowData={rowData}
125
+ />
126
+ </GridWrapper>
127
+ );
128
+ };
129
+
130
+ export const Sorting = GridReadOnlyTemplate.bind({});
131
+ Sorting.play = async (context) => {
132
+ const { canvasElement } = context;
133
+ await waitForGridReady(context);
134
+ await waitForGridRows({ grid: canvasElement });
135
+
136
+ const test = async (colId: string | null, expected: string[] | null, idExpected?: string[]) => {
137
+ if (colId !== null) {
138
+ await clickColumnHeaderToSort(colId, canvasElement);
139
+ await wait(500);
140
+ expected && expect(gridColumnValues(colId, canvasElement)).toEqual(expected);
141
+ }
142
+ idExpected && expect(gridColumnValues('id', canvasElement)).toEqual(idExpected);
143
+ };
144
+
145
+ // Default sort order
146
+ await test(null, null, ['1009', '1000', '1001', '1002', '1003', '1004', '1005', '1006', '1008', '1007']);
147
+
148
+ await test('numeric', ['–', '-1.1', '1', '2.01', '2.1', '2.21', '3', '3', '11', '21']);
149
+ await test('numeric', ['21', '11', '3', '3', '2.21', '2.1', '2.01', '1', '-1.1', '–']);
150
+
151
+ await test(
152
+ 'text',
153
+ ['–', '2', '10', 'ade', 'ade', 'b', 'b', 'cdc', 'e', 'e'],
154
+ ['1006', '1002', '1003', '1000', '1009', '1004', '1005', '1001', '1007', '1008'],
155
+ );
156
+ await test(
157
+ 'text',
158
+ ['e', 'e', 'cdc', 'b', 'b', 'ade', 'ade', '10', '2', '–'],
159
+ ['1008', '1007', '1001', '1005', '1004', '1009', '1000', '1003', '1002', '1006'],
160
+ );
161
+
162
+ await test('customComparatorText', null, [
163
+ '1000',
164
+ '1001',
165
+ '1002',
166
+ '1003',
167
+ '1004',
168
+ '1005',
169
+ '1006',
170
+ '1007',
171
+ '1008',
172
+ '1009',
173
+ ]);
174
+ await test('customComparatorText', null, [
175
+ '1009',
176
+ '1008',
177
+ '1007',
178
+ '1006',
179
+ '1005',
180
+ '1004',
181
+ '1003',
182
+ '1002',
183
+ '1001',
184
+ '1000',
185
+ ]);
186
+
187
+ await test('customComparatorNumeric', ['–', '-1.1', '1', '2.01', '2.1', '2.21', '3', '3', '11', '21']);
188
+ await test('customComparatorNumeric', ['21', '11', '3', '3', '2.21', '2.1', '2.01', '1', '-1.1', '–']);
189
+ };
@@ -1,4 +1,31 @@
1
- import { expect, waitFor } from 'storybook/test';
1
+ import { sortBy } from 'lodash-es';
2
+ import { expect, userEvent, waitFor } from 'storybook/test';
3
+
4
+ import { findQuick, getAllQuick } from './testQuick';
2
5
 
3
6
  export const waitForGridReady = ({ canvasElement }: { canvasElement: HTMLElement }) =>
4
7
  waitFor(() => expect(canvasElement.querySelector('.Grid-ready')).toBeInTheDocument(), { timeout: 5000 });
8
+
9
+ export const waitForGridRows = async (props?: { grid?: HTMLElement; timeout?: number }) =>
10
+ waitFor(() => expect(getAllQuick({ classes: '.ag-row' }, props?.grid).length > 0).toBe(true), {
11
+ timeout: props?.timeout ?? 4000,
12
+ });
13
+
14
+ export const clickColumnHeaderToSort = async (colId: string, within: HTMLElement): Promise<void> => {
15
+ const user = userEvent.setup({
16
+ delay: 100,
17
+ });
18
+ const header = await findQuick({ selector: `.ag-header-cell[col-id="${colId}"]` }, within);
19
+ await expect(header).toBeInTheDocument();
20
+ // For some reason clicks in storybook don't trigger ag-grid, but manual clicks do
21
+ await user.click(header);
22
+ await user.keyboard('{Enter}');
23
+ };
24
+
25
+ export const gridColumnValues = (colId: string, within: HTMLElement): string[] => {
26
+ return sortBy(
27
+ getAllQuick({ tagName: `[col-id="${colId}"]`, classes: `.ag-cell` }, within),
28
+ // @ts-expect-error css style type
29
+ (el) => el.parentElement?.attributeStyleMap.get('transform')?.[0]?.y.value,
30
+ ).map((cell) => cell.innerText);
31
+ };
@@ -25,6 +25,7 @@ export interface IQueryQuick {
25
25
  role?: string;
26
26
  classes?: string;
27
27
  child?: IQueryQuick;
28
+ selector?: string;
28
29
  }
29
30
 
30
31
  const escapeSelectorParam = (param: string): string => param.replace(/["\\]/g, '\\$&');
@@ -51,6 +52,7 @@ const quickSelector = <T extends HTMLElement>(
51
52
  let lastIQueryQuick = props;
52
53
  for (let loop: IQueryQuick | undefined = props; loop; loop = loop.child) {
53
54
  lastIQueryQuick = loop;
55
+ loop.selector && (selector += loop.selector);
54
56
  loop.tagName && (selector += loop.tagName);
55
57
  loop.ariaLabel && (selector += `[aria-label='${escapeSelectorParam(loop.ariaLabel)}']`);
56
58
  loop.role && (selector += `[role="${escapeSelectorParam(loop.role)}"]`);
package/src/utils/util.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isEmpty, negate } from 'lodash-es';
2
+ import natsort from 'natsort';
2
3
 
3
4
  export const isNotEmpty = negate(isEmpty);
4
5
 
@@ -58,3 +59,21 @@ export const sanitiseFileName = (filename: string): string => {
58
59
  if (!fileExt) return valid.slice().slice(0, 64);
59
60
  return valid.slice(0, -fileExt.length - 1).slice(0, 64) + '.' + fileExt;
60
61
  };
62
+
63
+ const naturalSortInsensitive = natsort({ insensitive: true });
64
+ const santitizeNaturalValue = (v: string | number | null | undefined): string => {
65
+ if (v === '–' || v === '-' || v == null) {
66
+ return '';
67
+ }
68
+ return String(v);
69
+ };
70
+ export const compareNaturalInsensitive = (
71
+ a?: object | string | number | null,
72
+ b?: object | string | number | null,
73
+ ): number => {
74
+ if ((a !== null && typeof a === 'object') || (b !== null && typeof b === 'object')) {
75
+ // We can't compare objects
76
+ return 0;
77
+ }
78
+ return naturalSortInsensitive(santitizeNaturalValue(a), santitizeNaturalValue(b));
79
+ };