From b1003a6640fb99577ccd2256f4d98c473a23e72f Mon Sep 17 00:00:00 2001 From: Richard Phillips Date: Thu, 8 Oct 2026 09:08:07 +0100 Subject: [PATCH 1/4] add components --- CHANGELOG.md | 2 + index.js | 4 + package-lock.json | 4 +- package.json | 2 +- src/components/AutoComplete.js | 111 +++++++ src/components/AutoComplete.stories.js | 127 ++++++++ src/components/__tests__/AutoComplete.test.js | 74 +++++ .../__tests__/useEditableSearchGrid.test.js | 111 +++++++ src/hooks/useEditableSearchGrid.js | 270 ++++++++++++++++++ src/hooks/useEditableSearchGrid.mdx | 155 ++++++++++ 10 files changed, 857 insertions(+), 3 deletions(-) create mode 100644 src/components/AutoComplete.js create mode 100644 src/components/AutoComplete.stories.js create mode 100644 src/components/__tests__/AutoComplete.test.js create mode 100644 src/hooks/__tests__/useEditableSearchGrid.test.js create mode 100644 src/hooks/useEditableSearchGrid.js create mode 100644 src/hooks/useEditableSearchGrid.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index df96cf37..740c4f90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ # Changelog +## [34.5.0] - 2026-10-08 +- Add AutoComplete component (type-ahead select with id/label and object modes) and useEditableSearchGrid hook (shared in-grid search mechanics for a MUI DataGrid), each with tests, docs and a story/mdx. ## [34.4.0] - 2026-10-05 - Fix ReportDataGrid to embolden columns identified by headers.totalColumns, preserving total-row and cell-colour styling. - Update LinkField display to match InputField display more closely and add toolTip parameter diff --git a/index.js b/index.js index 1171dc28..82e1f073 100644 --- a/index.js +++ b/index.js @@ -44,8 +44,11 @@ import usePut from './src/hooks/usePut.js'; import useSignIn from './src/hooks/useSignIn.js'; import useUserProfile from './src/hooks/useUserProfile.js'; import useDelete from './src/hooks/useDelete.js'; +import AutoComplete from './src/components/AutoComplete.js'; +import useEditableSearchGrid from './src/hooks/useEditableSearchGrid.js'; export { AddressUtility, + AutoComplete, BackButton, Breadcrumbs, CheckboxWithLabel, @@ -82,6 +85,7 @@ export { SnackbarMessage, usePreviousNextNavigation, useDebounceValue, + useEditableSearchGrid, useGet, useSearch, useInitialise, diff --git a/package-lock.json b/package-lock.json index a9b66c86..1ae64ecb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@linn-it/linn-form-components-library", - "version": "34.4.0", + "version": "34.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@linn-it/linn-form-components-library", - "version": "34.4.0", + "version": "34.5.0", "dependencies": { "react-dropzone": "15.0.0" }, diff --git a/package.json b/package.json index f90ba12c..442f9b55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@linn-it/linn-form-components-library", - "version": "34.4.0", + "version": "34.5.0", "private": false, "repository": { "type": "git", diff --git a/src/components/AutoComplete.js b/src/components/AutoComplete.js new file mode 100644 index 00000000..779957af --- /dev/null +++ b/src/components/AutoComplete.js @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import Autocomplete from '@mui/material/Autocomplete'; +import InputLabel from '@mui/material/InputLabel'; +import TextField from '@mui/material/TextField'; + +function AutoComplete({ + label, + options = [], + value = null, + onChange, + getOptionLabel, + isOptionEqualToValue, + matchesInput, + idField, + labelField, + required = false, + disabled = false, + disableClearable = false +}) { + const [inputValue, setInputValue] = useState(null); + + // Convenience mode: when idField/labelField are supplied the three option callbacks are derived + // from them, and `value`/`onChange` speak the plain id (not the option object) — so a caller passing + // a coded list just gives the field names and works in ids. Omit them for the original object-mode + // API where the caller supplies the callbacks and the value/onChange are the whole option object. + const resolvedGetOptionLabel = + getOptionLabel ?? + (labelField + ? option => option?.[labelField] ?? (idField ? String(option[idField]) : '') + : option => `${option ?? ''}`); + + const resolvedIsOptionEqualToValue = + isOptionEqualToValue ?? + (idField ? (option, selected) => option?.[idField] === selected?.[idField] : undefined); + + const resolvedMatchesInput = + matchesInput ?? + (idField ? (option, typed) => String(option?.[idField]) === typed : undefined); + + // In id mode, resolve the incoming id to its option object for display and emit the id back out. + const selectedOption = idField + ? (options.find(option => option?.[idField] === value) ?? null) + : value; + const emitChange = idField ? option => onChange(option?.[idField] ?? null) : onChange; + + const optionLabel = option => resolvedGetOptionLabel(option); + + const handleBlur = () => { + const typedValue = inputValue?.trim(); + if (typedValue) { + const matchingOption = options.find( + option => + optionLabel(option).toUpperCase() === typedValue.toUpperCase() || + resolvedMatchesInput?.(option, typedValue) + ); + if (matchingOption) emitChange(matchingOption); + } + setInputValue(null); + }; + + return ( + { + emitChange(option); + setInputValue(null); + }} + onInputChange={(_, newValue, reason) => { + if (reason === 'input') setInputValue(newValue); + if (reason === 'clear') setInputValue(''); + }} + onBlur={handleBlur} + disabled={disabled} + renderInput={params => ( + <> + theme.typography.fontSize, + color: 'inherit', + '& .MuiInputLabel-asterisk': { + color: theme => theme.palette.error.main + } + }} + htmlFor={params.id} + > + {label} + + theme.spacing(1) }} + margin="dense" + size="small" + required={required} + variant="outlined" + /> + + )} + /> + ); +} + +export default AutoComplete; diff --git a/src/components/AutoComplete.stories.js b/src/components/AutoComplete.stories.js new file mode 100644 index 00000000..b7fa0197 --- /dev/null +++ b/src/components/AutoComplete.stories.js @@ -0,0 +1,127 @@ +import { useArgs } from 'storybook/preview-api'; +import AutoComplete from './AutoComplete'; + +const departments = [ + { departmentCode: 2508, description: 'Assets' }, + { departmentCode: 3100, description: 'Marketing' }, + { departmentCode: 4200, description: 'Research & Development' }, + { departmentCode: 5000, description: 'Operations' } +]; + +function StatefulAutoComplete(args) { + const [{ value }, updateArgs] = useArgs(); + return ( + updateArgs({ value: newValue })} + /> + ); +} + +const description = ` +A reusable type-ahead select (wrapper over MUI \`Autocomplete\`). Two modes: **object mode** (you supply +the callbacks and work in option objects) and **id/label mode** (you supply two field names and work in +plain ids). + +UX: type to filter, and **type a value then Tab/blur to commit the match** (matched by option label, +case-insensitive, or by \`matchesInput\` / the \`idField\` value). + +### Id/label mode (give two field names, value is the id) + +Use for a plain coded list. No callbacks, no \`find\` — \`value\`/\`onChange\` speak the id: + +\`\`\`jsx +// departments: [{ departmentCode: 2508, description: 'Assets' }, ...] + +\`\`\` + +The mode derives \`getOptionLabel\`, \`isOptionEqualToValue\`, \`matchesInput\`, and value resolution for +you. You can still override any derived callback by passing it explicitly. + +### Object mode (supply the callbacks, value is the object) + +Use when you need full control — e.g. the label is computed or equality spans more than one field: + +\`\`\`jsx + option.monthName ?? String(option.periodNumber)} + isOptionEqualToValue={(option, value) => option.periodNumber === value.periodNumber} + matchesInput={(period, typed) => String(period.periodNumber) === typed} + onChange={period => setField('startPeriod', period?.periodNumber ?? null)} + required +/> +\`\`\` + +> Rule of thumb: pass \`idField\` + \`labelField\` for a simple coded list and work in ids. Only drop to +> object mode when the label/equality/match logic is non-trivial (computed label, multi-field match). +`; + +export default { + title: 'Components/AutoComplete', + component: AutoComplete, + tags: ['autodocs'], + parameters: { + docs: { description: { component: description } } + }, + render: StatefulAutoComplete, + args: { + label: 'Department', + options: departments, + idField: 'departmentCode', + labelField: 'description', + value: null, + required: false, + disabled: false, + disableClearable: false + } +}; + +export const IdLabelMode = { + name: 'Id/label mode' +}; + +export const Preselected = { + args: { + value: 3100 + } +}; + +export const Required = { + args: { + required: true + } +}; + +export const Disabled = { + args: { + value: 2508, + disabled: true + } +}; + +export const ObjectMode = { + name: 'Object mode', + render: StatefulAutoComplete, + args: { + label: 'Department', + options: departments, + idField: undefined, + labelField: undefined, + value: null, + getOptionLabel: option => option?.description ?? '', + isOptionEqualToValue: (option, selected) => + option.departmentCode === selected?.departmentCode, + matchesInput: (option, typed) => String(option.departmentCode) === typed + } +}; diff --git a/src/components/__tests__/AutoComplete.test.js b/src/components/__tests__/AutoComplete.test.js new file mode 100644 index 00000000..f7d76120 --- /dev/null +++ b/src/components/__tests__/AutoComplete.test.js @@ -0,0 +1,74 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { fireEvent, screen, cleanup } from '@testing-library/react'; +import render from '../../test-utils'; +import AutoComplete from '../AutoComplete'; + +afterEach(cleanup); + +describe('AutoComplete', () => { + const options = [ + { code: 10, name: 'Alpha' }, + { code: 20, name: 'Beta' }, + { code: 30, name: 'Gamma' } + ]; + + describe('id/label convenience mode', () => { + test('shows the labelField text for the plain id value', () => { + render( + {}} + /> + ); + + expect(screen.getByRole('combobox')).toHaveValue('Beta'); + }); + + test('emits the id (not the option object) on change', () => { + const onChange = jest.fn(); + render( + + ); + + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'Gamma' } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith(30); + }); + }); + + describe('object mode (unchanged)', () => { + test('emits the whole option object on change', () => { + const onChange = jest.fn(); + render( + o.name} + isOptionEqualToValue={(o, v) => o.code === v.code} + /> + ); + + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'Alpha' } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith(options[0]); + }); + }); +}); diff --git a/src/hooks/__tests__/useEditableSearchGrid.test.js b/src/hooks/__tests__/useEditableSearchGrid.test.js new file mode 100644 index 00000000..600e2215 --- /dev/null +++ b/src/hooks/__tests__/useEditableSearchGrid.test.js @@ -0,0 +1,111 @@ +import '@testing-library/jest-dom'; +import React, { useState } from 'react'; +import { fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { DataGrid } from '@mui/x-data-grid'; +import render from '../../test-utils'; +import useEditableSearchGrid from '../useEditableSearchGrid'; + +afterEach(cleanup); + +// A representative consumer: it owns its own and just spreads the hook's pieces onto it, +// exactly as a real screen would. +const Host = ({ onRowChange }) => { + const [rows, setRows] = useState([ + { id: 1, departmentCode: '0000002508', nominalCode: '0000009385', amount: 12 } + ]); + const columns = [ + { field: 'departmentCode', headerName: 'Department', type: 'search', width: 120, pad: 10 }, + { field: 'nominalCode', headerName: 'Nominal', type: 'search', width: 120, pad: 10 }, + { field: 'amount', headerName: 'Amount', width: 100 } + ]; + const groupValidations = [ + { + fields: ['departmentCode', 'nominalCode'], + isValid: row => row.nominalAccountExists !== false + } + ]; + const search = useEditableSearchGrid({ rows, setRows, columns, onRowChange, groupValidations }); + + return ( + <> + + {search.searchDialogs} + + ); +}; + +const cell = (container, field) => + container.querySelector(`.MuiDataGrid-cell[data-field="${field}"]`); + +describe('useEditableSearchGrid', () => { + test('decorates search columns and colours a failing group', () => { + const Fail = () => { + const [rows, setRows] = useState([ + { + id: 1, + departmentCode: '0000002508', + nominalCode: '0000009385', + amount: 12, + nominalAccountExists: false + } + ]); + const columns = [ + { field: 'departmentCode', headerName: 'Department', type: 'search', width: 120 }, + { field: 'nominalCode', headerName: 'Nominal', type: 'search', width: 120 }, + { field: 'amount', headerName: 'Amount', width: 100 } + ]; + const search = useEditableSearchGrid({ + rows, + setRows, + columns, + groupValidations: [ + { + fields: ['departmentCode', 'nominalCode'], + isValid: row => row.nominalAccountExists !== false + } + ] + }); + return ( + + ); + }; + + const { container } = render(); + expect(container.querySelectorAll('.invalidCode').length).toBe(2); + }); + + test('commits a typed code on Tab: pads it, calls onRowChange, and moves focus on', async () => { + const onRowChange = jest.fn(); + const { container } = render(); + + fireEvent.doubleClick(cell(container, 'departmentCode')); + const input = cell(container, 'departmentCode').querySelector('input'); + fireEvent.change(input, { target: { value: '7769' } }); + fireEvent.keyDown(input, { key: 'Tab', code: 'Tab', keyCode: 9 }); + + await waitFor(() => expect(onRowChange).toHaveBeenCalled()); + + const [updatedRow, , changedField] = onRowChange.mock.calls[0]; + expect(updatedRow.departmentCode).toBe('0000007769'); + expect(changedField).toBe('departmentCode'); + + await waitFor(() => + expect(cell(container, 'nominalCode')).toHaveAttribute('tabindex', '0') + ); + }); +}); diff --git a/src/hooks/useEditableSearchGrid.js b/src/hooks/useEditableSearchGrid.js new file mode 100644 index 00000000..436e5889 --- /dev/null +++ b/src/hooks/useEditableSearchGrid.js @@ -0,0 +1,270 @@ +import React, { useState } from 'react'; +import { gridExpandedSortedRowIdsSelector, useGridApiRef, GridSearchIcon } from '@mui/x-data-grid'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import Search from '../components/Search.js'; + +// Shared in-grid-search mechanics for a NORMAL MUI DataGrid, distilled from the hand-rolled copies in +// PurchaseLedgerSplitDebitCredit / CashbookTransaction (finance) and SalesOrder (sales). You keep your +// own and spread these onto it: +// +// const search = useEditableSearchGrid({ rows, setRows, columns, onRowChange, groupValidations }); +// +// {search.searchDialogs} +// +// It makes NO API calls and uses NO redux — the consumer fetches (useGet), holds `rows`, does the +// code lookups in `onRowChange`, and supplies each search column's `searchResults`. +// +// A `type:'search'` column is an editable text cell with two ways to fill it: +// - type a code you know and press Tab -> the edit commits in one press (processRowUpdate pads it and +// fires onRowChange) and focus advances; the consumer's onRowChange does the direct lookup. +// - press Enter (or click the cell's search icon) -> a modal opens, pre-seeded with what you +// typed, to find the value by description. +// +// `groupValidations` colours cells: when a group's isValid(row) is false, every field in it gets the +// invalid class (yellow by default — see `gridSx`). + +const DEFAULT_INVALID_CLASS = 'invalidCode'; + +const padCode = (value, width) => { + if (!width || value == null) { + return value; + } + + const text = `${value}`; + return text.length && text.length < width ? text.padStart(width, '0') : value; +}; + +function useEditableSearchGrid({ + rows = [], + setRows, + columns = [], + getRowId = row => row.id, + onRowChange, + groupValidations = [] +}) { + const apiRef = useGridApiRef(); + const [searchDialog, setSearchDialog] = useState({ forRow: null, forColumn: null }); + const [searchTerm, setSearchTerm] = useState(''); + + const searchColumns = columns.filter(c => c.type === 'search'); + + const closeDialog = () => setSearchDialog({ forRow: null, forColumn: null }); + + // The invalid class for a field, if any group validation covering it is currently failing. + const invalidClassForField = (field, row) => { + const failing = groupValidations.find( + g => + g.fields.includes(field) && + typeof g.isValid === 'function' && + g.isValid(row) === false + ); + return failing ? (failing.invalidClassName ?? DEFAULT_INVALID_CLASS) : ''; + }; + + const searchRenderCell = params => ( + <> + { + setSearchTerm(`${params.value ?? ''}`); + setSearchDialog({ forRow: params.id, forColumn: params.field }); + }} + /> + {params.value} + + ); + + // Decorate each column: search columns get the icon renderer; every column's cellClassName merges + // the caller's own class with the group-validation (invalid) class. + const decoratedColumns = columns.map(c => { + const withRenderer = + c.type === 'search' + ? { + ...c, + editable: c.editable ?? true, + renderCell: c.renderCell ?? searchRenderCell + } + : c; + + return { + ...withRenderer, + cellClassName: params => { + const own = + typeof c.cellClassName === 'function' + ? c.cellClassName(params) + : (c.cellClassName ?? ''); + return [own, invalidClassForField(c.field, params.row)].filter(Boolean).join(' '); + } + }; + }); + + const handleCellKeyDown = (params, event) => { + // Enter on a search cell opens the dialog, pre-seeded with whatever has been typed. + if (event.keyCode === 13 && params.colDef.type === 'search') { + const inputEl = apiRef.current + .getCellElement(params.id, params.field) + ?.querySelector('input'); + setSearchTerm(`${inputEl?.value ?? params.value ?? ''}`); + setSearchDialog({ forRow: params.id, forColumn: params.field }); + apiRef.current.stopCellEditMode({ + id: params.id, + field: params.field, + ignoreModifications: true + }); + event.preventDefault(); + return; + } + + if (event.key !== 'Tab') { + return; + } + + // Forms-style Tab traversal (from SalesOrder): move to the next/previous cell, wrapping rows. + const rowIds = gridExpandedSortedRowIdsSelector(apiRef.current.state); + const visibleColumns = apiRef.current.getVisibleColumns(); + const current = { + rowIndex: rowIds.findIndex(id => id === params.id), + colIndex: apiRef.current.getColumnIndex(params.field) + }; + + const atLastCell = + current.colIndex === visibleColumns.length - 1 && + current.rowIndex === rowIds.length - 1 && + !event.shiftKey; + const atFirstCell = current.colIndex === 0 && current.rowIndex === 0 && event.shiftKey; + if (atLastCell || atFirstCell) { + return; + } + + event.preventDefault(); + + const next = { ...current }; + if (!event.shiftKey) { + if (next.colIndex < visibleColumns.length - 1) { + next.colIndex += 1; + } else { + next.rowIndex += 1; + next.colIndex = 0; + } + } else if (next.colIndex > 0) { + next.colIndex -= 1; + } else { + next.rowIndex -= 1; + next.colIndex = visibleColumns.length - 1; + } + + // Commit the edit before leaving (this is what makes type-a-code-then-Tab work in one press, + // rather than needing an extra Enter first). + if (apiRef.current.getCellMode(params.id, params.field) === 'edit') { + apiRef.current.stopCellEditMode({ id: params.id, field: params.field }); + } + + apiRef.current.scrollToIndexes(next); + apiRef.current.setCellFocus(rowIds[next.rowIndex], visibleColumns[next.colIndex].field); + }; + + // Pad every search column's code to its declared width. Exposed so a consumer that needs its own + // processRowUpdate (extra per-row logic) can still reuse the padding. + const padSearchCodes = row => { + const updated = { ...row }; + searchColumns.forEach(c => { + updated[c.field] = padCode(updated[c.field], c.pad); + }); + return updated; + }; + + const processRowUpdate = (newRow, oldRow) => { + let changedField; + const updated = { ...newRow }; + + searchColumns.forEach(c => { + if (updated[c.field] !== oldRow?.[c.field]) { + changedField = c.field; + updated[c.field] = padCode(updated[c.field], c.pad); + } + }); + + setRows(current => current.map(r => (getRowId(r) === getRowId(updated) ? updated : r))); + onRowChange?.(updated, oldRow, changedField); + return updated; + }; + + const writeResultToRow = (column, selected) => { + const rowId = searchDialog.forRow; + const currentRow = rows.find(r => getRowId(r) === rowId); + const code = selected[column.resultIdField ?? 'id'] ?? selected.id; + let updated = { ...currentRow, [column.field]: padCode(code, column.pad) }; + column.updateFields?.forEach(f => { + updated = { ...updated, [f.field]: selected[f.from] }; + }); + + setRows(current => current.map(r => (getRowId(r) === rowId ? updated : r))); + onRowChange?.(updated, currentRow, column.field); + closeDialog(); + }; + + const searchDialogs = ( + <> + {searchColumns.map(c => ( + + Search {c.headerName} + + setSearchTerm(newValue)} + search={c.search} + searchResults={c.searchResults?.map(r => ({ + ...r, + id: r[c.resultIdField ?? c.field] ?? r.id + }))} + loading={c.searchLoading} + priorityFunction="closestMatchesFirst" + onResultSelect={selected => writeResultToRow(c, selected)} + clearSearch={() => setSearchTerm('')} + /> + + + + + + ))} + + ); + + // Spread onto the DataGrid's own sx so the default invalid class renders yellow. + const gridSx = { + [`& .${DEFAULT_INVALID_CLASS}`]: { color: 'black', backgroundColor: 'yellow' } + }; + + return { + apiRef, + columns: decoratedColumns, + handleCellKeyDown, + processRowUpdate, + padSearchCodes, + searchDialogs, + gridSx + }; +} + +export default useEditableSearchGrid; diff --git a/src/hooks/useEditableSearchGrid.mdx b/src/hooks/useEditableSearchGrid.mdx new file mode 100644 index 00000000..200574ec --- /dev/null +++ b/src/hooks/useEditableSearchGrid.mdx @@ -0,0 +1,155 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# useEditableSearchGrid + +A hook that supplies the shared **in-grid search** mechanics for a **normal MUI `DataGrid`** — distilled +from the hand-rolled copies in `PurchaseLedgerSplitDebitCredit` / `CashbookTransaction` (finance) and +`SalesOrder` (sales). You keep your own `` and spread the hook's pieces onto it; the hook does +**not** render a grid and is not a wrapper. + +**It makes no API calls and uses no redux.** The consumer fetches (with `useGet`), holds the `rows` in +state, does the code lookups in `onRowChange`, and supplies each search column's `searchResults`. + +--- + +## How a `search` column is filled + +A column with `type: 'search'` is an editable text cell with two ways to fill it: + +1. **Type a code you know, then Tab** — the edit commits in one press, the value is zero-padded (if + `pad` is set), focus advances, and `onRowChange(row, oldRow, changedField)` fires so you can do the + direct lookup. +2. **Press Enter (or click the cell's search icon)** — a modal `` opens, pre-seeded with what + you typed, to find the value by description. Picking a result writes it to the row (plus any + `updateFields`) and fires `onRowChange`. + +Tab / Shift-Tab traverse cells forms-style, wrapping across rows. + +--- + +## Usage + +```jsx +import { DataGrid } from '@mui/x-data-grid'; +import { useEditableSearchGrid } from '@linn-it/linn-form-components-library'; + +function MyLines() { + const [rows, setRows] = useState(initialRows); + + const columns = [ + { field: 'departmentCode', headerName: 'Department', type: 'search', width: 140, pad: 10, + search: term => getDepartments(null, `?searchTerm=${encodeURIComponent(term.trim())}`), + searchResults: departmentsResult ?? [], + searchLoading, + resultIdField: 'departmentCode', + updateFields: [{ field: 'departmentName', from: 'description' }] }, + { field: 'departmentName', headerName: 'Name', width: 220 }, + { field: 'amount', headerName: 'Amount', type: 'number', width: 100, editable: true } + ]; + + const search = useEditableSearchGrid({ + rows, + setRows, + columns, + onRowChange: (row, oldRow, changedField) => { + if (changedField === 'departmentCode' && row.departmentCode) { + getDepartmentById(row.departmentCode); // map the result back onto the row in an effect + } + }, + groupValidations: [ + { fields: ['departmentCode', 'nominalCode'], isValid: r => r.nominalAccountExists !== false } + ] + }); + + return ( + <> + + {search.searchDialogs} + + ); +} +``` + +Because it's your own ``, everything else — custom cell renderers, `preProcessEditCellProps`, +`valueFormatter`, a delete column, footers, `loading`, etc. — is just normal grid config. + +--- + +## Hook input + +```js +useEditableSearchGrid({ rows, setRows, columns, getRowId, onRowChange, groupValidations }) +``` + + + + + + + + + + + + + +
FieldTypeDefaultNotes
rowsarray[]Your row state.
setRowsupdater => void—Your row state setter; the hook calls it (functional updater) on every commit.
columnsarray[]Your MUI columns, plus the search extensions below.
getRowIdrow => idrow => row.idRow id accessor (match your DataGrid's).
onRowChange(updatedRow, oldRow, changedField) => void—Fires after any search-cell commit (typed-and-Tab or modal select). Do lookups / validation here.
groupValidationsarray[][{'{ fields, isValid, invalidClassName? }'}] — colours every field in a group when isValid(row) is false.
+ +### `search` column extensions + + + + + + + + + + + + + + + + +
FieldTypeNotes
type: 'search'—Editable lookup cell; editable defaults to true.
padnumberZero-pad the committed code to this width (7769 → 0000007769).
searchterm => voidRuns the modal search — trigger the parent's fetch.
searchResultsarrayResults for the modal list.
searchLoadingboolModal loading state.
resultIdFieldstringWhich result field is the code. Defaults to the column field, then id.
updateFields[{'{ field, from }'}]After a modal select, copy result[from] → row[field].
renderCellfnOverride the default (icon + value) renderer.
cellClassNamestring | fnYour own class; merged with the group-validation class.
+ +## Hook output + + + + + + + + + + + + + + +
ReturnedSpread onto / useNotes
apiRef<DataGrid apiRef={'{...}'} />Shared with you — use it for your own grid driving (e.g. startCellEditMode to auto-advance, a stale-response guard).
columns<DataGrid columns={'{...}'} />Your columns decorated with the search icon + validation class.
handleCellKeyDown<DataGrid onCellKeyDown={'{...}'} />Tab traversal + commit-on-Tab + Enter→dialog.
processRowUpdate<DataGrid processRowUpdate={'{...}'} />Pads the changed code, calls setRows, fires onRowChange, returns the row.
padSearchCodes—row => paddedRow. Reuse the padding if you write your own processRowUpdate.
searchDialogsrender {'{search.searchDialogs}'}The modal <Search> dialogs (one per search column).
gridSx<DataGrid sx={'{{ ...search.gridSx, ...yours }}'} />Defines the default invalidCode colour (yellow).
+ +--- + +## Notes + +- **No silent persistence** — the hook updates rows via your `setRows`; wire it or edits won't stick. +- **Padding** — only on `search` columns that declare `pad`, and only the field that changed. +- **Custom row logic** — keep your own `processRowUpdate` and either compose the hook's, or build one + with `padSearchCodes`; use the shared `apiRef` for things like auto-advancing to the next field. +- **Lookups and stale-response guards are the consumer's job** (domain- and hook-specific) — do them in + `onRowChange` with `useGet`. From f1cfdc93a1b04430f6efd652e2e2d61eeb3e1b5f Mon Sep 17 00:00:00 2001 From: Richard Phillips Date: Thu, 8 Oct 2026 12:13:22 +0100 Subject: [PATCH 2/4] fb --- src/components/AutoComplete.js | 1 - .../__tests__/useEditableSearchGrid.test.js | 65 ++++++++++++++++++- src/hooks/useEditableSearchGrid.js | 41 +++++++++--- 3 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/components/AutoComplete.js b/src/components/AutoComplete.js index 779957af..27cc6bf6 100644 --- a/src/components/AutoComplete.js +++ b/src/components/AutoComplete.js @@ -62,7 +62,6 @@ function AutoComplete({ { expect(cell(container, 'nominalCode')).toHaveAttribute('tabindex', '0') ); }); + + // Regression: Enter must open the dialog WITHOUT the grid starting edit mode on the cell behind it. + // If it did, picking a result wrote the code to the row but the stale empty editor committed on the + // next Tab and wiped it back out (the "name filled, code blank" bug). + test('Enter opens the search dialog and picking a result fills the code without wiping it', async () => { + const onRowChange = jest.fn(); + const SearchHost = () => { + const [rows, setRows] = useState([ + { id: 1, departmentCode: '', departmentName: '', amount: 0 } + ]); + const columns = [ + { + field: 'departmentCode', + headerName: 'Department', + type: 'search', + width: 140, + pad: 10, + search: () => {}, + searchResults: [{ departmentCode: '0000007769', description: 'ASSEMBLY' }], + resultIdField: 'departmentCode', + updateFields: [{ field: 'departmentName', from: 'description' }] + }, + { field: 'departmentName', headerName: 'Name', width: 140 }, + { field: 'amount', headerName: 'Amount', width: 100 } + ]; + const search = useEditableSearchGrid({ rows, setRows, columns, onRowChange }); + return ( + <> + + {search.searchDialogs} + + ); + }; + + const { container } = render(); + const deptCell = cell(container, 'departmentCode'); + fireEvent.click(deptCell); + fireEvent.keyDown(deptCell, { key: 'Enter', code: 'Enter', keyCode: 13 }); + + // The cell must NOT have gone into edit mode behind the dialog (no editor input in the cell). + expect(cell(container, 'departmentCode').querySelector('input')).toBeNull(); + + // Run the search (Enter in the box reveals the results), then pick the result; the code is + // written to the row and reported to the consumer. + const dialog = await screen.findByRole('dialog'); + const searchBox = within(dialog).getByRole('textbox'); + fireEvent.change(searchBox, { target: { value: 'ass' } }); + fireEvent.keyDown(searchBox, { key: 'Enter', code: 'Enter', keyCode: 13 }); + fireEvent.click(await screen.findByText('ASSEMBLY')); + + await waitFor(() => expect(onRowChange).toHaveBeenCalled()); + const [updatedRow, , changedField] = onRowChange.mock.calls.at(-1); + expect(updatedRow.departmentCode).toBe('0000007769'); + expect(updatedRow.departmentName).toBe('ASSEMBLY'); + expect(changedField).toBe('departmentCode'); + }); }); diff --git a/src/hooks/useEditableSearchGrid.js b/src/hooks/useEditableSearchGrid.js index 436e5889..b2a756eb 100644 --- a/src/hooks/useEditableSearchGrid.js +++ b/src/hooks/useEditableSearchGrid.js @@ -117,11 +117,21 @@ function useEditableSearchGrid({ ?.querySelector('input'); setSearchTerm(`${inputEl?.value ?? params.value ?? ''}`); setSearchDialog({ forRow: params.id, forColumn: params.field }); - apiRef.current.stopCellEditMode({ - id: params.id, - field: params.field, - ignoreModifications: true - }); + // onCellKeyDown fires before MUI enters edit mode, so a freshly focused (view-mode) cell is + // not editing yet - stopCellEditMode throws if called on a non-editing cell. Guard it like + // the Tab branch does. + if (apiRef.current.getCellMode(params.id, params.field) === 'edit') { + apiRef.current.stopCellEditMode({ + id: params.id, + field: params.field, + ignoreModifications: true + }); + } + // defaultMuiPrevented (NOT preventDefault) is what stops the grid's own Enter handler from + // starting edit mode on this cell behind the dialog. Without it MUI opens an empty editor + // underneath; selecting a result writes the code to the row, but committing that stale empty + // editor on the next Tab wipes the code back out (the "name filled, code blank" bug). + event.defaultMuiPrevented = true; event.preventDefault(); return; } @@ -212,6 +222,14 @@ function useEditableSearchGrid({ setRows(current => current.map(r => (getRowId(r) === rowId ? updated : r))); onRowChange?.(updated, currentRow, column.field); closeDialog(); + + // Return focus to the cell the search was launched from, so Tab carries on to the next cell in + // the row. Without this the closing dialog drops focus to the top of the page. Deferred to the + // next tick so it runs after the dialog has finished releasing focus on unmount. + const api = apiRef.current; + if (api) { + setTimeout(() => api.setCellFocus(rowId, column.field), 0); + } }; const searchDialogs = ( @@ -229,14 +247,17 @@ function useEditableSearchGrid({ resultsInModal resultLimit={100} propertyName={`${c.field}-search`} - label="" + label={c.headerName} value={searchTerm} handleValueChange={(_, newValue) => setSearchTerm(newValue)} search={c.search} - searchResults={c.searchResults?.map(r => ({ - ...r, - id: r[c.resultIdField ?? c.field] ?? r.id - }))} + searchResults={c.searchResults?.map(r => { + // Search ranks/renders via item.name (name.toUpperCase()), so a result + // that only carries a code (e.g. { departmentCode, description }) must + // still get a string name or ranking a non-empty search throws. + const id = r[c.resultIdField ?? c.field] ?? r.id; + return { ...r, id, name: r.name ?? `${id ?? ''}` }; + })} loading={c.searchLoading} priorityFunction="closestMatchesFirst" onResultSelect={selected => writeResultToRow(c, selected)} From 94c6bc7071bb0603c76dff0f6e18538edbe2375a Mon Sep 17 00:00:00 2001 From: Richard Phillips Date: Thu, 8 Oct 2026 13:19:24 +0100 Subject: [PATCH 3/4] make tab nicer --- .../__tests__/useEditableSearchGrid.test.js | 5 ++ src/hooks/useEditableSearchGrid.js | 47 +++++++++++++++---- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/hooks/__tests__/useEditableSearchGrid.test.js b/src/hooks/__tests__/useEditableSearchGrid.test.js index ca6f81d1..903c9d31 100644 --- a/src/hooks/__tests__/useEditableSearchGrid.test.js +++ b/src/hooks/__tests__/useEditableSearchGrid.test.js @@ -170,5 +170,10 @@ describe('useEditableSearchGrid', () => { expect(updatedRow.departmentCode).toBe('0000007769'); expect(updatedRow.departmentName).toBe('ASSEMBLY'); expect(changedField).toBe('departmentCode'); + + // Once the dialog has fully closed, focus returns to the originating cell (via the dialog's + // transition onExited) so Tab continues to the next cell in the row rather than the page + // dropping focus to the top. + await waitFor(() => expect(cell(container, 'departmentCode')).toHaveFocus()); }); }); diff --git a/src/hooks/useEditableSearchGrid.js b/src/hooks/useEditableSearchGrid.js index b2a756eb..8bd6abb2 100644 --- a/src/hooks/useEditableSearchGrid.js +++ b/src/hooks/useEditableSearchGrid.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { gridExpandedSortedRowIdsSelector, useGridApiRef, GridSearchIcon } from '@mui/x-data-grid'; import Dialog from '@mui/material/Dialog'; import DialogTitle from '@mui/material/DialogTitle'; @@ -56,6 +56,8 @@ function useEditableSearchGrid({ const apiRef = useGridApiRef(); const [searchDialog, setSearchDialog] = useState({ forRow: null, forColumn: null }); const [searchTerm, setSearchTerm] = useState(''); + // The cell to refocus once the search dialog has fully closed (set when a result is picked). + const pendingFocusRef = useRef(null); const searchColumns = columns.filter(c => c.type === 'search'); @@ -221,15 +223,13 @@ function useEditableSearchGrid({ setRows(current => current.map(r => (getRowId(r) === rowId ? updated : r))); onRowChange?.(updated, currentRow, column.field); - closeDialog(); - // Return focus to the cell the search was launched from, so Tab carries on to the next cell in - // the row. Without this the closing dialog drops focus to the top of the page. Deferred to the - // next tick so it runs after the dialog has finished releasing focus on unmount. - const api = apiRef.current; - if (api) { - setTimeout(() => api.setCellFocus(rowId, column.field), 0); - } + // Refocus the originating cell once the dialog has fully closed (handled in the dialog's + // transition onExited), so Tab carries on to the next cell in the row instead of the page + // dropping focus to the top. Restoring on exit rather than now avoids racing the dialog's own + // focus handling. + pendingFocusRef.current = { rowId, field: column.field }; + closeDialog(); }; const searchDialogs = ( @@ -239,6 +239,35 @@ function useEditableSearchGrid({ key={c.field} open={searchDialog.forColumn === c.field} onClose={closeDialog} + slotProps={{ + // The transition's onExited fires after the dialog has fully closed (and after + // MUI has done its own focus restoration), so this is the point to put focus back + // on the grid cell - Tab then carries on to the next cell in the row instead of + // the page dropping focus to the top. NB: in MUI v6+ this lives under + // slotProps.transition; the old top-level TransitionProps is ignored. + transition: { + onExited: () => { + const pending = pendingFocusRef.current; + pendingFocusRef.current = null; + const api = apiRef.current; + if (!pending || !api) { + return; + } + // Sync the grid's own focus/roving-tabindex state... + api.setCellFocus(pending.rowId, pending.field); + // ...but setCellFocus refuses to move DOM focus while the active element + // is still inside a portal (MUI guards against stealing focus from a + // dialog), and our two closing modals leave focus astray - so move DOM + // focus onto the cell element directly too. Tab then resumes from the cell. + // Repeat on the next frame in case a closing modal reclaims focus just + // after onExited. + const focusCell = () => + api.getCellElement(pending.rowId, pending.field)?.focus(); + focusCell(); + requestAnimationFrame(focusCell); + } + } + }} > Search {c.headerName} From 9f1b404920d2b85e9a5bd81ff4858c4efafb506b Mon Sep 17 00:00:00 2001 From: Richard Phillips Date: Thu, 8 Oct 2026 13:54:58 +0100 Subject: [PATCH 4/4] bump moment --- package-lock.json | 8 ++++---- package.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ae64ecb..88215d92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "jest": "30.5.1", "jest-environment-jsdom": "30.5.1", "jscodeshift": "17.4.0", - "moment": "2.30.1", + "moment": "2.31.0", "notistack": "3.0.2", "postcss": "8.5.27", "postcss-import": "17.0.0", @@ -13595,9 +13595,9 @@ } }, "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.31.0.tgz", + "integrity": "sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 442f9b55..ddf5037f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "react-dropzone": "15.0.0" }, "overrides": { - "fast-uri": "^3.1.7" + "fast-uri": "^3.1.8" }, "scripts": { "start": "react-scripts start", @@ -95,7 +95,7 @@ "jest": "30.5.1", "jest-environment-jsdom": "30.5.1", "jscodeshift": "17.4.0", - "moment": "2.30.1", + "moment": "2.31.0", "notistack": "3.0.2", "postcss": "8.5.27", "postcss-import": "17.0.0",