@linzjs/step-ag-grid 13.3.1 → 13.4.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": "13.3.1",
5
+ "version": "13.4.0",
6
6
  "keywords": [
7
7
  "aggrid",
8
8
  "ag-grid",
@@ -187,6 +187,9 @@ export const Grid = ({
187
187
  colId: "selection",
188
188
  editable: false,
189
189
  minWidth: 42,
190
+ headerComponentParams: {
191
+ exportable: false,
192
+ },
190
193
  maxWidth: 42,
191
194
  suppressSizeToFit: true,
192
195
  checkboxSelection: true,
@@ -344,6 +347,7 @@ export const Grid = ({
344
347
  alwaysShowVerticalScroll={params.alwaysShowVerticalScroll}
345
348
  isExternalFilterPresent={isExternalFilterPresent}
346
349
  doesExternalFilterPass={doesExternalFilterPass}
350
+ maintainColumnOrder={true}
347
351
  />
348
352
  </div>
349
353
  </div>
@@ -114,6 +114,9 @@ export const GridCell = <RowType extends GridBaseRow, Props extends CellEditorCo
114
114
  // This is so that e.g. bearings can be searched for by DMS or raw number.
115
115
  const valueFormatter = props.valueFormatter;
116
116
  const filterValueGetter = generateFilterGetter(props.field, props.filterValueGetter, valueFormatter);
117
+ const exportable = props.exportable;
118
+ // Can't leave this here ag-grid will complain
119
+ delete props.exportable;
117
120
 
118
121
  return {
119
122
  colId: props.field,
@@ -144,6 +147,10 @@ export const GridCell = <RowType extends GridBaseRow, Props extends CellEditorCo
144
147
  originalCellRenderer: props.cellRenderer,
145
148
  ...props.cellRendererParams,
146
149
  },
150
+ headerComponentParams: {
151
+ exportable,
152
+ ...props.headerComponentParams,
153
+ },
147
154
  };
148
155
  };
149
156
 
@@ -0,0 +1,43 @@
1
+ import { CsvExportParams } from "ag-grid-community/dist/lib/interfaces/exportParams";
2
+ import { delay } from "lodash-es";
3
+ import { useContext } from "react";
4
+
5
+ import { LuiMiniSpinner } from "@linzjs/lui";
6
+
7
+ import { GridContext } from "../../contexts/GridContext";
8
+ import { useStateDeferred } from "../../lui/stateDeferredHook";
9
+ import { GridFilterHeaderIconButton } from "./GridFilterHeaderIconButton";
10
+
11
+ export const GridFilterDownloadCsvButton = (csvExportParams: CsvExportParams) => {
12
+ const { downloadCsv } = useContext(GridContext);
13
+
14
+ const [downloading, setDownloading, setDownloadingDeferred] = useStateDeferred(false);
15
+
16
+ const handleDownloadClick = () => {
17
+ setDownloading(true);
18
+ // Defer the download such that the component disabled state can update
19
+ delay(() => {
20
+ downloadCsv(csvExportParams);
21
+ // Defer re-enablement as the browser takes time to process the download
22
+ setDownloadingDeferred(false, 4000);
23
+ }, 100);
24
+ };
25
+
26
+ return downloading ? (
27
+ <LuiMiniSpinner
28
+ size={22}
29
+ divProps={{
30
+ role: "status",
31
+ ["aria-label"]: "Downloading...",
32
+ style: { width: 42, display: "flex", justifyContent: "center" },
33
+ }}
34
+ />
35
+ ) : (
36
+ <GridFilterHeaderIconButton
37
+ icon={"ic_save_download"}
38
+ title={"Download CSV"}
39
+ onClick={handleDownloadClick}
40
+ disabled={downloading}
41
+ />
42
+ );
43
+ };
@@ -19,7 +19,7 @@ export const GridFilterHeaderIconButton = forwardRef<HTMLButtonElement, GridFilt
19
19
  {...buttonProps}
20
20
  type={"button"}
21
21
  level={"tertiary"}
22
- className={"GridFilterHeaderIconButton lui-button-icon-only"}
22
+ className={"lui-button-icon-only"}
23
23
  ref={ref}
24
24
  aria-label={title}
25
25
  title={title}
@@ -16,15 +16,17 @@ export const GridFilterQuick = ({ quickFilterPlaceholder, defaultValue }: GridFi
16
16
  }, [quickFilterValue, setQuickFilter]);
17
17
 
18
18
  return (
19
- <input
20
- aria-label="Search"
21
- className="Grid-quickFilterBox"
22
- type="text"
23
- placeholder={quickFilterPlaceholder ?? "Search..."}
24
- value={quickFilterValue}
25
- onChange={(event) => {
26
- setQuickFilterValue(event.target.value);
27
- }}
28
- />
19
+ <div className="GridFilterQuick-container">
20
+ <input
21
+ aria-label="Search"
22
+ className="GridFilterQuick-input"
23
+ type="text"
24
+ placeholder={quickFilterPlaceholder ?? "Search..."}
25
+ value={quickFilterValue}
26
+ onChange={(event) => {
27
+ setQuickFilterValue(event.target.value);
28
+ }}
29
+ />
30
+ </div>
29
31
  );
30
32
  };
@@ -3,4 +3,5 @@ export * from "./GridFilterColumnsToggle";
3
3
  export * from "./GridFilterButtons";
4
4
  export * from "./GridFilterQuick";
5
5
  export * from "./GridFilters";
6
+ export * from "./GridFilterDownloadCsvButton";
6
7
  export * from "./useGridFilter";
@@ -17,6 +17,7 @@ export const GridPopoverMenu = <RowType extends GridBaseRow>(
17
17
  maxWidth: 48,
18
18
  width: 40,
19
19
  editable: colDef.editable != null ? colDef.editable : true,
20
+ exportable: false,
20
21
  cellStyle: { justifyContent: "center" },
21
22
  cellRenderer: GridRenderPopoutMenuCell,
22
23
  ...colDef,
@@ -35,6 +35,7 @@ export interface GenericCellColDef<RowType extends GridBaseRow> extends ColDefT<
35
35
  valueFormatter?: string | ((params: RowValueFormatterParams<RowType>) => string);
36
36
  filterValueGetter?: string | ((params: RowValueGetterParams<RowType>) => string);
37
37
  editable?: boolean | ((params: RowEditableCallbackParams<RowType>) => boolean);
38
+ exportable?: boolean;
38
39
  }
39
40
 
40
41
  export interface GenericCellRendererParams<RowType extends GridBaseRow> {
@@ -1,4 +1,5 @@
1
1
  import { ColumnApi, GridApi, RowNode } from "ag-grid-community";
2
+ import { CsvExportParams } from "ag-grid-community/dist/lib/interfaces/exportParams";
2
3
  import { createContext, useContext } from "react";
3
4
 
4
5
  import { ColDefT, GridBaseRow } from "../components";
@@ -43,6 +44,7 @@ export interface GridContextType<RowType extends GridBaseRow> {
43
44
  getColumns: () => ColDefT<RowType>[];
44
45
  invisibleColumnIds: string[];
45
46
  setInvisibleColumnIds: (colIds: string[]) => void;
47
+ downloadCsv: (csvExportParams?: CsvExportParams) => void;
46
48
  }
47
49
 
48
50
  export const GridContext = createContext<GridContextType<any>>({
@@ -146,6 +148,9 @@ export const GridContext = createContext<GridContextType<any>>({
146
148
  console.error("no context provider for doesExternalFilterPass");
147
149
  return true;
148
150
  },
151
+ downloadCsv: () => {
152
+ console.error("no context provider for downloadCsv");
153
+ },
149
154
  });
150
155
 
151
156
  export const useGridContext = <RowType extends GridBaseRow>() => useContext<GridContextType<RowType>>(GridContext);
@@ -0,0 +1,39 @@
1
+ import { ValueFormatterParams } from "ag-grid-community/dist/lib/entities/colDef";
2
+
3
+ import { downloadCsvUseValueFormattersProcessCellCallback as Dpcc } from "./GridContextProvider";
4
+
5
+ describe("downloadCsvUseValueFormattersProcessCellCallback", () => {
6
+ test("each type of value for conversion to string", async () => {
7
+ expect(Dpcc({ value: undefined } as any)).toBe("");
8
+ expect(Dpcc({ value: "-" } as any)).toBe("");
9
+ expect(Dpcc({ value: "–" } as any)).toBe("");
10
+ expect(Dpcc({ value: null } as any)).toBe("");
11
+ expect(Dpcc({ value: false } as any)).toBe("false");
12
+ expect(Dpcc({ value: "x" } as any)).toBe("x");
13
+ expect(Dpcc({ value: 123 } as any)).toBe("123");
14
+ expect(Dpcc({ value: { a: 2 } } as any)).toBe('{"a":2}');
15
+ expect(Dpcc({ value: { a: 2 }, column: { getColDef: () => undefined } } as any)).toBe('{"a":2}');
16
+ expect(Dpcc({ value: { a: 2 }, column: { getColDef: () => ({ valueFormatter: undefined }) } } as any)).toBe(
17
+ '{"a":2}',
18
+ );
19
+ expect(
20
+ Dpcc({
21
+ value: { a: 2 },
22
+ column: { getColDef: () => ({ valueFormatter: "registeredValueFormatter" }) },
23
+ } as any),
24
+ ).toBe('{"a":2}');
25
+
26
+ expect(
27
+ Dpcc({
28
+ value: { a: 2 },
29
+ column: {
30
+ getColDef: () => ({
31
+ valueFormatter: (params: ValueFormatterParams) => {
32
+ return "xxx" + params.value.a;
33
+ },
34
+ }),
35
+ },
36
+ } as any),
37
+ ).toBe("xxx2");
38
+ });
39
+ });
@@ -1,5 +1,7 @@
1
1
  import { ColDef, ColumnApi, GridApi, RowNode } from "ag-grid-community";
2
2
  import { CellPosition } from "ag-grid-community/dist/lib/entities/cellPosition";
3
+ import { ValueFormatterParams } from "ag-grid-community/dist/lib/entities/colDef";
4
+ import { CsvExportParams, ProcessCellForExportParams } from "ag-grid-community/dist/lib/interfaces/exportParams";
3
5
  import { compact, debounce, defer, delay, difference, isEmpty, last, remove, sortBy } from "lodash-es";
4
6
  import { ReactElement, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
5
7
 
@@ -418,12 +420,18 @@ export const GridContextProvider = <RowType extends GridBaseRow>(props: GridCont
418
420
  [gridApiOp],
419
421
  );
420
422
 
423
+ // waitForExternallySelectedItemsToBeInSync can't use the state as it won't be updated during function execution
424
+ const externallySelectedItemsAreInSyncRef = useRef(false);
425
+ useEffect(() => {
426
+ externallySelectedItemsAreInSyncRef.current = externallySelectedItemsAreInSync;
427
+ }, [externallySelectedItemsAreInSync]);
428
+
421
429
  const waitForExternallySelectedItemsToBeInSync = useCallback(async () => {
422
430
  // Wait for up to 5 seconds
423
- for (let i = 0; i < 5000 / 200 && !externallySelectedItemsAreInSync; i++) {
431
+ for (let i = 0; i < 5000 / 200 && !externallySelectedItemsAreInSyncRef.current; i++) {
424
432
  await wait(200);
425
433
  }
426
- }, [externallySelectedItemsAreInSync]);
434
+ }, []);
427
435
 
428
436
  const onFilterChanged = useMemo(
429
437
  () =>
@@ -467,6 +475,26 @@ export const GridContextProvider = <RowType extends GridBaseRow>(props: GridCont
467
475
  }
468
476
  }, [invisibleColumnIds, columnApi, getColumns]);
469
477
 
478
+ /**
479
+ * Download visible columns as a CSV
480
+ */
481
+ const downloadCsv = useCallback(
482
+ (csvExportParams?: CsvExportParams) => {
483
+ if (!gridApi || !columnApi) return;
484
+
485
+ const columnKeys = columnApi
486
+ ?.getColumnState()
487
+ .filter((cs) => !cs.hide && gridApi.getColumnDef(cs.colId)?.headerComponentParams?.exportable !== false)
488
+ .map((cs) => cs.colId);
489
+ gridApi.exportDataAsCsv({
490
+ columnKeys,
491
+ processCellCallback: downloadCsvUseValueFormattersProcessCellCallback,
492
+ ...csvExportParams,
493
+ });
494
+ },
495
+ [columnApi, gridApi],
496
+ );
497
+
470
498
  return (
471
499
  <GridContext.Provider
472
500
  value={{
@@ -502,9 +530,58 @@ export const GridContextProvider = <RowType extends GridBaseRow>(props: GridCont
502
530
  removeExternalFilter,
503
531
  isExternalFilterPresent,
504
532
  doesExternalFilterPass,
533
+ downloadCsv,
505
534
  }}
506
535
  >
507
536
  {props.children}
508
537
  </GridContext.Provider>
509
538
  );
510
539
  };
540
+
541
+ /**
542
+ * Aggrid defaults to using getters and ignores formatters.
543
+ * step-ag-grid by default has a valueFormatter for every column that defaults to the getter if no valueFormatter
544
+ * This function uses valueFormatter by default
545
+ */
546
+ export const downloadCsvUseValueFormattersProcessCellCallback = (params: ProcessCellForExportParams): string => {
547
+ const encodeToString = (value: any): string => {
548
+ // Convert nullish values to blank
549
+ if (value === "-" || value === "–" || value == null) {
550
+ return "";
551
+ }
552
+ if (typeof value === "string") {
553
+ return value;
554
+ }
555
+ return JSON.stringify(value);
556
+ };
557
+
558
+ // Try to use valueFormatter
559
+ const colDef = params?.column?.getColDef();
560
+ if (!colDef) return encodeToString(params.value);
561
+
562
+ // All columns in step-ag-grid have a default valueFormatter
563
+ // If you have custom a renderer you need to define your own valueFormatter to produce the text value
564
+ const valueFormatter = colDef.valueFormatter;
565
+ // If no valueFormatter then value _must_ be a string
566
+ if (valueFormatter == null) {
567
+ if (params.value != null && typeof params.value !== "string") {
568
+ console.error(`downloadCsv: valueFormatter missing and getValue is not a string, colId: ${colDef.colId}`);
569
+ }
570
+ return encodeToString(params.value);
571
+ }
572
+
573
+ // We don't have access to registered functions, so we can't call them
574
+ if (typeof valueFormatter !== "function") {
575
+ console.error(
576
+ `downloadCsv: String type (registered) value formatters are unsupported in downloadCsv, colId: ${colDef.colId}`,
577
+ );
578
+ return encodeToString(params.value);
579
+ }
580
+
581
+ const result = valueFormatter({ ...params, data: params.node?.data, colDef } as ValueFormatterParams);
582
+ if (params.value != null && typeof result !== "string") {
583
+ console.error(`downloadCsv: valueFormatter is returning non string values, colDef:", colId: ${colDef.colId}`);
584
+ }
585
+ // We add an extra encodeToString here just in case valueFormatter is returning non string values
586
+ return encodeToString(result);
587
+ };
@@ -10,7 +10,7 @@ import {
10
10
  } from "react";
11
11
 
12
12
  import { useCombinedRef, useMenuChange, useMenuStateAndFocus } from "../hooks";
13
- import { MenuButtonModifiers, RenderProp, RootMenuProps, UncontrolledMenuProps } from "../types";
13
+ import { MenuButtonModifiers, MenuCloseEvent, RenderProp, RootMenuProps, UncontrolledMenuProps } from "../types";
14
14
  import { FocusPositions, Keys, getName, isMenuOpen, mergeProps, safeCall } from "../utils";
15
15
  import { ControlledMenu } from "./ControlledMenu";
16
16
 
@@ -40,7 +40,7 @@ export function MenuFr(
40
40
  const buttonRef = useRef<HTMLButtonElement>(null);
41
41
 
42
42
  const handleClose = useCallback(
43
- (e) => {
43
+ (e: MenuCloseEvent) => {
44
44
  toggleMenu(false);
45
45
  if (e.key) buttonRef.current && buttonRef.current.focus();
46
46
  },
@@ -9,10 +9,15 @@ import {
9
9
  Grid,
10
10
  GridCell,
11
11
  GridContextProvider,
12
+ GridFilterColumnsToggle,
13
+ GridFilterDownloadCsvButton,
14
+ GridFilterQuick,
15
+ GridFilters,
12
16
  GridPopoverEditBearing,
13
17
  GridPopoverEditBearingCorrection,
14
18
  GridProps,
15
19
  GridUpdatingContextProvider,
20
+ GridWrapper,
16
21
  wait,
17
22
  } from "../..";
18
23
  import "../../styles/GridTheme.scss";
@@ -97,16 +102,23 @@ const GridPopoverEditBearingTemplate: ComponentStory<typeof Grid> = (props: Grid
97
102
  ] as ITestRow[]);
98
103
 
99
104
  return (
100
- <Grid
101
- data-testid={"bearingsTestTable"}
102
- {...props}
103
- readOnly={false}
104
- externalSelectedItems={externalSelectedItems}
105
- setExternalSelectedItems={setExternalSelectedItems}
106
- columnDefs={columnDefs}
107
- rowData={rowData}
108
- domLayout={"autoHeight"}
109
- />
105
+ <GridWrapper maxHeight={300}>
106
+ <GridFilters>
107
+ <GridFilterQuick />
108
+ <GridFilterColumnsToggle />
109
+ <GridFilterDownloadCsvButton fileName={"customFilename"} />
110
+ </GridFilters>
111
+ <Grid
112
+ data-testid={"bearingsTestTable"}
113
+ {...props}
114
+ readOnly={false}
115
+ externalSelectedItems={externalSelectedItems}
116
+ setExternalSelectedItems={setExternalSelectedItems}
117
+ columnDefs={columnDefs}
118
+ rowData={rowData}
119
+ domLayout={"autoHeight"}
120
+ />
121
+ </GridWrapper>
110
122
  );
111
123
  };
112
124
 
@@ -9,12 +9,17 @@ import {
9
9
  Grid,
10
10
  GridCell,
11
11
  GridContextProvider,
12
+ GridFilterColumnsToggle,
13
+ GridFilterDownloadCsvButton,
14
+ GridFilterQuick,
15
+ GridFilters,
12
16
  GridFormSubComponentTextArea,
13
17
  GridFormSubComponentTextInput,
14
18
  GridPopoverEditDropDown,
15
19
  GridPopoverMenu,
16
20
  GridProps,
17
21
  GridUpdatingContextProvider,
22
+ GridWrapper,
18
23
  MenuHeaderItem,
19
24
  MenuSeparator,
20
25
  MenuSeparatorString,
@@ -299,14 +304,21 @@ const GridEditDropDownTemplate: ComponentStory<typeof Grid> = (props: GridProps)
299
304
  ] as ITestRow[]);
300
305
 
301
306
  return (
302
- <Grid
303
- {...props}
304
- externalSelectedItems={externalSelectedItems}
305
- setExternalSelectedItems={setExternalSelectedItems}
306
- columnDefs={columnDefs}
307
- rowData={rowData}
308
- domLayout={"autoHeight"}
309
- />
307
+ <GridWrapper maxHeight={300}>
308
+ <GridFilters>
309
+ <GridFilterQuick />
310
+ <GridFilterColumnsToggle />
311
+ <GridFilterDownloadCsvButton fileName={"customFilename"} />
312
+ </GridFilters>
313
+ <Grid
314
+ {...props}
315
+ externalSelectedItems={externalSelectedItems}
316
+ setExternalSelectedItems={setExternalSelectedItems}
317
+ columnDefs={columnDefs}
318
+ rowData={rowData}
319
+ domLayout={"autoHeight"}
320
+ />
321
+ </GridWrapper>
310
322
  );
311
323
  };
312
324
 
@@ -24,6 +24,7 @@ import {
24
24
  wait,
25
25
  } from "../..";
26
26
  import { GridFilterColumnsToggle } from "../../components";
27
+ import { GridFilterDownloadCsvButton } from "../../components/gridFilter/GridFilterDownloadCsvButton";
27
28
  import "../../styles/GridTheme.scss";
28
29
  import "../../styles/index.scss";
29
30
 
@@ -96,6 +97,7 @@ const GridReadOnlyTemplate: ComponentStory<typeof Grid> = (props: GridProps) =>
96
97
  headerName: "Popout message",
97
98
  maxWidth: 150,
98
99
  cellRenderer: () => <>Single Click me!</>,
100
+ exportable: false,
99
101
  },
