@linzjs/step-ag-grid 32.0.0 → 32.1.1

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": "32.0.0",
5
+ "version": "32.1.1",
6
6
  "keywords": [
7
7
  "aggrid",
8
8
  "ag-grid",
@@ -44,7 +44,7 @@ import { useGridCopy } from './gridHook/useGridCopy';
44
44
  import { CellLocation, useGridRangeSelection } from './gridHook/useGridRangeSelection';
45
45
  import { GridNoRowsOverlay } from './GridNoRowsOverlay';
46
46
  import { usePostSortRowsHook } from './PostSortRowsHook';
47
- import { GridBaseRow, GridOnRowDragEndProps } from './types';
47
+ import { ColDefT, GridBaseRow, GridOnRowDragEndProps } from './types';
48
48
 
49
49
  ModuleRegistry.registerModules([AllCommunityModule]);
50
50
 
@@ -471,13 +471,16 @@ export const Grid = <TData extends GridBaseRow = GridBaseRow>({
471
471
  };
472
472
  } else {
473
473
  const colDefEditable = colDef.editable;
474
+ const ignoreGridReadOnly = (colDef as ColDefT<TData>).ignoreGridReadOnly === true;
474
475
  const editable = combineEditables(
475
- params.loading !== true && params.readOnly !== true,
476
+ params.loading !== true,
477
+ ignoreGridReadOnly ? true : params.readOnly !== true,
476
478
  params.defaultColDef?.editable,
477
479
  colDefEditable,
478
480
  );
479
481
  return {
480
- ...colDef,
482
+ // ag-grid warns on unrecognised colDef properties, so it can't be left on the returned colDef
483
+ ...omit(colDef, 'ignoreGridReadOnly'),
481
484
  editable,
482
485
  cellClassRules: {
483
486
  ...colDef.cellClassRules,
@@ -1,4 +1,3 @@
1
- import { isEmpty } from 'lodash-es';
2
1
  import { Fragment, ReactElement, useCallback, useEffect, useRef, useState } from 'react';
3
2
 
4
3
  import { useGridPopoverContext } from '../../contexts/GridPopoverContext';
@@ -126,10 +125,15 @@ export const GridFormPopoverMenu = <TData extends GridBaseRow>(props: GridFormPo
126
125
  save,
127
126
  });
128
127
 
128
+ // isEmpty(options) alone isn't enough: options can be a non-empty array where every item is
129
+ // hidden (e.g. all actions unavailable in a given mode), which must also show "No actions"
130
+ // rather than rendering an empty menu body.
131
+ const hasVisibleOption = options?.some((item) => !item.hidden && item.label !== __isMenuSeparator__);
132
+
129
133
  return popoverWrapper(
130
134
  <ComponentLoadingWrapper loading={!options} className={'GridFormPopupMenu'}>
131
135
  <>
132
- {isEmpty(options) ? (
136
+ {!hasVisibleOption ? (
133
137
  <MenuItem key={`GridPopoverMenu-empty`} className={'GridPopoverMenu-noOptions'} disabled={true}>
134
138
  No actions
135
139
  </MenuItem>
@@ -75,9 +75,16 @@ const BooleanCellRenderer = (props: CustomCellEditorProps) => {
75
75
  if (isDisabled) {
76
76
  return;
77
77
  }
78
- const cell: HTMLElement | null = eGridCell?.querySelector('.ag-cell-focus .grid-edit-boolean input.ag-checkbox-input:not(:disabled)');
79
- if (cell && cell.ownerDocument.activeElement !== cell) {
80
- cell.focus();
78
+ const cell: HTMLElement | null = eGridCell?.querySelector(
79
+ '.ag-cell-focus .grid-edit-boolean input.ag-checkbox-input:not(:disabled)',
80
+ );
81
+ if (cell) {
82
+ // When you redraw a cell the activeElement moves to the cell div away from input
83
+ // Refocus here if the cell div is active back to the input
84
+ const activeElement = cell.ownerDocument.activeElement;
85
+ if (activeElement && activeElement.getAttribute('role') === 'gridcell' && activeElement !== cell) {
86
+ cell.focus();
87
+ }
81
88
  }
82
89
  });
83
90
 
@@ -20,6 +20,9 @@ export const GridPopoverMenu = <TData extends GridBaseRow>(
20
20
  editable: colDef.editable != null ? colDef.editable : true,
21
21
  exportable: false,
22
22
  cellStyle: { flex: 1, justifyContent: 'center' },
23
+ suppressMovable: true,
24
+ lockPosition: 'right',
25
+ resizable: false,
23
26
  cellRenderer: GridRenderPopoutMenuCell,
24
27
  // Menus open on single click, this parameter is picked up in Grid.tsx
25
28
  singleClickEdit: true,
@@ -20,6 +20,8 @@ export interface GridOnRowDragEndProps<TData extends GridBaseRow> {
20
20
  // This is so that typescript retains the row type to pass to the GridCells
21
21
  export interface ColDefT<TData extends GridBaseRow, ValueType = any> extends ColDef<TData, ValueType> {
22
22
  editable?: boolean | EditableCallback<TData, ValueType>;
23
+ /** When true, this column's editable state ignores the grid-level `readOnly` prop. */
24
+ ignoreGridReadOnly?: boolean;
23
25
  valueGetter?: string | ValueGetterFunc<TData, ValueType>;
24
26
  valueFormatter?: string | ValueFormatterFunc<TData, ValueType>;
25
27
  cellRenderer?:
@@ -18,6 +18,8 @@ import {
18
18
  primitiveToSelectOption,
19
19
  wait,
20
20
  } from '../..';
21
+ import { expect, userEvent, waitFor } from 'storybook/test';
22
+
21
23
  import { waitForGridReady } from '../../utils/__tests__/storybookTestUtil';
22
24
  import { IFormTestRow } from './FormTest';
23
25
 
@@ -130,3 +132,23 @@ const GridPopoutEditBooleanTemplate: StoryFn<typeof Grid> = () => {
130
132
 
131
133
  export const _EditBoolean = GridPopoutEditBooleanTemplate.bind({});
132
134
  _EditBoolean.play = waitForGridReady;
135
+
136
+ export const _EditBooleanRetainsFocusAfterToggle = GridPopoutEditBooleanTemplate.bind({});
137
+ _EditBooleanRetainsFocusAfterToggle.play = async (context) => {
138
+ await waitForGridReady(context);
139
+ const { canvasElement } = context;
140
+
141
+ // Use bold2 (no async delay) so the redraw happens immediately
142
+ const getBoolInput = () =>
143
+ canvasElement.querySelector('.ag-cell[col-id="bold2"] input.ag-checkbox-input') as HTMLElement;
144
+
145
+ await userEvent.click(canvasElement.querySelector('.ag-cell[col-id="bold2"]') as HTMLElement);
146
+ await waitFor(() => expect(getBoolInput()).toHaveFocus(), { timeout: 500 });
147
+
148
+ // Space toggles the checkbox, which triggers redrawRows — ag-grid moves focus from the
149
+ // input to the cell div (role="gridcell"). The shared interval should detect this and restore focus.
150
+ await userEvent.keyboard(' ');
151
+
152
+ // Re-query the input each retry since redraw replaces the DOM node
153
+ await waitFor(() => expect(getBoolInput()).toHaveFocus(), { timeout: 500 });
154
+ };
@@ -5,6 +5,7 @@ import '@linzjs/lui/dist/fonts';
5
5
 
6
6
  import { Meta, StoryFn } from '@storybook/react-vite';
7
7
  import { ReactElement, useCallback, useMemo, useState } from 'react';
8
+ import { expect, fn, screen, userEvent } from 'storybook/test';
8
9
 
9
10
  import {
10
11
  ColDefT,
@@ -294,3 +295,71 @@ const GridFilterLessThan = (props: {
294
295
 
295
296
  export const ReadOnlySingleSelection = GridReadOnlyTemplate.bind({});
296
297
  ReadOnlySingleSelection.play = waitForGridReady;
298
+
299
+ interface IIgnoreReadOnlyRow {
300
+ id: number;
301
+ name: string;
302
+ }
303
+
304
+ const disabledMenuItemAction = fn();
305
+
306
+ const IgnoreGridReadOnlyTemplate: StoryFn<typeof Grid<IIgnoreReadOnlyRow>> = () => {
307
+ const columnDefs: ColDefT<IIgnoreReadOnlyRow>[] = useMemo(
308
+ () => [
309
+ GridCell<IIgnoreReadOnlyRow, IIgnoreReadOnlyRow['name']>({ field: 'name', headerName: 'Name' }),
310
+ GridPopoverMenu(
311
+ { colId: 'blockedActions', headerName: 'Blocked actions' },
312
+ {
313
+ multiEdit: true,
314
+ editorParams: {
315
+ options: async () => [{ label: 'Should not open', action: async () => {} }],
316
+ },
317
+ },
318
+ ),
319
+ GridPopoverMenu(
320
+ { colId: 'alwaysEnabledActions', headerName: 'Always enabled actions', ignoreGridReadOnly: true },
321
+ {
322
+ multiEdit: true,
323
+ editorParams: {
324
+ options: async () => [
325
+ { label: 'Locate feature', action: async () => {} },
326
+ { label: 'Other action', action: disabledMenuItemAction, disabled: 'Not available in read-only mode' },
327
+ ],
328
+ },
329
+ },
330
+ ),
331
+ ],
332
+ [],
333
+ );
334
+
335
+ const [rowData] = useState<IIgnoreReadOnlyRow[]>([{ id: 1, name: 'Row 1' }]);
336
+
337
+ return (
338
+ <GridWrapper maxHeight={200}>
339
+ <Grid data-testid={'readonly-ignore-flag'} readOnly columnDefs={columnDefs} rowData={rowData} />
340
+ </GridWrapper>
341
+ );
342
+ };
343
+
344
+ export const ReadOnlyIgnoreGridReadOnlyColumn = IgnoreGridReadOnlyTemplate.bind({});
345
+ ReadOnlyIgnoreGridReadOnlyColumn.play = async (context) => {
346
+ await waitForGridReady(context);
347
+ const { canvasElement } = context;
348
+
349
+ const alwaysEnabledCell = canvasElement.querySelector('.ag-cell[col-id="alwaysEnabledActions"]');
350
+ await expect(alwaysEnabledCell).toBeInTheDocument();
351
+ await userEvent.click(alwaysEnabledCell as HTMLElement);
352
+ await expect(await screen.findByRole('menuitem', { name: 'Locate feature' })).toBeInTheDocument();
353
+
354
+ const disabledItem = await screen.findByRole('menuitem', { name: 'Other action' });
355
+ await expect(disabledItem).toHaveAttribute('aria-disabled', 'true');
356
+ await userEvent.click(disabledItem);
357
+ await expect(disabledMenuItemAction).not.toHaveBeenCalled();
358
+
359
+ await userEvent.keyboard('{Escape}');
360
+
361
+ const blockedCell = canvasElement.querySelector('.ag-cell[col-id="blockedActions"]');
362
+ await expect(blockedCell).toBeInTheDocument();
363
+ await userEvent.click(blockedCell as HTMLElement);
364
+ await expect(screen.queryByRole('menuitem', { name: 'Should not open' })).not.toBeInTheDocument();
365
+ };
@@ -171,3 +171,50 @@ GridFormPopoverMenuInteractions_.play = async ({ canvasElement }) => {
171
171
  await userEvent.type(textArea, '{Enter}');
172
172
  expect(updateValue).not.toHaveBeenCalled();
173
173
  };
174
+
175
+ const AllHiddenTemplate: StoryFn<typeof GridFormPopoverMenu> = (props: GridFormPopoverMenuProps<any>) => {
176
+ const anchorRef = useRef<HTMLHeadingElement>(null);
177
+
178
+ return (
179
+ <div className={'react-menu-inline-test'}>
180
+ <GridContextProvider>
181
+ <h6 ref={anchorRef}>Interaction Test</h6>
182
+ <GridPopoverContext.Provider
183
+ value={{
184
+ anchorRef,
185
+ value: null,
186
+ updateValue,
187
+ data: { value: '' },
188
+ colId: '',
189
+ field: 'value',
190
+ selectedRows: [],
191
+ saving: false,
192
+ setSaving: () => {},
193
+ formatValue: (value) => value,
194
+ stopEditing: () => {},
195
+ }}
196
+ >
197
+ <GridFormPopoverMenu
198
+ {...props}
199
+ options={() => [
200
+ { label: 'Hidden one', value: 1, action: enabledAction, hidden: true },
201
+ { label: 'Hidden two', value: 2, action: enabledAction, hidden: true },
202
+ ]}
203
+ />
204
+ </GridPopoverContext.Provider>
205
+ </GridContextProvider>
206
+ </div>
207
+ );
208
+ };
209
+
210
+ // Regression test: when every option resolves to hidden, the menu must show "No actions"
211
+ // rather than an empty menu body (isEmpty(options) alone doesn't catch this — the array is
212
+ // non-empty, just every item is hidden).
213
+ export const GridFormPopoverMenuAllOptionsHidden_ = AllHiddenTemplate.bind({});
214
+ GridFormPopoverMenuAllOptionsHidden_.play = async ({ canvasElement }) => {
215
+ const canvas = within(canvasElement);
216
+
217
+ expect(await canvas.findByText('No actions')).toBeInTheDocument();
218
+ expect(canvas.queryByRole('menuitem', { name: 'Hidden one' })).not.toBeInTheDocument();
219
+ expect(canvas.queryByRole('menuitem', { name: 'Hidden two' })).not.toBeInTheDocument();
220
+ };