100
102
  {
101
103
  multiEdit: true,
@@ -214,7 +216,7 @@ const GridReadOnlyTemplate: ComponentStory<typeof Grid> = (props: GridProps) =>
214
216
  return (
215
217
  <GridWrapper maxHeight={300}>
216
218
  <GridFilters>
217
- <GridFilterQuick quickFilterPlaceholder={"Custom placeholder..."} />
219
+ <GridFilterQuick />
218
220
  <GridFilterLessThan text="Age <" field={"age"} />
219
221
  <GridFilterButtons<ITestRow>
220
222
  luiButtonProps={{ style: { whiteSpace: "nowrap" } }}
@@ -229,6 +231,7 @@ const GridReadOnlyTemplate: ComponentStory<typeof Grid> = (props: GridProps) =>
229
231
  ]}
230
232
  />
231
233
  <GridFilterColumnsToggle />
234
+ <GridFilterDownloadCsvButton />
232
235
  </GridFilters>
233
236
  <Grid
234
237
  data-testid={"readonly"}
@@ -263,7 +266,7 @@ const GridFilterLessThan = (props: { field: keyof ITestRow; text: string }): JSX
263
266
  };
264
267
 
265
268
  return (
266
- <div className={"flex-row-center"}>
269
+ <div className={"GridFilter-container flex-row-center"}>
267
270
  <div style={{ whiteSpace: "nowrap" }}>{props.text}</div>
268
271
  &#160;
269
272
  <input type={"text"} defaultValue={value} onChange={(e) => updateValue(e.target.value)} style={{ width: 64 }} />
@@ -27,17 +27,36 @@
27
27
 
28
28
  .Grid-container-filters {
29
29
  width: 100%;
30
- margin-bottom: 16px;
31
30
  flex: 0;
32
31
  display: flex;
33
32
  align-items: center;
34
33
  flex-direction: row;
35
- grid-column-gap: 1em;
36
- padding: 0.25rem 0;
34
+ padding: 16px;
35
+ background-color: colors.$hint;
36
+ border-bottom: 1px solid colors.$silver;
37
+ border-top: 2px solid colors.$lily;
37
38
  }
38
39
 
40
+ .Grid-container-filters button {
41
+ margin-left: 0 !important;
42
+ }
43
+
44
+ %GridFilter-container {
45
+ &:not(:last-child) {
46
+ margin-right: 8px;
47
+ }
48
+ }
49
+
50
+ .GridFilter-container {
51
+ @extend %GridFilter-container;
52
+ }
53
+
54
+ .GridFilterQuick-container {
55
+ @extend %GridFilter-container;
56
+ flex: 1;
57
+ }
39
58
 
40
- .Grid-quickFilterBox {
59
+ .GridFilterQuick-input {
41
60
  width: 100%;
42
61
  height: 40px;
43
62
  border: 0.06rem solid colors.$silver;