From 4dc1e8ead0502726142e0f62a6e47b9ae4d838c0 Mon Sep 17 00:00:00 2001 From: Salvatore Previti Date: Wed, 29 Jul 2026 10:35:13 +0100 Subject: [PATCH 1/8] AG-17933 invalid edit value mode block with full row fix (#14588) * AG-17933-invalid-edit-value-mode-bloxk-with-full-row-fix * AG-17933-invalid-edit-value-mode-bloxk-with-full-row-fix * AG-17933-invalid-edit-value-mode-bloxk-with-full-row-fix * AG-17933-invalid-edit-value-mode-bloxk-with-full-row-fix * AG-17933-invalid-edit-value-mode-bloxk-with-full-row-fix --- .../api-documentation/grid-api/api.json | 6 + .../src/agWidgets/agInputDateField.ts | 27 + packages/ag-grid-community/src/api/gridApi.ts | 10 + .../src/api/gridApiFunctions.ts | 1 + .../src/columns/columnModel.ts | 5 + .../src/edit/cellEditors/dateCellEditor.ts | 4 + .../edit/cellEditors/dateStringCellEditor.ts | 4 + .../src/edit/cellEditors/iCellEditorInput.ts | 2 + .../src/edit/cellEditors/simpleCellEditor.ts | 4 + .../src/edit/editApi.test.ts | 4 +- .../ag-grid-community/src/edit/editApi.ts | 32 +- .../src/edit/editModelService.test.ts | 21 + .../src/edit/editModelService.ts | 24 +- .../ag-grid-community/src/edit/editModule.ts | 2 + .../ag-grid-community/src/edit/editService.ts | 500 +++-- .../src/edit/strategy/baseEditStrategy.ts | 86 +- .../src/edit/strategy/fullRowEditStrategy.ts | 22 +- .../edit/strategy/singleCellEditStrategy.ts | 14 +- .../src/edit/utils/controllers.ts | 5 +- .../src/edit/utils/editors.ts | 320 ++- .../src/edit/utils/refresh.ts | 102 +- .../src/entities/agColumn.ts | 1 + .../ag-grid-community/src/entities/rowNode.ts | 6 +- .../src/interfaces/iCellEditor.ts | 2 + .../pinnedRowModel/manualPinnedRowUtils.ts | 1 + .../src/rendering/cell/cellComp.ts | 4 +- .../src/rendering/cell/cellCtrl.ts | 62 +- .../cell/cellKeyboardListenerFeature.ts | 17 +- .../e2e/rich-select-scroll-picker.spec.ts | 127 ++ .../src/richSelect/richSelectCellEditor.ts | 4 +- .../src/reactUi/cells/cellComp.tsx | 4 +- .../src/reactUi/cells/popupEditorComp.tsx | 2 +- .../cell-editing/cell-editing-batch.test.ts | 71 + .../cell-editing-edge-cases.test.ts | 28 + ...cell-editing-full-row-batch-styles.test.ts | 3 +- .../cell-editing/cell-editing-purge.test.ts | 483 +++++ .../cell-editing-regression.test.ts | 86 +- ...l-editing-single-cell-batch-styles.test.ts | 3 +- .../cell-editing-validation-batch.test.ts | 1717 +++++++++++++++++ .../cell-editing-validation-editors.test.ts | 669 +++++++ ...ng-validation-filter-change-react.test.tsx | 397 ++++ ...l-editing-validation-filter-change.test.ts | 449 +++++ ...cell-editing-validation-navigation.test.ts | 691 +++++++ .../cell-editing-validation-react.test.tsx | 363 ++++ ...cell-editing-validation-stop-paths.test.ts | 1169 +++++++++++ .../cell-editing-validation.test.ts | 513 +++++ .../group-edit/group-edit-test-utils.ts | 55 +- .../group-row-editable-pinned-sibling.test.ts | 4 +- .../rich-select-scroll-picker.test.ts | 65 + .../set-value/cell-editing-bulk-edit.test.ts | 47 + .../editable-header-name-react.test.tsx | 9 +- .../src/columns/column-api-extended.test.ts | 14 +- .../floating-filter-editing.test.ts | 62 +- .../filter-behaviour/group-filter.test.ts | 22 +- .../src/selection/cell-selection-raf.test.ts | 18 +- .../filters/advancedFilterBuilderHarness.ts | 21 +- .../src/test-utils/gridRows/gridHtmlRows.ts | 42 +- .../src/test-utils/gridRows/gridRows.ts | 84 +- .../gridRows/rows-diagram/formatting.ts | 4 + .../gridRows/rows-diagram/nodeInfo.ts | 3 + .../rows-validation-dom/cell-helpers.ts | 75 + .../gridRowDomCellValidator.ts | 33 +- .../src/test-utils/widgets/dropdowns.ts | 26 +- .../src/toolbar/toolbar-action-button.test.ts | 14 +- 64 files changed, 8165 insertions(+), 500 deletions(-) create mode 100644 packages/ag-grid-enterprise/e2e/rich-select-scroll-picker.spec.ts create mode 100644 testing/behavioural/src/cell-editing/cell-editing-purge.test.ts create mode 100644 testing/behavioural/src/cell-editing/cell-editing-validation-batch.test.ts create mode 100644 testing/behavioural/src/cell-editing/cell-editing-validation-editors.test.ts create mode 100644 testing/behavioural/src/cell-editing/cell-editing-validation-filter-change-react.test.tsx create mode 100644 testing/behavioural/src/cell-editing/cell-editing-validation-filter-change.test.ts create mode 100644 testing/behavioural/src/cell-editing/cell-editing-validation-navigation.test.ts create mode 100644 testing/behavioural/src/cell-editing/cell-editing-validation-react.test.tsx create mode 100644 testing/behavioural/src/cell-editing/cell-editing-validation-stop-paths.test.ts create mode 100644 testing/behavioural/src/cell-editing/cell-editing-validation.test.ts create mode 100644 testing/behavioural/src/cell-editing/rich-select/rich-select-scroll-picker.test.ts diff --git a/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json b/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json index df65eb82912..8ad0d919f9e 100644 --- a/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json +++ b/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json @@ -568,6 +568,12 @@ "name": "Edit Validation", "url": "./cell-editing-validation" } + }, + "getEditValidationErrors": { + "more": { + "name": "Edit Validation", + "url": "./cell-editing-validation" + } } }, "export": { diff --git a/packages/ag-grid-community/src/agWidgets/agInputDateField.ts b/packages/ag-grid-community/src/agWidgets/agInputDateField.ts index 0106a88d136..ee742fa78e2 100644 --- a/packages/ag-grid-community/src/agWidgets/agInputDateField.ts +++ b/packages/ag-grid-community/src/agWidgets/agInputDateField.ts @@ -8,6 +8,7 @@ import type { import { _addOrRemoveAttribute, _getActiveDomElement, + _isBrowserFirefox, _isBrowserSafari, _parseDateTimeFromString, _serialiseDate, @@ -147,6 +148,13 @@ export class AgInputDateField< return this; } + /** Commits a partially-typed segment before a stop reads the value. A focus-loss stop already blurred. */ + public flushPendingSegment(): void { + if (_isBrowserFirefox() && _getActiveDomElement(this.beans) === this.eInput) { + flushFirefoxSegment(this.eInput); + } + } + public getDate(): Date | undefined { if (!this.eInput.validity.valid) { return undefined; @@ -159,6 +167,25 @@ export class AgInputDateField< } } +const swallowEvent = (e: Event) => e.stopImmediatePropagation(); + +/** + * Firefox alone withholds a pending segment from input.value until blur, so flush by blur+refocus. Only + * the bubbling pair is swallowed, and it must be: it would otherwise reach stopEditingWhenCellsLoseFocus + * with a null relatedTarget and end the edit. `blur`/`focus` need no guard — they reach no ancestor. + */ +const flushFirefoxSegment = (eInput: HTMLInputElement): void => { + eInput.addEventListener('focusout', swallowEvent, true); + eInput.addEventListener('focusin', swallowEvent, true); + try { + eInput.blur(); + eInput.focus({ preventScroll: true }); + } finally { + eInput.removeEventListener('focusout', swallowEvent, true); + eInput.removeEventListener('focusin', swallowEvent, true); + } +}; + /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export const AgInputDateFieldSelector: AgComponentSelector = { selector: 'AG-INPUT-DATE-FIELD', diff --git a/packages/ag-grid-community/src/api/gridApi.ts b/packages/ag-grid-community/src/api/gridApi.ts index c7b505cc80b..a698f56f685 100644 --- a/packages/ag-grid-community/src/api/gridApi.ts +++ b/packages/ag-grid-community/src/api/gridApi.ts @@ -918,6 +918,12 @@ export interface _EditGridApi { * @agModule `TextEditorModule` / `LargeTextEditorModule` / `NumberEditorModule` / `DateEditorModule` / `CheckboxEditorModule` / `CustomEditorModule` / `SelectEditorModule` / `RichSelectModule` */ validateEdit(): ICellEditorValidationError[] | null; + + /** + * The validation errors the Grid currently holds, without running validation. Use `validateEdit()` to revalidate instead. + * @agModule `TextEditorModule` / `LargeTextEditorModule` / `NumberEditorModule` / `DateEditorModule` / `CheckboxEditorModule` / `CustomEditorModule` / `SelectEditorModule` / `RichSelectModule` + */ + getEditValidationErrors(): ICellEditorValidationError[] | null; } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ @@ -950,6 +956,10 @@ export interface _BatchEditApi { * If no cells were edited during the batch session (i.e. `batchEditingStarted` was never * fired), `batchEditingStopped` is not fired either. * + * With `invalidEditValueMode: 'block'`, a commit is rejected while any edit is invalid: nothing is + * written, `batchEditingStopped` does not fire, and the batch and its editors stay open so the value + * can be corrected and the commit retried (or `cancelBatchEdit()` called). + * * Only supported with the Client-Side Row Model. * @agModule `BatchEditModule` */ diff --git a/packages/ag-grid-community/src/api/gridApiFunctions.ts b/packages/ag-grid-community/src/api/gridApiFunctions.ts index 1550dbd7f52..784071a26ad 100644 --- a/packages/ag-grid-community/src/api/gridApiFunctions.ts +++ b/packages/ag-grid-community/src/api/gridApiFunctions.ts @@ -229,6 +229,7 @@ export const gridApiFunctionsMap: Record = startEditingCell: 0, isEditing: 0, validateEdit: 0, + getEditValidationErrors: 0, }), ...mod<_BatchEditApi>('BatchEdit', { startBatchEdit: 0, diff --git a/packages/ag-grid-community/src/columns/columnModel.ts b/packages/ag-grid-community/src/columns/columnModel.ts index 8326e0004b6..4fae2b6e56b 100644 --- a/packages/ag-grid-community/src/columns/columnModel.ts +++ b/packages/ag-grid-community/src/columns/columnModel.ts @@ -49,6 +49,8 @@ export class ColumnModel extends BeanStub implements NamedBean { public colsTree: (AgColumn | AgProvidedColumnGroup)[] = []; public colsTreeDepth = 0; public colDefList: AgColumn[] = []; + /** Invalidation key for anything memoised off the colDefs. Mutating a live colDef in place doesn't register. */ + public colDefsVersion = 0; public colDefTree: (AgColumn | AgProvidedColumnGroup)[] = []; public colDefTreeDepth = 0; private colDefHasMarryChildren = false; @@ -220,6 +222,7 @@ export class ColumnModel extends BeanStub implements NamedBean { this.colDefTree = tree; this.colDefTreeDepth = builder.treeDepth; this.colDefList = cols; + ++this.colDefsVersion; this.colDefHasMarryChildren = builder.marryChildren; this.colDefGroupsById = builder.groupsById; this.colDefColsByKey = builder.colsByKey; @@ -491,6 +494,8 @@ export class ColumnModel extends BeanStub implements NamedBean { this.refreshColsDerivedState(); if (colsListChanged) { beans.rowSpanSvc?.refreshCols(); + // Last: the purge dispatches synchronously, so a listener must not see a half-rebuilt model. + beans.editSvc?.releaseColumnsLeaving(finalColsList); } if (this.colsTree !== prevColTree || colsListChanged) { this.eventSvc.dispatchEvent({ type: 'gridColumnsChanged' }); diff --git a/packages/ag-grid-community/src/edit/cellEditors/dateCellEditor.ts b/packages/ag-grid-community/src/edit/cellEditors/dateCellEditor.ts index 2807ebd62e3..0fafaaac8c6 100644 --- a/packages/ag-grid-community/src/edit/cellEditors/dateCellEditor.ts +++ b/packages/ag-grid-community/src/edit/cellEditors/dateCellEditor.ts @@ -98,6 +98,10 @@ class DateCellEditorInput implements CellEditorInput { getColDef: () => ({ editable: true }), isColumnFunc: () => false, } as unknown as Column; - const cellCtrl1 = { rowNode: rowNode1, focusCell: vi.fn(), onEditorAttachedFuncs: [] } as unknown as CellCtrl; - const cellCtrl2 = { rowNode: rowNode2, focusCell: vi.fn(), onEditorAttachedFuncs: [] } as unknown as CellCtrl; + const cellCtrl1 = { rowNode: rowNode1, focusCell: vi.fn() } as unknown as CellCtrl; + const cellCtrl2 = { rowNode: rowNode2, focusCell: vi.fn() } as unknown as CellCtrl; const getCellCtrl = (column: Column) => { if (column.getColId() === 'col1') { diff --git a/packages/ag-grid-community/src/edit/editApi.ts b/packages/ag-grid-community/src/edit/editApi.ts index f8bc8bf60f0..ed42c19cc33 100644 --- a/packages/ag-grid-community/src/edit/editApi.ts +++ b/packages/ag-grid-community/src/edit/editApi.ts @@ -10,8 +10,13 @@ import type { } from '../interfaces/iCellEditor'; import type { CellPosition } from '../interfaces/iCellPosition'; import type { IRowNode } from '../interfaces/iRowNode'; -import { _getCellCtrl } from './utils/controllers'; -import { UNEDITED, _destroyEditors, _sourceAndPendingDiffer, _syncFromEditors } from './utils/editors'; +import { + UNEDITED, + _flushEditors, + _readEditValidationErrors, + _sourceAndPendingDiffer, + _validateEdit, +} from './utils/editors'; export function undoCellEditing(beans: BeanCollection): void { beans.undoRedo?.undo('api'); @@ -57,24 +62,17 @@ export function getEditingCells(beans: BeanCollection): EditingCellPosition[] { export function stopEditing(beans: BeanCollection, cancel: boolean = false): void { const { editSvc } = beans; if (editSvc?.isBatchEditing()) { - if (cancel) { - for (const cellPosition of beans.editModelSvc?.getEditPositions() ?? []) { - if (cellPosition.state === 'editing') { - editSvc.revertSingleCellEdit(cellPosition); - } - } - } else { - _syncFromEditors(beans, { persist: true }); - } - _destroyEditors(beans, undefined, { cancel }); + editSvc.stopBatchEditors(cancel); } else { editSvc?.stopEditing(undefined, { cancel, source: 'edit', forceStop: !cancel, forceCancel: cancel }); } } export function isEditing(beans: BeanCollection, cellPosition: CellPosition): boolean { - const cellCtrl = _getCellCtrl(beans, cellPosition); - return !!beans.editSvc?.isEditing(cellCtrl); + // Resolved from the row model, not a cell controller: an unrendered row has no controller, and an + // undefined position makes isEditing answer "is anything editing" — a scroll-dependent wrong answer. + const rowNode = _getRowNode(beans, cellPosition); + return !!rowNode && !!beans.editSvc?.isEditing({ rowNode, column: cellPosition.column }); } export function startEditingCell(beans: BeanCollection, params: StartEditingCellParams): void { @@ -124,9 +122,13 @@ export function startEditingCell(beans: BeanCollection, params: StartEditingCell } export function validateEdit(beans: BeanCollection): ICellEditorValidationError[] | null { - return beans.editSvc?.validateEdit() || null; + _flushEditors(beans); + return _validateEdit(beans); } +export const getEditValidationErrors: (beans: BeanCollection) => ICellEditorValidationError[] | null = + _readEditValidationErrors; + export function getCurrentUndoSize(beans: BeanCollection): number { return beans.undoRedo?.getCurrentUndoStackSize() ?? 0; } diff --git a/packages/ag-grid-community/src/edit/editModelService.test.ts b/packages/ag-grid-community/src/edit/editModelService.test.ts index 9745ba79c1a..6e4c4441276 100644 --- a/packages/ag-grid-community/src/edit/editModelService.test.ts +++ b/packages/ag-grid-community/src/edit/editModelService.test.ts @@ -201,6 +201,27 @@ describe('EditModelService', () => { }); }); + describe('cell validation map', () => { + it('drops an emptied row so size and hasCellValidation stay accurate', () => { + const positionSameRow: Required = { rowNode: rowNode1, column: column2 }; + const validationModel = editModelService.getCellValidationModel(); + validationModel.setCellValidation(position1, { errorMessages: ['bad'] }); + validationModel.setCellValidation(positionSameRow, { errorMessages: ['bad'] }); + expect(validationModel.getCellValidationMap().size).toBe(1); + + validationModel.clearCellValidation(position1); + expect(validationModel.hasCellValidation()).toBe(true); + expect(validationModel.hasCellValidation({ rowNode: rowNode1 })).toBe(true); + expect(validationModel.hasCellValidation(position1)).toBe(false); + expect(validationModel.getCellValidationMap().size).toBe(1); + + validationModel.clearCellValidation(positionSameRow); + expect(validationModel.hasCellValidation()).toBe(false); + expect(validationModel.hasCellValidation({ rowNode: rowNode1 })).toBe(false); + expect(validationModel.getCellValidationMap().size).toBe(0); + }); + }); + describe('start', () => { // findNextCellToFocusOn suspends the model while it may initialise editors (see singleCellEditStrategy), // so start() can run for a row that already has editing entries while suspended. diff --git a/packages/ag-grid-community/src/edit/editModelService.ts b/packages/ag-grid-community/src/edit/editModelService.ts index 624d337da2b..27871747e18 100644 --- a/packages/ag-grid-community/src/edit/editModelService.ts +++ b/packages/ag-grid-community/src/edit/editModelService.ts @@ -379,6 +379,14 @@ export class EditModelService extends BeanStub implements NamedBean { this.editingCount = 0; } + /** + * Side-effect-free "is anything invalid?". Scope follows the position: omitted means the whole grid, + * a rowNode without a column means that row. Callers that must not repopulate/refresh use this. + */ + public hasValidationErrors(position?: EditPosition): boolean { + return this.rowValidations.hasRowValidation(position) || this.cellValidations.hasCellValidation(position); + } + public getCellValidationModel(): EditCellValidationModel { return this.cellValidations; } @@ -409,11 +417,13 @@ export class EditCellValidationModel { return this.cellValidations?.get(rowNode!)?.get(column!); } + /** Scope follows the position: no rowNode means the whole grid, a rowNode without a column means that row. */ public hasCellValidation(position?: EditPosition): boolean { - if (!position?.rowNode || !position.column) { + const rowNode = position?.rowNode; + if (!rowNode) { return this.cellValidations.size > 0; } - return !!this.getCellValidation(position); + return position.column ? !!this.getCellValidation(position) : this.cellValidations.has(rowNode); } public setCellValidation(position: Required, validation: EditValidation): void { @@ -426,7 +436,15 @@ export class EditCellValidationModel { public clearCellValidation(position: Required): void { const { rowNode, column } = position; - this.cellValidations.get(rowNode)?.delete(column); + const cellValidations = this.cellValidations; + const rowValidations = cellValidations.get(rowNode); + if (rowValidations) { + rowValidations.delete(column); + if (rowValidations.size === 0) { + // Drop the emptied row so cellValidations.size stays an accurate "has any error" signal. + cellValidations.delete(rowNode); + } + } } public setCellValidationMap(validationMap: EditValidationMap): void { diff --git a/packages/ag-grid-community/src/edit/editModule.ts b/packages/ag-grid-community/src/edit/editModule.ts index 0979b344341..5e7c922fbe6 100644 --- a/packages/ag-grid-community/src/edit/editModule.ts +++ b/packages/ag-grid-community/src/edit/editModule.ts @@ -16,6 +16,7 @@ import { getCurrentRedoSize, getCurrentUndoSize, getEditRowValues, + getEditValidationErrors, getEditingCells, isEditing, redoCellEditing, @@ -45,6 +46,7 @@ export const EditCoreModule: _ModuleWithApi<_EditGridApi> = { stopEditing, isEditing, validateEdit, + getEditValidationErrors, }, dynamicBeans: { singleCell: SingleCellEditStrategy, diff --git a/packages/ag-grid-community/src/edit/editService.ts b/packages/ag-grid-community/src/edit/editService.ts index f0be947a86e..71b297a161c 100644 --- a/packages/ag-grid-community/src/edit/editService.ts +++ b/packages/ag-grid-community/src/edit/editService.ts @@ -12,12 +12,7 @@ import type { GridOptionsService } from '../gridOptionsService'; import { _addGridCommonParams, _isClientSideRowModel } from '../gridOptionsUtils'; import type { CellRange, IRangeService } from '../interfaces/IRangeService'; import type { EditStrategyType } from '../interfaces/editStrategyType'; -import type { - AgBaseCellEditor, - EditingCellPosition, - ICellEditorParams, - ICellEditorValidationError, -} from '../interfaces/iCellEditor'; +import type { AgBaseCellEditor, EditingCellPosition, ICellEditorParams } from '../interfaces/iCellEditor'; import type { CellPosition } from '../interfaces/iCellPosition'; import type { RefreshCellsParams } from '../interfaces/iCellsParams'; import type { Column } from '../interfaces/iColumn'; @@ -44,22 +39,27 @@ import type { BaseEditStrategy } from './strategy/baseEditStrategy'; import { isCellEditable, isFullRowCellEditable, shouldStartEditing } from './strategy/strategyUtils'; import { _applyCellEditStyles } from './styles/cellEditStyleFeature'; import { _applyRowEditStyles } from './styles/rowEditStyleFeature'; -import { _addStopEditingWhenGridLosesFocus, _getCellCtrl } from './utils/controllers'; +import { _addStopEditingWhenGridLosesFocus, _getCellCtrl, _getRowCtrl } from './utils/controllers'; import { UNEDITED, + _destroyEditor, _destroyEditors, _filterChangedEdits, + _flushEditors, + _onPopupEditorClosed, _populateModelValidationErrors, + _populateRowValidationErrors, _purgeUnchangedEdit, _purgeUnchangedEdits, _refreshEditorOnColDefChanged, + _scanEditorsForValidation, _setupEditor, _sourceAndPendingDiffer, _syncFromEditor, + _syncFromEditorComp, _syncFromEditors, - _validateEdit, } from './utils/editors'; -import { _refreshEditCells } from './utils/refresh'; +import { _purgeEdits, _purgeStalePinnedEdits } from './utils/refresh'; type BatchPrepDetails = { compDetails?: UserCompDetails; valueToDisplay?: any }; @@ -123,6 +123,21 @@ export class EditService extends BeanStub implements NamedBean { private strategy?: BaseEditStrategy; private stopping = false; private rangeSelectionWhileEditing = 0; + /** + * Whether the last stop was rejected by block mode holding an invalid value. `res === false` can't + * stand in for this: a consumed mid-batch key also reports false but is a real stop that must still + * navigate and must still be able to end the batch. + */ + private stopBlockRejected = false; + + /** Memo for hasConfiguredValidation, keyed on colModel.colDefsVersion. -1 never matches a real version. */ + private validationConfigVersion = -1; + private validationConfigResult = false; + /** Memo for editorsRequireValidation; undefined means "not scanned yet". */ + private editorsValidation: boolean | undefined; + /** Cell whose editor takes focus as it attaches, and whether the cell itself must be focused first. */ + private pendingEditorFocus: CellCtrl | null = null; + private pendingEditorFocusCell = false; // Nonzero while a bulk write coalesces per-cell cleanup/refresh (see beginBulkWrite). private bulkWriteDepth = 0; @@ -135,6 +150,11 @@ export class EditService extends BeanStub implements NamedBean { this.rangeSvc = beans.rangeSvc!; this.csrm = _isClientSideRowModel(this.gos, beans.rowModel); + // The row-level callback feeds hasConfiguredValidation's memo, which is keyed on the colDef version. + this.addManagedPropertyListener('getFullRowEditValidationErrors', () => { + this.validationConfigVersion = -1; + }); + this.addManagedPropertyListener('editType', ({ currentValue }: any) => { this.stopEditing(undefined, CANCEL_PARAMS); @@ -142,31 +162,28 @@ export class EditService extends BeanStub implements NamedBean { this.createStrategy(currentValue); }); - const handler = _refreshEditCells(beans); + const pinnedSweep = _purgeStalePinnedEdits(beans); const stopInvalidEdits = () => { - const hasCellValidation = this.model.getCellValidationModel().getCellValidationMap().size > 0; - const hasRowValidation = this.model.getRowValidationModel().getRowValidationMap().size > 0; + if (!this.isEditing()) { + return; + } - if (hasCellValidation || hasRowValidation) { + // Flushes and repopulates first: a pull-only editor never calls params.validate(), so a stale + // read here would let the commit-vs-cancel choice persist an invalid value. + if (this.revalidateAndCheck()) { this.stopEditing(undefined, CANCEL_PARAMS); - } else if (this.isEditing()) { - if (this.batch) { - _destroyEditors(beans, this.model.getEditPositions()); - } else { - this.stopEditing(undefined, COMMIT_PARAMS); - } + } else if (this.batch) { + _destroyEditors(beans, this.model.getEditPositions()); + } else { + this.stopEditing(undefined, COMMIT_PARAMS); } - - return false; }; this.addManagedEventListeners({ - columnPinned: handler, - columnVisible: handler, - columnRowGroupChanged: handler, - rowExpansionStateChanged: handler, - pinnedRowsChanged: handler, - displayedRowsChanged: handler, + // Static pinned rows are dropped without being destroyed and without reporting it, so pinned + // membership is re-tested here. Manual pinning pushes from PinnedRows.delete instead. + pinnedRowsChanged: pinnedSweep, + pinnedRowDataChanged: pinnedSweep, sortChanged: stopInvalidEdits, filterChanged: stopInvalidEdits, cellFocused: this.onCellFocused.bind(this), @@ -192,6 +209,12 @@ export class EditService extends BeanStub implements NamedBean { } if (params) { this.stopEditing(undefined, params); + // A block-mode commit rejected by validation keeps its editors open: hold the batch so the + // user can fix the value and re-commit (or cancel). Any other incomplete stop (an empty or + // fully purged batch, a re-entrant call) must still end the batch rather than strand it. + if (params.commit && this.stopBlockRejected) { + return; + } } // If batchEditingStarted was dispatched but stopEditing didn't reach dispatchBatchStopped // (e.g. all edits were purged so prepareStopContext returned null), fire it now to @@ -203,6 +226,34 @@ export class EditService extends BeanStub implements NamedBean { this.batchStartDispatched = false; } + /** + * Closes a batch's open editors, staging their values, without ending the batch. Block mode holds + * instead: an invalid edit keeps its editor open so it can be corrected, as on every other stop path. + */ + public stopBatchEditors(cancel: boolean): void { + const { beans, model } = this; + + if (cancel) { + const positions = model.getEditPositions(); + for (let i = 0, len = positions.length; i < len; ++i) { + const position = positions[i]; + if (position.state === 'editing') { + this.revertSingleCellEdit(position); + } + } + } else { + // Probed only in block mode, so revert mode neither repopulates nor refreshes on a plain close. + if (this.cellEditingInvalidCommitBlocks() && this.revalidateAndCheck()) { + return; + } + _syncFromEditors(beans, { persist: true }); + } + + _destroyEditors(beans, model.getEditPositions(), { cancel }); + // Every editor is closed, so the errors they were holding go with them. + this.clearValidationIfNoOpenEditors(); + } + /** Lazily dispatch batchEditingStarted when the first write or editor open occurs during a batch session. */ private ensureBatchStarted(): void { if (!this.batch || this.batchStartDispatched) { @@ -268,10 +319,6 @@ export class EditService extends BeanStub implements NamedBean { return this.strategy?.shouldCancel(position, event, source) ?? null; } - public validateEdit(): ICellEditorValidationError[] | null { - return _validateEdit(this.beans); - } - public isEditing(position?: EditPosition | null, params?: IsEditingParams): boolean { return this.model.hasEdits(position ?? undefined, params ?? CHECK_SIBLING); } @@ -310,7 +357,8 @@ export class EditService extends BeanStub implements NamedBean { const cellCtrl = _getCellCtrl(this.beans, position)!; if (cellCtrl && !cellCtrl.comp) { params.editable = undefined; // So we re-evaluate editable later - cellCtrl.onCompAttachedFuncs.push(() => this.startEditing(position, params)); + // On the cell, so a second cell asking while this one waits to mount cannot displace it. + cellCtrl.pendingEditStart = params; return; } @@ -342,6 +390,12 @@ export class EditService extends BeanStub implements NamedBean { } public stopEditing(position?: EditPosition, params?: StopEditParams): boolean { + // Cleared early so a stop that bails below can't leave a stale rejection for stopBatchEditing — + // but never from a nested stop, which would wipe the live rejection it sits inside. + if (!this.stopping) { + this.stopBlockRejected = false; + } + const context = this.prepareStopContext(position, params); if (!context) { return false; @@ -396,10 +450,10 @@ export class EditService extends BeanStub implements NamedBean { return null; } + // A stop cancels any focus still waiting on an editor that is about to go. + this.pendingEditorFocus = null; + const cellCtrl = _getCellCtrl(this.beans, position); - if (cellCtrl) { - cellCtrl.onEditorAttachedFuncs = []; - } const willStop = (!cancel && @@ -432,10 +486,7 @@ export class EditService extends BeanStub implements NamedBean { } if (this.shouldHandleMidBatchKey(event, position)) { - return { - res: false, - edits: this.handleMidBatchKey(event, position, context), - }; + return { res: false, edits: this.handleMidBatchKey(event, position, context) }; } _syncFromEditors(this.beans, { persist: true }); @@ -451,14 +502,38 @@ export class EditService extends BeanStub implements NamedBean { const { beans, model } = this; const { cancel, commit, edits, event, source, willCancel, willStop } = context; - // In batch cancel, don't persist editorValue→pendingValue: per-cell Escape preserves the - // previous pending value, and batch-wide cancel (forceCancel) discards all edits anyway. - const persist = !this.batch || !willCancel; + // A pull-only editor leaves the validation maps stale, and buffered input has to reach the value + // before validity is read, or the stop validates the old value and commits the flushed one. + const applying = !willCancel && !cancel; + if (applying) { + _flushEditors(beans); + _populateModelValidationErrors(beans); + } + + // Grid-wide by design: cell validation only holds cells with an open editor, and a batch commit + // must block on all of them. Read directly — a populate on cancel would refresh styles. + const invalid = model.hasValidationErrors(); + // Revert mode must discard each invalid cell's in-flight attempt before the persist below + // overwrites it (see revertInvalidEdits); block mode instead holds the whole commit. + const blockMode = this.cellEditingInvalidCommitBlocks(); + const revertBatchCommit = invalid && !blockMode && this.batch && commit; + if (revertBatchCommit) { + this.revertInvalidEdits(); + } + + // A block-mode invalid commit is rejected (batch or not): persist nothing and report the stop as + // not completed, so the held edit, its editor and the previous valid pending value all survive. + const blockRejected = invalid && blockMode && willStop && applying; + this.stopBlockRejected = blockRejected; + + // Batch cancel also skips the persist: per-cell Escape keeps the previous pending value, and + // forceCancel discards everything anyway. + const persist = (!this.batch || !willCancel) && !blockRejected; _syncFromEditors(beans, { persist, isCancelling: willCancel || cancel, isStopping: willStop }); const freshEdits = model.getEditMapCopy(); const shouldCommit = !willCancel && (!this.batch || commit); - const editsToDelete = shouldCommit ? this.processEdits(freshEdits, source) : []; + const editsToDelete = shouldCommit ? this.processEdits(freshEdits, source, invalid && !revertBatchCommit) : []; if (cancel) { this.strategy?.stopCancelled(context.forceCancel); @@ -482,7 +557,7 @@ export class EditService extends BeanStub implements NamedBean { cellCtrl?.refreshCell({ force: true, suppressFlash: !valueChanged }); } - return { res: willStop, edits: freshEdits }; + return { res: willStop && !blockRejected, edits: freshEdits }; } private shouldHandleMidBatchKey( @@ -512,14 +587,8 @@ export class EditService extends BeanStub implements NamedBean { } else if (isEscape && cellCtrl) { const { rowNode, column } = cellCtrl; if (this.batch && rowNode && column) { - // Defensive: this Escape branch handles the case where the strategy blocked - // direct cancellation (shouldCancelEditing returned false), so processStopRequest - // fell through to handleMidBatchKey instead of handleStopOrCancel. - // In batch mode, Escape reverts to previous pending value, not source value. - const pos: Required = { rowNode, column }; - _destroyEditors(beans, [pos], { silent: true }); - this.model.stop(pos, true, true); - _getCellCtrl(beans, pos)?.refreshCell(FORCE_REFRESH); + // The strategy refuses a direct cancel mid-batch, so the revert lands here. + this.revertCellEdit({ rowNode, column }); } else { this.revertSingleCellEdit(cellCtrl); } @@ -550,7 +619,6 @@ export class EditService extends BeanStub implements NamedBean { commit, forceCancel, willCancel, - willStop, }: StopContext & { params?: StopEditParams; res: boolean }): void { const beans = this.beans; if (res && position) { @@ -561,8 +629,11 @@ export class EditService extends BeanStub implements NamedBean { // Suppress navigation is required for bulk activities like pasting or fill handle via setDataValue, // otherwise navigateAfterEdit will cause the grid to redundantly scan for the next available cell - // to edit, which causes focus and rendering changes, for each cell in the bulk operation - this.navigateAfterEdit(params, cellCtrl?.cellPosition); + // to edit, which causes focus and rendering changes, for each cell in the bulk operation. + // A block-rejected stop never happened, so navigating would strand the held invalid editor. + if (!this.stopBlockRejected) { + this.navigateAfterEdit(params, cellCtrl?.cellPosition); + } _purgeUnchangedEdits(beans); @@ -582,11 +653,13 @@ export class EditService extends BeanStub implements NamedBean { rowRenderer.refreshRows({ suppressFlash: true, force: true }); } - const batchCommit = willStop && commit; + // res is false when block mode rejected the commit, so the batch stays open (see + // stopBatchEditing) and no batchEditingStopped fires — the batch has not stopped. + const batchCommit = res && commit; const batchCancel = willCancel && forceCancel; // Only fire batchEditingStopped when the batch is genuinely ending: - // - commit: commitBatchEdit() was called (forceStop + commit) + // - commit: commitBatchEdit() was called (forceStop + commit) and validation passed // - forceCancel: cancelBatchEdit() was called (cancel + forceCancel) // Do NOT fire during mid-batch row transitions (Tab/Enter across rows in fullRow mode). if (batchCommit || batchCancel) { @@ -646,12 +719,59 @@ export class EditService extends BeanStub implements NamedBean { this.beans.navigation?.navigateToNextCell(null, direction, cellPosition, false); } - private processEdits(edits: EditMap, source: EditSource): EditPosition[] { - const rowNodes = Array.from(edits.keys()); + /** + * Reverts each invalid cell to its previous pending value, so a revert-mode batch commit still + * writes an earlier valid edit and drops cells that never held one. A row error invalidates the row. + */ + private revertInvalidEdits(): void { + const model = this.model; + const positions = model.getEditPositions(); + // Chosen up front: each revert revalidates the row, and a row error clearing part-way through + // would spare the siblings it had invalidated. + const invalid: Required[] = []; + for (let i = 0, len = positions.length; i < len; ++i) { + const position = positions[i]; + if (model.hasValidationErrors(position)) { + invalid.push(position); + } + } + for (let i = 0, len = invalid.length; i < len; ++i) { + this.revertCellEdit(invalid[i]); + } + } - const hasValidationErrors = - this.model.getCellValidationModel().getCellValidationMap().size > 0 || - this.model.getRowValidationModel().getRowValidationMap().size > 0; + /** Closes one cell's editor while its row keeps editing, so the edit and its pending value survive. */ + public closeCellEditor(position: Required): void { + const beans = this.beans; + // Staged first: the editor goes while the row edit carries on, so an unstaged value would be lost. + _syncFromEditorComp(beans, position, { persist: true, isStopping: true }); + _destroyEditor(beans, position, {}); + } + + /** + * Drops a cell's in-flight editor attempt, keeping any earlier pending value (per-cell Escape + * semantics), leaving any surrounding row or batch edit untouched. Callers that must revert one + * cell without a key event go through here. + */ + public revertCellEdit(position: Required): void { + const beans = this.beans; + _destroyEditor(beans, position, { cancel: true }); + const model = this.model; + model.stop(position, true, true); + // The dropped attempt takes its error with it — outside the stop pipeline nothing else clears it. + model.getCellValidationModel().clearCellValidation(position); + // The row rule was last run against the value just dropped, so its verdict is about a value the + // row no longer holds — recompute it, and restyle the row on the new one. + _populateRowValidationErrors(beans); + const rowCtrl = _getRowCtrl(beans, position); + if (rowCtrl) { + this.applyRowEditStyles(rowCtrl); + } + _getCellCtrl(beans, position)?.refreshCell(FORCE_REFRESH); + } + + private processEdits(edits: EditMap, source: EditSource, skipAllCommits: boolean): EditPosition[] { + const rowNodes = Array.from(edits.keys()); const editsToDelete: EditPosition[] = []; @@ -664,7 +784,7 @@ export class EditService extends BeanStub implements NamedBean { const editValue = editRow.get(column)!; const position: Required = { rowNode, column }; - if (_sourceAndPendingDiffer(editValue) && !hasValidationErrors) { + if (_sourceAndPendingDiffer(editValue) && !skipAllCommits) { const cellCtrl = _getCellCtrl(this.beans, position); const success = this.setNodeDataValue( rowNode, @@ -940,6 +1060,110 @@ export class EditService extends BeanStub implements NamedBean { return isEditable; } + /** + * Whether anything can report a validation error: a configured rule, or an open editor that validates + * itself. Both halves are memoised — every probe would otherwise rescan the colDefs and the cell ctrls. + */ + public hasValidationRules(): boolean { + return this.hasConfiguredValidation() || this.editorsRequireValidation(); + } + + /** Configured validation: a row-level callback or a colDef rule, so it changes only with the colDefs. */ + private hasConfiguredValidation(): boolean { + const colModel = this.beans.colModel; + const version = colModel.colDefsVersion; + if (this.validationConfigVersion === version) { + return this.validationConfigResult; + } + this.validationConfigVersion = version; + + let required = !!this.gos.get('getFullRowEditValidationErrors'); + const cols = colModel.colDefList; + for (let i = 0, len = cols.length; !required && i < len; ++i) { + const colDef = cols[i].colDef; + const params = colDef.cellEditorParams; + if (!params || (!colDef.editable && !colDef.groupRowEditable)) { + continue; + } + required = + // cellEditorParams can be a function, whose per-row rules can't be read from the colDef. + typeof params === 'function' || + params.minLength !== undefined || + params.maxLength !== undefined || + params.getValidationErrors !== undefined || + params.min !== undefined || + params.max !== undefined; + } + this.validationConfigResult = required; + return required; + } + + /** Editor-supplied validation: the scan materialises every rendered cell ctrl, hence the memo. */ + private editorsRequireValidation(): boolean { + // Only an open editor can match, so with none there is nothing to scan or to remember. + if (!this.model.hasOpenEditors()) { + return false; + } + this.editorsValidation ??= _scanEditorsForValidation(this.beans); + return this.editorsValidation; + } + + /** Called wherever an editor comes or goes: created, attached (React mounts late) or gone with its cell. */ + public invalidateEditorsValidation(): void { + this.editorsValidation = undefined; + } + + /** Rows whose edits were purged without a stop: the strategy still holds them, so let it release. */ + public releasePurgedRows(rowNodes: Set): void { + this.strategy?.releaseRows(rowNodes); + } + + /** Ends a row's edits as it is torn down; its position can never be reached again. */ + public releaseRowEdits(rowNode: IRowNode): void { + const editRow = this.model.getEditRow(rowNode); + if (!editRow?.size || this.beans.gridDestroySvc.destroyCalled) { + return; + } + // Snapshot: the purge dispatches cellEditingStopped, and a listener may edit the map underneath. + const positions: Required[] = []; + for (const column of editRow.keys()) { + positions.push({ rowNode, column }); + } + _purgeEdits(this.beans, positions); + } + + /** Ends edits on every column dropping out of colsList, destroyed or merely parked. */ + public releaseColumnsLeaving(newCols: AgColumn[]): void { + const editMap = this.model.getEditMap(); + if (!editMap?.size) { + return; + } + + // Driven from the edited columns, a handful, rather than diffing a list that can be thousands long. + const leaving = new Set(); + for (const editRow of editMap.values()) { + for (const column of editRow.keys()) { + leaving.add(column); + } + } + for (let i = 0, len = newCols.length; leaving.size > 0 && i < len; ++i) { + leaving.delete(newCols[i]); + } + if (!leaving.size) { + return; + } + + const positions: Required[] = []; + editMap.forEach((editRow, rowNode) => { + for (const column of editRow.keys()) { + if (leaving.has(column)) { + positions.push({ rowNode, column }); + } + } + }); + _purgeEdits(this.beans, positions); + } + public cellEditingInvalidCommitBlocks(): boolean { return this.gos.get('invalidEditValueMode') === 'block'; } @@ -949,7 +1173,7 @@ export class EditService extends BeanStub implements NamedBean { event?: Event | CellFocusedEvent, focus: boolean = true ): EditNavOnValidationResult { - if (this.hasValidationErrors(position)) { + if (this.revalidateAndCheck(position)) { const cellCtrl = _getCellCtrl(this.beans, position); if (this.cellEditingInvalidCommitBlocks()) { (event as Event)?.preventDefault?.(); @@ -977,6 +1201,72 @@ export class EditService extends BeanStub implements NamedBean { _populateModelValidationErrors(this.beans); } + /** + * A cell component has attached: opens its editor if the cell is (or is starting) editing. + * @returns whether the cell is editing, so the caller renders its value only when it is not. + */ + public onCompAttached(cellCtrl: CellCtrl, startEdit?: boolean): boolean { + const editable = startEdit ? cellCtrl.isCellEditable() : undefined; + const editing = !!editable || this.isEditing(cellCtrl, { withOpenEditor: true }); + if (editing) { + this.startEditing(cellCtrl, { + startedEdit: false, + source: 'api', + silent: true, + continueEditing: true, + editable, + }); + } + + return editing; + } + + /** Replays a start that was waiting for a component — React mounts one a turn late. Called after the + * cell has rendered, so the editor takes over a cell that is already showing its value. */ + public replayPendingStart(cellCtrl: CellCtrl): void { + const params = cellCtrl.pendingEditStart!; + cellCtrl.pendingEditStart = null; + this.startEditing(cellCtrl, params); + } + + /** A cell leaving takes its validation, and the focus it was owed, with it. */ + public onCellDestroyed(cellCtrl: CellCtrl): void { + if (this.pendingEditorFocus === cellCtrl) { + this.pendingEditorFocus = null; + } + if (cellCtrl.comp?.getCellEditor()) { + this.invalidateEditorsValidation(); + } + } + + /** Focus can only be in one place, so a later request replaces an earlier one. */ + public focusEditorOnAttach(cellCtrl: CellCtrl, focusCell: boolean): void { + this.pendingEditorFocus = cellCtrl; + this.pendingEditorFocusCell = focusCell; + } + + /** An editor has attached to its cell — both view layers route here once the editor is live. */ + public onEditorAttached(cellCtrl: CellCtrl): void { + // The editor lands after the setup that created it, so the validation memo is stale until now. + this.invalidateEditorsValidation(); + cellCtrl.rangeFeature?.unsetComp(); + + if (this.pendingEditorFocus !== cellCtrl) { + return; + } + const focusCell = this.pendingEditorFocusCell; + this.pendingEditorFocus = null; + if (focusCell) { + cellCtrl.focusCell({ forceBrowserFocus: true }); + } + cellCtrl.comp?.getCellEditor()?.focusIn?.(); + } + + /** Calls through to standalone method for treeshaking via the editService */ + public onPopupEditorClosed(cellCtrl: CellCtrl, event?: MouseEvent | TouchEvent | KeyboardEvent): void { + _onPopupEditorClosed(this.beans, cellCtrl, event); + } + public revertSingleCellEdit(cellPosition: Required, focus = false): void { const cellCtrl = _getCellCtrl(this.beans, cellPosition); if (!cellCtrl?.comp?.getCellEditor()) { @@ -984,7 +1274,7 @@ export class EditService extends BeanStub implements NamedBean { return; } - _destroyEditors(this.beans, [cellPosition], { silent: true }); + _destroyEditor(this.beans, cellPosition, { silent: true }); this.model.clearEditValue(cellPosition); @@ -1002,8 +1292,20 @@ export class EditService extends BeanStub implements NamedBean { cellCtrl?.comp?.getCellEditor()?.focusIn?.(); } - public hasValidationErrors(position?: EditPosition): boolean { + /** + * Brings the validation state up to date, then reports it. Not a plain query: use the model's + * {@link EditModelService.hasValidationErrors} when the state is known to be current. + */ + public revalidateAndCheck(position?: EditPosition): boolean { + // Buffered input has to reach the value before validity is read, or the answer describes the + // pre-flush value while the stop that follows commits the flushed one. + _flushEditors(this.beans); _populateModelValidationErrors(this.beans); + return this.checkValidated(position); + } + + /** The read half of {@link revalidateAndCheck}, for a caller that has just revalidated. */ + public checkValidated(position?: EditPosition): boolean { const cellCtrl = _getCellCtrl(this.beans, position); if (cellCtrl) { cellCtrl.refreshCell(FORCE_REFRESH); @@ -1012,20 +1314,7 @@ export class EditService extends BeanStub implements NamedBean { this.applyRowEditStyles(cellCtrl.rowCtrl); } - let invalid = false; - if (position?.rowNode) { - invalid ||= this.model.getRowValidationModel().hasRowValidation({ rowNode: position.rowNode }); - if (position.column) { - invalid ||= this.model - .getCellValidationModel() - .hasCellValidation({ rowNode: position.rowNode, column: position.column }); - } - } else { - invalid ||= this.model.getCellValidationModel().getCellValidationMap().size > 0; - invalid ||= this.model.getRowValidationModel().getRowValidationMap().size > 0; - } - - return invalid; + return this.model.hasValidationErrors(position); } public moveToNextCell( @@ -1289,16 +1578,11 @@ export class EditService extends BeanStub implements NamedBean { } // Fallback for editors that don't implement refresh(): recreate the editor to pick up the new value. - const restoreFocus = cellCtrl.hasBrowserFocus(); - if (restoreFocus) { - cellCtrl.onEditorAttachedFuncs.push(() => { - const latestCellCtrl = _getCellCtrl(this.beans, position); - latestCellCtrl?.focusCell({ forceBrowserFocus: true }); - latestCellCtrl?.comp?.getCellEditor()?.focusIn?.(); - }); + if (cellCtrl.hasBrowserFocus()) { + this.focusEditorOnAttach(cellCtrl, true); } - _destroyEditors(beans, [position], { silent: true, cancel: true }); + _destroyEditor(beans, position, { silent: true, cancel: true }); _setupEditor(beans, position, { silent: true }); _populateModelValidationErrors(beans); _getCellCtrl(beans, position)?.refreshCell(FORCE_REFRESH); @@ -1423,21 +1707,12 @@ export class EditService extends BeanStub implements NamedBean { } public dispatchBatchEvent(type: 'batchEditingStarted' | 'batchEditingStopped', edits: EditMap): void { - this.eventSvc.dispatchEvent(this.createBatchEditEvent(type, edits)); - } - - public createBatchEditEvent( - type: 'batchEditingStarted' | 'batchEditingStopped', - edits: EditMap - ): BatchEditingStartedEvent | BatchEditingStoppedEvent { - return _addGridCommonParams(this.gos, { + const event: BatchEditingStartedEvent | BatchEditingStoppedEvent = _addGridCommonParams(this.gos, { type, - ...(type === 'batchEditingStopped' - ? { - changes: this.toEventChangeList(edits), - } - : {}), + // Only the stopped event carries what changed. + ...(type === 'batchEditingStopped' ? { changes: this.toEventChangeList(edits) } : {}), }); + this.eventSvc.dispatchEvent(event); } private toEventChangeList(edits: EditMap): CellValueChange[] { @@ -1471,7 +1746,8 @@ export class EditService extends BeanStub implements NamedBean { const isFormula = formula?.isFormula(editValue) ?? false; - ranges.forEach((range: CellRange) => { + for (let i = 0, len = ranges.length; i < len; ++i) { + const range = ranges[i]; const rangeColumns = range.columns as AgColumn[]; const hasFormulaColumnsInRange = rangeColumns.some((col) => col?.allowFormula); rangeSvc?.forEachRowInRange(range, (position) => { @@ -1525,19 +1801,19 @@ export class EditService extends BeanStub implements NamedBean { editValue = formula?.updateFormulaByOffset({ value: editValue, rowDelta: 1 }); } }); + } - this.setEditMap(edits); - - if (this.batch) { - this.cleanupEditors(); - - _purgeUnchangedEdits(beans); + // One bulk edit however many ranges it spans, so commit once: a stopped event per range leaves + // the pair unbalanced, and consumers that latch on the first (undo/redo) drop every later range. + this.setEditMap(edits); - this.ensureBatchStarted(); + if (this.batch) { + this.cleanupEditors(); - return; - } + _purgeUnchangedEdits(beans); + this.ensureBatchStarted(); + } else { this.committing = true; try { this.stopEditing(undefined, { source: 'bulk' }); @@ -1547,7 +1823,7 @@ export class EditService extends BeanStub implements NamedBean { this.eventSvc.dispatchEvent({ type: 'bulkEditingStopped', changes: this.toEventChangeList(edits) }); } } - }); + } // focus the first cell in the range const cellCtrl = _getCellCtrl(beans, { rowNode, column })!; diff --git a/packages/ag-grid-community/src/edit/strategy/baseEditStrategy.ts b/packages/ag-grid-community/src/edit/strategy/baseEditStrategy.ts index ad3897c5ca4..776f8589f4a 100644 --- a/packages/ag-grid-community/src/edit/strategy/baseEditStrategy.ts +++ b/packages/ag-grid-community/src/edit/strategy/baseEditStrategy.ts @@ -1,9 +1,11 @@ import { KeyCode } from 'ag-stack'; import { BeanStub } from '../../context/beanStub'; -import type { BeanName } from '../../context/context'; +import type { BeanCollection, BeanName } from '../../context/context'; import type { AgColumn } from '../../entities/agColumn'; import { _getRowNode } from '../../entities/positionUtils'; +import type { RowNode } from '../../entities/rowNode'; +import { _createGlobalRowEvent } from '../../entities/rowNodeUtils'; import type { AgEventType } from '../../eventTypes'; import type { CellFocusClearedEvent, CellFocusedEvent, CommonCellFocusParams } from '../../events'; import type { EditMap, EditValue, ReadonlyEditMap, ReadonlyEditRow } from '../../interfaces/iEditModelService'; @@ -17,6 +19,7 @@ import type { } from '../../interfaces/iEditService'; import type { IRowNode } from '../../interfaces/iRowNode'; import type { CellCtrl } from '../../rendering/cell/cellCtrl'; +import { _createCellEvent } from '../../rendering/cell/cellEvent'; import type { EditModelService } from '../editModelService'; import type { EditService } from '../editService'; import { _getCellCtrl, _getRowCtrl } from '../utils/controllers'; @@ -199,6 +202,17 @@ export abstract class BaseEditStrategy extends BeanStub { cellCtrl.comp?.getCellEditor()?.focusOut?.(); } + /** Pins focus to the cell the user must fix, so blocked navigation can't leak focus to another row. */ + public focusFirstInvalidCell(cell: CellCtrl, event?: KeyboardEvent): void { + const target = findFirstInvalidCell(this.beans, this.model, cell.rowNode) ?? cell; + + // setFocusInOnEditor must run last, or forceBrowserFocus drags the caret back out of the editor. + target.focusCell({ forceBrowserFocus: true, sourceEvent: event }); + if (target.isCellEditable()) { + this.setFocusInOnEditor(target); + } + } + public setFocusInOnEditor(cellCtrl: CellCtrl): void { const comp = cellCtrl.comp; const editor = comp?.getCellEditor(); @@ -213,7 +227,7 @@ export abstract class BaseEditStrategy extends BeanStub { const isFullRow = this.beans.gos.get('editType') === 'fullRow'; cellCtrl.focusCell({ forceBrowserFocus: isFullRow }); - cellCtrl.onEditorAttachedFuncs.push(() => comp?.getCellEditor()?.focusIn?.()); + this.editSvc.focusEditorOnAttach(cellCtrl, false); } } @@ -230,11 +244,20 @@ export abstract class BaseEditStrategy extends BeanStub { type?: T, payload?: any ): void { - const cellCtrl = _getCellCtrl(this.beans, position); - - if (cellCtrl) { - this.eventSvc.dispatchEvent({ ...(cellCtrl.createEvent(event ?? null, type as T) as any), ...payload }); - } + const { beans } = this; + const cellCtrl = _getCellCtrl(beans, position); + + // Without a controller (cell hidden / virtualised out) the event still has to fire, or an edit ending + // off-screen leaves the started/stopped pair unbalanced. Resolved through the same read the editor + // opened on, since the raw field carries no pending edit and no getter, formula or group value. + const value = cellCtrl + ? cellCtrl.value + : beans.valueSvc.getDisplayValue(position.column as AgColumn, position.rowNode, 'edit', false); + + this.eventSvc.dispatchEvent({ + ..._createCellEvent(beans, event ?? null, type as T, position, value), + ...payload, + }); } public dispatchRowEvent( @@ -246,13 +269,17 @@ export abstract class BaseEditStrategy extends BeanStub { return; } - const rowCtrl = _getRowCtrl(this.beans, position)!; - - if (rowCtrl) { - this.eventSvc.dispatchEvent(rowCtrl.createRowEvent(type)); - } + const rowCtrl = _getRowCtrl(this.beans, position); + // The row can outlive its controller (removed, or virtualised out), and a stopped event still + // has to balance the started one, so fall back to building it from the node. + this.eventSvc.dispatchEvent( + rowCtrl ? rowCtrl.createRowEvent(type) : _createGlobalRowEvent(position.rowNode as RowNode, this.gos, type) + ); } + /** Edits ended outside the stop pipeline (their rows were purged), so drop any per-row bookkeeping. */ + public abstract releaseRows(rowNodes: Set): void; + public shouldStop( _position?: EditPosition, event?: KeyboardEvent | MouseEvent | null | undefined, @@ -354,3 +381,38 @@ export abstract class BaseEditStrategy extends BeanStub { super.destroy(); } } + +/** + * The cell the user must fix: the row's first invalid cell in visible-column order, scrolled in when its + * column is virtualised out. The scroll targets that one column, not every candidate a scan would reject. + */ +const findFirstInvalidCell = ( + beans: BeanCollection, + model: EditModelService, + rowNode: IRowNode +): CellCtrl | undefined => { + const rowValidations = model.getCellValidationModel().getCellValidationMap().get(rowNode); + if (!rowValidations) { + return undefined; + } + + const cols = beans.visibleCols.allCols; + let column: AgColumn | undefined; + for (let i = 0, len = cols.length; i < len; ++i) { + const col = cols[i]; + if (rowValidations.has(col)) { + column = col; + break; + } + } + if (!column) { + return undefined; + } + + const cellCtrl = _getCellCtrl(beans, { rowNode, column }); + if (cellCtrl) { + return cellCtrl; + } + beans.navigation?.ensureColumnVisible(column); + return _getCellCtrl(beans, { rowNode, column }); +}; diff --git a/packages/ag-grid-community/src/edit/strategy/fullRowEditStrategy.ts b/packages/ag-grid-community/src/edit/strategy/fullRowEditStrategy.ts index 8028fb7d68d..1d2dbb42528 100644 --- a/packages/ag-grid-community/src/edit/strategy/fullRowEditStrategy.ts +++ b/packages/ag-grid-community/src/edit/strategy/fullRowEditStrategy.ts @@ -154,7 +154,6 @@ export class FullRowEditStrategy extends BaseEditStrategy { } }); - editSvc.populateModelValidationErrors(); if (editSvc.checkNavWithValidation({ rowNode }) === 'block-stop') { return false; } @@ -206,6 +205,18 @@ export class FullRowEditStrategy extends BaseEditStrategy { super.onCellFocusChanged(event); } + public override releaseRows(rowNodes: Set): void { + const { startedRows } = this; + for (const rowNode of rowNodes) { + if (startedRows.delete(rowNode)) { + this.dispatchRowEvent({ rowNode }, 'rowEditingStopped'); + } + if (this.rowNode === rowNode) { + this.rowNode = undefined; + } + } + } + public override cleanupEditors(position: EditRowPosition = {}, includeEditing?: boolean): void { super.cleanupEditors(position, includeEditing); @@ -302,11 +313,16 @@ export class FullRowEditStrategy extends BaseEditStrategy { this.setFocusInOnEditor(nextCell); nextCell.focusCell({ sourceEvent: event }); } + } else if (preventNavigation && !rowsMatch) { + // block mode: Tab past the row's last editable cell must not leak focus to another row — + // pin the user to the first invalid cell they must correct (or cancel). + this.focusFirstInvalidCell(prevCell, event); } else { - if (nextEditable && preventNavigation) { + // Browser focus first: forceBrowserFocus would drag the caret back out of the editor. + nextCell.focusCell({ forceBrowserFocus: true, sourceEvent: event }); + if (nextEditable) { this.setFocusInOnEditor(nextCell); } - nextCell.focusCell({ forceBrowserFocus: true, sourceEvent: event }); } if (!rowsMatch && !preventNavigation) { diff --git a/packages/ag-grid-community/src/edit/strategy/singleCellEditStrategy.ts b/packages/ag-grid-community/src/edit/strategy/singleCellEditStrategy.ts index 969e915f12c..f62b466b1cf 100644 --- a/packages/ag-grid-community/src/edit/strategy/singleCellEditStrategy.ts +++ b/packages/ag-grid-community/src/edit/strategy/singleCellEditStrategy.ts @@ -18,6 +18,13 @@ export class SingleCellEditStrategy extends BaseEditStrategy { private rowNode?: IRowNode | null; private column?: Column | null; + public override releaseRows(rowNodes: Set): void { + if (this.rowNode && rowNodes.has(this.rowNode)) { + this.rowNode = undefined; + this.column = undefined; + } + } + public override shouldStop( position?: EditPosition, event?: KeyboardEvent | MouseEvent | null | undefined, @@ -261,10 +268,11 @@ export class SingleCellEditStrategy extends BaseEditStrategy { this.cleanupEditors(nextCell); } + } else if (preventNavigation) { + // Only one cell edits at a time here, so any blocked Tab — same row or not — would leave the + // held invalid editor behind: pin the user to the cell they must correct (or cancel). + this.focusFirstInvalidCell(prevCell, event); } else { - if (nextEditable && preventNavigation) { - this.setFocusInOnEditor(nextCell); - } nextCell.focusCell({ forceBrowserFocus: true, sourceEvent: event }); } diff --git a/packages/ag-grid-community/src/edit/utils/controllers.ts b/packages/ag-grid-community/src/edit/utils/controllers.ts index 7761ab9a92f..0a76baa1b1c 100644 --- a/packages/ag-grid-community/src/edit/utils/controllers.ts +++ b/packages/ag-grid-community/src/edit/utils/controllers.ts @@ -8,7 +8,6 @@ import type { Column } from '../../interfaces/iColumn'; import type { IRowNode, RowPinnedType } from '../../interfaces/iRowNode'; import type { CellCtrl } from '../../rendering/cell/cellCtrl'; import type { RowCtrl } from '../../rendering/row/rowCtrl'; -import { _destroyEditors, _syncFromEditors } from './editors'; type ResolveRowControllerType = { rowIndex?: number | null; @@ -80,9 +79,7 @@ export function _getCellCtrl(beans: BeanCollection, inputs: ResolveControllerTyp function _stopEditing(beans: BeanCollection): void { const { editSvc } = beans; if (editSvc?.isBatchEditing()) { - // persist any pending editor values before closing editors on focus loss - _syncFromEditors(beans, { persist: true }); - _destroyEditors(beans); + editSvc.stopBatchEditors(false); } else { editSvc?.stopEditing(undefined, { source: 'api' }); } diff --git a/packages/ag-grid-community/src/edit/utils/editors.ts b/packages/ag-grid-community/src/edit/utils/editors.ts index cf1a173a5c8..b9e4ebad1f6 100644 --- a/packages/ag-grid-community/src/edit/utils/editors.ts +++ b/packages/ag-grid-community/src/edit/utils/editors.ts @@ -1,4 +1,4 @@ -import { _getLocaleTextFunc, _setAriaInvalid } from 'ag-stack'; +import { KeyCode, _getLocaleTextFunc, _setAriaInvalid } from 'ag-stack'; import { _unwrapUserComp } from '../../components/framework/unwrapUserComp'; import { _getCellEditorDetails } from '../../components/framework/userCompUtils'; @@ -7,6 +7,7 @@ import type { AgColumn } from '../../entities/agColumn'; import type { CellEditingStoppedEvent } from '../../events'; import { _addGridCommonParams } from '../../gridOptionsUtils'; import type { + AgBaseCellEditor, DefaultProvidedCellEditorParams, EditingCellPosition, GetCellEditorInstancesParams, @@ -21,8 +22,7 @@ import type { IRowNode } from '../../interfaces/iRowNode'; import type { CellCtrl } from '../../rendering/cell/cellCtrl'; import type { RowCtrl } from '../../rendering/row/rowCtrl'; import { EditCellValidationModel, EditRowValidationModel } from '../editModelService'; -import { _applyCellEditStyles } from '../styles/cellEditStyleFeature'; -import { _getCellCtrl } from './controllers'; +import { _getCellCtrl, _getRowCtrl } from './controllers'; export const UNEDITED = Symbol('unedited'); @@ -177,18 +177,20 @@ export function _setupEditor( return; } - const { rangeFeature, rowCtrl, comp, onEditorAttachedFuncs } = cellCtrl; + const { rowCtrl, comp } = cellCtrl; + + // A silent setup continues a session that already fired cellEditingStarted, and wiping its bookkeeping + // would swallow the stop. A new session resets it: a reused row node can carry the previous one's flags. + const { cellStartedEditing, cellStoppedEditing } = (silent ? previousEdit?.editorState : undefined) ?? {}; editModelSvc?.setEdit(position, { editorValue: getNormalisedFormula(beans, newValue, true, position.column), state: 'editing', - // Reset lifecycle flags for this new editor session. Previous sessions may have left - // cellStartedEditing/cellStoppedEditing set on a reused row node. - editorState: { cellStartedEditing: undefined, cellStoppedEditing: undefined }, + editorState: { cellStartedEditing, cellStoppedEditing }, }); cellCtrl.editCompDetails = compDetails; - onEditorAttachedFuncs.push(() => rangeFeature?.unsetComp()); + beans.editSvc?.invalidateEditorsValidation(); // a new editor may bring validation with it comp?.setEditDetails(compDetails, popup, popupLocation, gos.get('reactiveCustomComponents')); rowCtrl?.refreshRow({ suppressFlash: true }); cellCtrl.refreshNoteState(); @@ -223,7 +225,7 @@ function _valueFromEditor( ): { editorValue?: any; editorValueExists: boolean; isCancelAfterEnd?: boolean } { const noValueResult = { editorValueExists: false }; - if (_hasValidationRules(beans)) { + if (beans.editSvc!.hasValidationRules()) { const validationErrors = cellEditor.getValidationErrors?.(); if ((validationErrors?.length ?? 0) > 0) { @@ -296,14 +298,25 @@ function _createEditorParams( cellStartedEdit: !!cellStartedEdit, onKeyDown: cellCtrl?.onKeyDown.bind(cellCtrl), stopEditing: (suppressNavigateAfterEdit: boolean, event?: KeyboardEvent) => { - editSvc!.stopEditing(position, { source: batchEdit ? 'ui' : 'api', suppressNavigateAfterEdit, event }); - _destroyEditor(beans, position, {}); + editSvc!.stopEditing(position, { + source: batchEdit ? 'ui' : 'api', + suppressNavigateAfterEdit, + event, + }); + // Block mode holds an invalid edit's editors open — tearing this one down would orphan it. + // Row-scoped, since in full-row the hold can come from a sibling cell. The stop's own result + // can't stand in for this: a mid-batch stop reports false while still being a real close. + const held = + editSvc!.cellEditingInvalidCommitBlocks() && !!beans.editModelSvc?.hasValidationErrors({ rowNode }); + if (!held) { + _destroyEditor(beans, position, {}); + } }, eGridCell: cellCtrl?.eGui, parseValue: (newValue: any) => valueSvc.parseValue(agColumn, rowNode, newValue, cellCtrl?.value), formatValue: cellCtrl?.formatValue.bind(cellCtrl), validate: () => { - editSvc?.validateEdit(); + _validateEdit(beans); }, }); } @@ -383,35 +396,142 @@ function checkAndPreventDefault( return params; } -export function _syncFromEditors( - beans: BeanCollection, - params: { persist: boolean; isCancelling?: boolean; isStopping?: boolean } -): void { - for (const cellId of beans.editModelSvc?.getEditPositions() ?? []) { - const cellCtrl = _getCellCtrl(beans, cellId); +/** + * Commits every open editor's buffered input (e.g. a Firefox date segment) so a stop validates and reads + * the final value. Stop-only: the Firefox flush blurs the input, so it must never run per keystroke. + */ +export function _flushEditors(beans: BeanCollection): void { + const editModelSvc = beans.editModelSvc; + // Every staged edit would otherwise cost a cell-ctrl resolve to discover it has no editor to flush. + if (!editModelSvc?.hasOpenEditors()) { + return; + } - if (!cellCtrl) { - continue; + // Walk the live map rather than getEditPositions: only the position is needed, not the cloned values. + // One scratch position for the whole walk — _getCellCtrl only reads it. + const position = {} as Required; + editModelSvc.getEditMap()?.forEach((editRow, rowNode) => { + position.rowNode = rowNode; + for (const column of editRow.keys()) { + position.column = column; + const editor = _getCellCtrl(beans, position)?.comp?.getCellEditor(); + if (editor) { + (_unwrapUserComp(editor) as AgBaseCellEditor).agFlushInput?.(); + } } + }); +} - const editor = cellCtrl.comp?.getCellEditor(); +/** + * Block mode can't hold an invalid editor whose popup is gone, so the caller must revert: an orphaned + * editor would leave the cell uneditable. + */ +const isPopupEditBlockedInvalid = (beans: BeanCollection, cellCtrl: CellCtrl): boolean => { + if (!beans.editSvc!.cellEditingInvalidCommitBlocks()) { + return false; + } + // Buffered input has to reach the value first, or it reads as the old valid one. + _flushEditors(beans); + _populateModelValidationErrors(beans); + // Cell-scoped: a row-level error, or a sibling's, is not this value's fault, and reverting cannot fix it. + return !!beans.editModelSvc?.getCellValidationModel().hasCellValidation(cellCtrl); +}; - if (!editor) { - continue; +/** + * A popup editor closed — clicked away, Escape, or focus moved on. That is this cell's stop and no more: + * the row or batch it belongs to keeps its own session, and block mode still holds an invalid value. + */ +export const _onPopupEditorClosed = ( + beans: BeanCollection, + cellCtrl: CellCtrl, + e?: MouseEvent | TouchEvent | KeyboardEvent +): void => { + const editSvc = beans.editSvc; + if (!editSvc?.isEditing(cellCtrl, { withOpenEditor: true })) { + return; + } + + const isKeyboardEvent = e instanceof KeyboardEvent; + const isMouseEvent = e instanceof MouseEvent; + + const isEscape = isKeyboardEvent && e.key === KeyCode.ESCAPE; + + const fullRow = beans.gos.get('editType') === 'fullRow'; + const revertBlockedInvalid = !isEscape && isPopupEditBlockedInvalid(beans, cellCtrl); + const batch = editSvc.isBatchEditing(); + + // Full-row owns its own stop (Escape, Enter, grid focus loss); ending it because a popup closed + // would commit — or, when blocked, discard — every sibling at whatever value it happens to hold. + const { rowNode, column } = cellCtrl; + if (!isEscape && fullRow && editSvc.isRowEditing(rowNode, { checkSiblings: true })) { + if (revertBlockedInvalid) { + editSvc.revertCellEdit({ rowNode, column }); + } else { + editSvc.closeCellEditor({ rowNode, column }); } + return; + } - const { editorValue, editorValueExists, isCancelAfterEnd } = _valueFromEditor(beans, editor, params); + // Mid-batch a mouse-driven cancel is a no-op (only Escape cancels), so revert this cell + // directly; that also keeps any earlier staged value, as per-cell Escape does. + if (revertBlockedInvalid && batch) { + editSvc.revertCellEdit({ rowNode, column }); + return; + } - if (isCancelAfterEnd) { - const { cellStartedEditing, cellStoppedEditing } = beans.editModelSvc?.getEdit(cellId)?.editorState || {}; - beans.editModelSvc?.setEdit(cellId, { - editorState: { isCancelAfterEnd, cellStartedEditing, cellStoppedEditing }, - }); + // note: this happens because of a click outside of the grid or if the popupEditor + // is closed with `Escape` key. if another cell was clicked, then the editing will + // have already stopped and returned on the conditional above. + editSvc.stopEditing(cellCtrl, { + source: batch ? 'ui' : 'api', + cancel: isEscape || revertBlockedInvalid, + event: isKeyboardEvent || isMouseEvent ? e : undefined, + }); + + if (isEscape) { + cellCtrl.focusCell({ forceBrowserFocus: true, sourceEvent: e }); + } +}; + +export const _syncFromEditors = ( + beans: BeanCollection, + params: { persist: boolean; isCancelling?: boolean; isStopping?: boolean } +): void => { + // As in _flushEditors, and for the same reason: this runs per keystroke, and getEditPositions would + // allocate a position plus a copy of every edit value to reach the two fields read here. + const position = {} as Required; + beans.editModelSvc?.getEditMap()?.forEach((editRow, rowNode) => { + position.rowNode = rowNode; + for (const column of editRow.keys()) { + position.column = column; + _syncFromEditorComp(beans, position, params); } + }); +}; - _syncFromEditor(beans, cellId, editorValue, undefined, !editorValueExists, params); +/** The single-cell half of {@link _syncFromEditors}: stages one open editor's value into the model. */ +export const _syncFromEditorComp = ( + beans: BeanCollection, + position: Required, + params: { persist: boolean; isCancelling?: boolean; isStopping?: boolean } +): void => { + const editor = _getCellCtrl(beans, position)?.comp?.getCellEditor(); + + if (!editor) { + return; } -} + + const { editorValue, editorValueExists, isCancelAfterEnd } = _valueFromEditor(beans, editor, params); + + if (isCancelAfterEnd) { + const { cellStartedEditing, cellStoppedEditing } = beans.editModelSvc?.getEdit(position)?.editorState || {}; + beans.editModelSvc?.setEdit(position, { + editorState: { isCancelAfterEnd, cellStartedEditing, cellStoppedEditing }, + }); + } + + _syncFromEditor(beans, position, editorValue, undefined, !editorValueExists, params); +}; export function _syncFromEditor( beans: BeanCollection, @@ -490,23 +610,22 @@ function _persistEditorValue(beans: BeanCollection, position: Required[], - params: { event?: Event; silent?: boolean; cancel?: boolean } = {} + edits: Required[], + params: DestroyEditorParams = {} ): void { - if (!edits) { - edits = beans.editModelSvc?.getEditPositions(); - } - - if (edits) { - for (const cellPosition of edits) { - _destroyEditor(beans, cellPosition, params); - } + for (let i = 0, len = edits.length; i < len; ++i) { + _destroyEditor(beans, edits[i], params); } } -type DestroyEditorParams = { event?: Event | null; silent?: boolean; cancel?: boolean }; +export type DestroyEditorParams = { + event?: Event | null; + silent?: boolean; + cancel?: boolean; +}; export function _destroyEditor( beans: BeanCollection, @@ -531,7 +650,17 @@ export function _destroyEditor( if (!cellCtrl) { if (edit) { + // Unguarded: with the editor gone no probe can see its rules, so a guarded clear would + // strand the error and reject every later commit. + editModelSvc?.getCellValidationModel().clearCellValidation(position); editModelSvc?.setEdit(position, { state }); + dispatchStoppedIfFinished(beans, position, edit, params); + // The row survives even though the cell doesn't, so refresh its edit styles here — no cell + // ctrl remains to do it, and the row would keep its stale editing classes. + const rowCtrl = _getRowCtrl(beans, { rowNode: position.rowNode }); + if (rowCtrl) { + beans.editSvc?.applyRowEditStyles(rowCtrl); + } } return; @@ -546,20 +675,15 @@ export function _destroyEditor( if (edit) { editModelSvc?.setEdit(position, { state }); - const args = beans.gos.get('enableGroupEdit') - ? _enabledGroupEditStoppedArgs(edit, params?.cancel) - : { - valueChanged: false, - newValue: undefined, - oldValue: edit.sourceValue, - }; - dispatchEditingStopped(beans, position, args, params); + // Derived like every other stop: the model can still hold a pending value that differs from + // source here, so the args can't be hardcoded to "nothing changed". + dispatchStoppedIfFinished(beans, position, edit, params); } return; } - if (_hasValidationRules(beans)) { + if (beans.editSvc!.hasValidationRules()) { const errorMessages = edit && cellEditor?.getValidationErrors?.(); const cellValidationModel = editModelSvc?.getCellValidationModel(); @@ -571,6 +695,7 @@ export function _destroyEditor( } if (edit) { + // hasOpenEditors under-reports until setEditDetails below: the edit is closed, the editor isn't. editModelSvc?.setEdit(position, { state }); } @@ -579,15 +704,7 @@ export function _destroyEditor( cellCtrl?.refreshCell({ force: true, suppressFlash: true }); - const latest = editModelSvc?.getEdit(position); - - if (latest && latest.state !== 'editing') { - const cancel = params?.cancel; - const args = beans.gos.get('enableGroupEdit') - ? _enabledGroupEditStoppedArgs(latest, cancel) - : _cellEditStoppedArgs(latest, edit, cancel); - dispatchEditingStopped(beans, position, args, params); - } + dispatchStoppedIfFinished(beans, position, edit, params); } type EditingStoppedArgs = Partial>; @@ -638,6 +755,24 @@ function _cellEditStoppedArgs( }; } +/** Fires cellEditingStopped once the edit has left the 'editing' state, with the right event args. */ +const dispatchStoppedIfFinished = ( + beans: BeanCollection, + position: Required, + edit: Readonly | undefined, + params: DestroyEditorParams +): void => { + const latest = beans.editModelSvc?.getEdit(position); + if (!latest || latest.state === 'editing') { + return; + } + const cancel = params?.cancel; + const args = beans.gos.get('enableGroupEdit') + ? _enabledGroupEditStoppedArgs(latest, cancel) + : _cellEditStoppedArgs(latest, edit, cancel); + dispatchEditingStopped(beans, position, args, params); +}; + function dispatchEditingStopped( beans: BeanCollection, position: Required, @@ -660,27 +795,8 @@ function dispatchEditingStopped( } } -function _columnDefsRequireValidation(cols: AgColumn[]): boolean { - for (let i = 0, len = cols.length; i < len; ++i) { - const colDef = cols[i].colDef; - const params = colDef.cellEditorParams; - if (!params || (!colDef.editable && !colDef.groupRowEditable)) { - continue; - } - if ( - params.minLength !== undefined || - params.maxLength !== undefined || - params.getValidationErrors !== undefined || - params.min !== undefined || - params.max !== undefined - ) { - return true; - } - } - return false; -} - -function _editorsRequireValidation(beans: BeanCollection): boolean { +/** The uncached scan behind {@link EditService.editorsRequireValidation}, which owns the memo. */ +export function _scanEditorsForValidation(beans: BeanCollection): boolean { const ctrls = beans.rowRenderer.getCellCtrls(); for (let i = 0, len = ctrls.length; i < len; ++i) { const ctrl = ctrls[i]; @@ -695,23 +811,15 @@ function _editorsRequireValidation(beans: BeanCollection): boolean { return false; } -function _hasValidationRules(beans: BeanCollection): boolean { - return ( - !!beans.gos.get('getFullRowEditValidationErrors') || - _columnDefsRequireValidation(beans.colModel.colDefList) || - _editorsRequireValidation(beans) - ); -} - export function _populateModelValidationErrors(beans: BeanCollection, force?: boolean): void { - if (!(force || _hasValidationRules(beans))) { + const editSvc = beans.editSvc; + if (!(force || editSvc?.hasValidationRules() || beans.editModelSvc?.hasValidationErrors())) { return; } const cellValidationModel = new EditCellValidationModel(); - const { ariaAnnounce, localeSvc, editModelSvc, gos } = beans; - const includeRows = gos.get('editType') === 'fullRow'; + const { ariaAnnounce, localeSvc, editModelSvc } = beans; const translate = _getLocaleTextFunc(localeSvc); const ariaValidationErrorPrefix = translate('ariaValidationErrorPrefix', 'Cell Editor Validation'); const rowCtrlSet = new Set(); @@ -762,21 +870,26 @@ export function _populateModelValidationErrors(beans: BeanCollection, force?: bo // the second loop over mappedEditor below editModelSvc?.setCellValidationModel(cellValidationModel); - if (includeRows) { - const rowValidations = _generateRowValidationErrors(beans); - editModelSvc?.setRowValidationModel(rowValidations); - } + _populateRowValidationErrors(beans); for (const rowCtrl of rowCtrlSet.values()) { - beans.editSvc?.applyRowEditStyles(rowCtrl); + editSvc?.applyRowEditStyles(rowCtrl); for (const cellCtrl of rowCtrl.getAllCellCtrls()) { cellCtrl.tooltipFeature?.refreshTooltip(true); cellCtrl.editorTooltipFeature?.refreshTooltip(true); - _applyCellEditStyles(beans, cellCtrl); + editSvc?.applyCellEditStyles(cellCtrl); } } } +/** The row half of {@link _populateModelValidationErrors}: cell errors, and the editors behind them, untouched. + * Row rules only ever apply to a full row, so any other edit type leaves the map as it is — empty. */ +export const _populateRowValidationErrors = (beans: BeanCollection): void => { + if (beans.gos.get('editType') === 'fullRow') { + beans.editModelSvc?.setRowValidationModel(_generateRowValidationErrors(beans)); + } +}; + const _generateRowValidationErrors = (beans: BeanCollection): EditRowValidationModel => { const rowValidationModel = new EditRowValidationModel(); const getFullRowEditValidationErrors = beans.gos.get('getFullRowEditValidationErrors'); @@ -820,12 +933,7 @@ const _generateRowValidationErrors = (beans: BeanCollection): EditRowValidationM const errorMessages = getFullRowEditValidationErrors?.({ editorsState }) ?? []; if (errorMessages.length > 0) { - rowValidationModel.setRowValidation( - { - rowNode, - }, - { errorMessages } - ); + rowValidationModel.setRowValidation({ rowNode }, { errorMessages }); } } @@ -834,7 +942,11 @@ const _generateRowValidationErrors = (beans: BeanCollection): EditRowValidationM export function _validateEdit(beans: BeanCollection): ICellEditorValidationError[] | null { _populateModelValidationErrors(beans, true); + return _readEditValidationErrors(beans); +} +/** The read half of {@link _validateEdit}: reports the validation state as it stands, changing nothing. */ +export function _readEditValidationErrors(beans: BeanCollection): ICellEditorValidationError[] | null { const map = beans.editModelSvc?.getCellValidationModel().getCellValidationMap(); if (!map) { diff --git a/packages/ag-grid-community/src/edit/utils/refresh.ts b/packages/ag-grid-community/src/edit/utils/refresh.ts index 8fdc8f1e401..61120234619 100644 --- a/packages/ag-grid-community/src/edit/utils/refresh.ts +++ b/packages/ag-grid-community/src/edit/utils/refresh.ts @@ -1,38 +1,88 @@ import type { BeanCollection } from '../../context/context'; -import type { Column } from '../../interfaces/iColumn'; +import type { EditPosition } from '../../interfaces/iEditService'; import type { IRowNode } from '../../interfaces/iRowNode'; +import { _getRowCtrl } from './controllers'; +import type { DestroyEditorParams } from './editors'; +import { _destroyEditor, _populateModelValidationErrors } from './editors'; -const purgeRows = ( - { rowModel, pinnedRowModel, editModelSvc }: BeanCollection, - rowNodes: Set -): Set => { - const found = new Set(); - - rowModel.forEachNode((node) => rowNodes.has(node) && found.add(node)); - pinnedRowModel?.forEachPinnedRow('top', (node) => rowNodes.has(node) && found.add(node)); - pinnedRowModel?.forEachPinnedRow('bottom', (node) => rowNodes.has(node) && found.add(node)); +/** Drops edits on pinned rows that left their section: static pinned rows are dropped without being + * destroyed and without reporting it, so membership has to be re-tested. */ +export const _purgeStalePinnedEdits = (beans: BeanCollection) => () => { + const editMap = beans.editModelSvc?.getEditMap(); + if (!editMap?.size) { + return; + } - for (const rowNode of rowNodes) { - if (!found.has(rowNode)) { - editModelSvc!.removeEdits({ rowNode }); + const pinnedRowModel = beans.pinnedRowModel; + let stale: Required[] | undefined; + editMap.forEach((editRow, rowNode) => { + const pinned = rowNode.rowPinned; + const id = rowNode.id; + // No id means membership can't be tested, so keep the edit rather than discard it on a guess. + if (pinned == null || id == null || pinnedRowModel?.getPinnedRowById(id, pinned) === rowNode) { + return; } - } + for (const column of editRow.keys()) { + stale ??= []; + stale.push({ rowNode, column }); + } + }); - return found; + if (stale) { + _purgeEdits(beans, stale); + } }; -const purgeCells = ({ editModelSvc }: BeanCollection, rowNodes: Set, columns: Set): void => { - for (const rowNode of rowNodes) { - editModelSvc - ?.getEditRow(rowNode) - ?.forEach((_, column) => !columns.has(column) && editModelSvc.removeEdits({ rowNode, column })); +/** + * Ends `positions` as edits: the editor stops (firing cellEditingStopped and clearing its validation), the + * model entry goes, and a row left holding nothing is released from the strategy's started-rows bookkeeping. + */ +export const _purgeEdits = (beans: BeanCollection, positions: Required[]): void => { + const editModelSvc = beans.editModelSvc!; + const editSvc = beans.editSvc!; + + // cancel: nothing wrote a value, so the stopped event must not advertise one. + const params: DestroyEditorParams = { cancel: true }; + const cellValidations = editModelSvc.getCellValidationModel(); + const touchedRows = new Set(); + for (let i = 0, len = positions.length; i < len; ++i) { + const position = positions[i]; + // Via _destroyEditor so an open editor still fires cellEditingStopped; clearing after it, since it + // re-reads the still-attached editor and would re-register the error it is about to strand. + _destroyEditor(beans, position, params); + cellValidations.clearCellValidation(position); + editModelSvc.removeEdits(position); + touchedRows.add(position.rowNode); } -}; -export const _refreshEditCells = (beans: BeanCollection) => () => { - const columns = new Set(beans.colModel.getCols()); - // Only the row keys are read here — no need to deep-copy the whole edit map. - const rowNodes = new Set(beans.editModelSvc!.getEditMap()?.keys()); + // A row purged down to nothing never reaches the stop pipeline, so the strategy would keep it in its + // started-rows bookkeeping — no rowEditingStopped, and the detached node held alive. + const releasedRows = new Set(); + const rowValidations = editModelSvc.getRowValidationModel(); + let retainedEdits = false; + for (const rowNode of touchedRows) { + if (editModelSvc.getEditRow(rowNode)?.size) { + retainedEdits = true; + } else { + releasedRows.add(rowNode); + rowValidations.clearRowValidation({ rowNode }); + } + } + + // The recorded row error was computed from an edit set that no longer exists, and what is left of the row + // may break the rule on its own — so recompute rather than clear, and before the rows restyle below. + if (retainedEdits) { + _populateModelValidationErrors(beans); + } - purgeCells(beans, purgeRows(beans, rowNodes), columns); + for (const rowNode of touchedRows) { + // Only once the edits are gone, or the row restyles as still-editing. + const rowCtrl = _getRowCtrl(beans, { rowNode }); + if (rowCtrl) { + editSvc.applyRowEditStyles(rowCtrl); + } + } + if (releasedRows.size) { + editSvc.releasePurgedRows(releasedRows); + } }; diff --git a/packages/ag-grid-community/src/entities/agColumn.ts b/packages/ag-grid-community/src/entities/agColumn.ts index 08d10f74709..ea372562177 100644 --- a/packages/ag-grid-community/src/entities/agColumn.ts +++ b/packages/ag-grid-community/src/entities/agColumn.ts @@ -267,6 +267,7 @@ export class AgColumn this.initCalculatedColumnState(colDef); return false; } + ++this.beans.colModel.colDefsVersion; // a real colDef change invalidates anything derived from them this.cachedSortTypes = null; // sort/initialSort/sortingOrder may have changed this.initColDefHotFields(); this.beans.showValuesAsSvc?.resolveColumn(this, false); // colDef change — `initialShowValuesAs` is create-only diff --git a/packages/ag-grid-community/src/entities/rowNode.ts b/packages/ag-grid-community/src/entities/rowNode.ts index 5915774662c..c0275298d9f 100644 --- a/packages/ag-grid-community/src/entities/rowNode.ts +++ b/packages/ag-grid-community/src/entities/rowNode.ts @@ -959,11 +959,15 @@ export class RowNode } this.destroyed = true; + const beans = this.beans; + // Before the position is cleared below: the editor stop reads the row where it actually was. + beans.editSvc?.releaseRowEdits(this); + // Unpin my clone if I'm the source. Only clones have rowPinned (see _createPinnedSibling), // so this naturally no-ops when the recursive destroy hits the clone. const pinnedSibling = this.pinnedSibling; if (pinnedSibling?.rowPinned) { - this.beans.pinnedRowModel?.pinRow(pinnedSibling, null); + beans.pinnedRowModel?.pinRow(pinnedSibling, null); } if (fadeOut === true) { diff --git a/packages/ag-grid-community/src/interfaces/iCellEditor.ts b/packages/ag-grid-community/src/interfaces/iCellEditor.ts index 5fbc98ebf6b..963782e0384 100644 --- a/packages/ag-grid-community/src/interfaces/iCellEditor.ts +++ b/packages/ag-grid-community/src/interfaces/iCellEditor.ts @@ -63,6 +63,8 @@ export interface BaseCellEditor { */ export interface AgBaseCellEditor extends ICellEditor { agSetEditValue(value: TValue | null | undefined): void; + /** Commit any buffered input to the value before the grid reads it on stop (e.g. a Firefox date segment). */ + agFlushInput?(): void; } export interface ICellEditor extends BaseCellEditor { diff --git a/packages/ag-grid-community/src/pinnedRowModel/manualPinnedRowUtils.ts b/packages/ag-grid-community/src/pinnedRowModel/manualPinnedRowUtils.ts index a78508c0743..be3a607c24d 100644 --- a/packages/ag-grid-community/src/pinnedRowModel/manualPinnedRowUtils.ts +++ b/packages/ag-grid-community/src/pinnedRowModel/manualPinnedRowUtils.ts @@ -39,6 +39,7 @@ export class PinnedRows { } public delete(item: RowNode): void { + this.beans.editSvc?.releaseRowEdits(item); this.all.delete(item); this.visible.delete(item); this.queued.delete(item.id!); diff --git a/packages/ag-grid-community/src/rendering/cell/cellComp.ts b/packages/ag-grid-community/src/rendering/cell/cellComp.ts index e5f344e3dec..97e4d931548 100644 --- a/packages/ag-grid-community/src/rendering/cell/cellComp.ts +++ b/packages/ag-grid-community/src/rendering/cell/cellComp.ts @@ -494,7 +494,7 @@ export class CellComp extends Component { cellEditor.afterGuiAttached?.(); this.cellCtrl.enableEditorTooltipFeature(cellEditor); - this.cellCtrl.cellEditorAttached(); + this.beans.editSvc?.onEditorAttached(this.cellCtrl); } public refreshEditStyles(editing: boolean, isPopup?: boolean): void { @@ -569,7 +569,7 @@ export class CellComp extends Component { eChild: ePopupGui, closeOnEsc: true, closedCallback: (e) => { - cellCtrl.onPopupEditorClosed(e); + editSvc!.onPopupEditorClosed(cellCtrl, e); }, anchorToElement: eCell, positionCallback, diff --git a/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts b/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts index c5999f08972..e9b0a925070 100644 --- a/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts +++ b/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts @@ -1,6 +1,5 @@ import { AgPromise, - KeyCode, _addOrRemoveAttribute, _findFocusableElements, _getActiveDomElement, @@ -34,6 +33,7 @@ import type { ICellEditor } from '../../interfaces/iCellEditor'; import type { CellPosition } from '../../interfaces/iCellPosition'; import type { ICellRangeFeature } from '../../interfaces/iCellRangeFeature'; import type { RefreshCellsParams } from '../../interfaces/iCellsParams'; +import type { StartEditParams } from '../../interfaces/iEditService'; import type { CellChangedEvent } from '../../interfaces/iRowNode'; import type { RowPosition } from '../../interfaces/iRowPosition'; import type { UserCompDetails } from '../../interfaces/iUserCompDetails'; @@ -145,8 +145,8 @@ export class CellCtrl extends BeanStub { // this comp used only for custom row drag handle (ie when user calls params.registerRowDragger) private customRowDragComp: RowDragComp; - public onCompAttachedFuncs: (() => void)[] = []; - public onEditorAttachedFuncs: (() => void)[] = []; + /** A start requested before this cell had a component, replayed by the edit service once it attaches. */ + public pendingEditStart: StartEditParams | null = null; private focusEventWhileNotReady: CellFocusedEvent | null = null; // if cell has been focused, check if it's focused when destroyed @@ -290,27 +290,12 @@ export class CellCtrl extends BeanStub { this.rangeFeature?.setComp(comp); this.rowResizeFeature?.refreshRowResizer(); - const editable = startEditing ? this.isCellEditable() : undefined; - const continuingEdit = !editable && this.hasEdit && this.editSvc?.isEditing(this, { withOpenEditor: true }); - - if (editable || continuingEdit) { - this.editSvc?.startEditing(this, { - startedEdit: false, - source: 'api', - silent: true, - continueEditing: true, - editable, - }); - } else { + if (!this.editSvc?.onCompAttached(this, startEditing)) { // We can skip refreshing the range handle as this is done in this.rangeFeature.setComp above this.showValue(false, true); } - - if (this.onCompAttachedFuncs.length) { - for (const func of this.onCompAttachedFuncs) { - func(); - } - this.onCompAttachedFuncs = []; + if (this.pendingEditStart) { + this.editSvc!.replayPendingStart(this); } } @@ -523,31 +508,6 @@ export class CellCtrl extends BeanStub { return selectionChanged || rowDragChanged || dndSourceChanged || autoHeightChanged; } - public onPopupEditorClosed(e?: MouseEvent | TouchEvent | KeyboardEvent): void { - const { editSvc } = this.beans; - if (!editSvc?.isEditing(this, { withOpenEditor: true })) { - return; - } - - const isKeyboardEvent = e instanceof KeyboardEvent; - const isMouseEvent = e instanceof MouseEvent; - - const isEscape = isKeyboardEvent && e.key === KeyCode.ESCAPE; - - // note: this happens because of a click outside of the grid or if the popupEditor - // is closed with `Escape` key. if another cell was clicked, then the editing will - // have already stopped and returned on the conditional above. - editSvc.stopEditing(this, { - source: editSvc.isBatchEditing() ? 'ui' : 'api', - cancel: isEscape, - event: isKeyboardEvent || isMouseEvent ? e : undefined, - }); - - if (isEscape) { - this.focusCell({ forceBrowserFocus: true, sourceEvent: e }); - } - } - /** * Ends the Cell Editing * @param cancel `True` if the edit process is being canceled. @@ -1114,8 +1074,7 @@ export class CellCtrl extends BeanStub { } public override destroy(): void { - this.onCompAttachedFuncs = []; - this.onEditorAttachedFuncs = []; + this.editSvc?.onCellDestroyed(this); // if this was focused; (e.g cell span status changes) then we need to restore focus if (this.isCellFocused() && this.hasBrowserFocus()) { @@ -1197,13 +1156,6 @@ export class CellCtrl extends BeanStub { return rowDragComp; } - public cellEditorAttached(): void { - for (const func of this.onEditorAttachedFuncs) { - func(); - } - this.onEditorAttachedFuncs = []; - } - public setFocusedCellPosition(_cellPosition: CellPosition): void { // noop, used by spannedCellCtrl } diff --git a/packages/ag-grid-community/src/rendering/cell/cellKeyboardListenerFeature.ts b/packages/ag-grid-community/src/rendering/cell/cellKeyboardListenerFeature.ts index d98b89b8803..8af89b676c5 100644 --- a/packages/ag-grid-community/src/rendering/cell/cellKeyboardListenerFeature.ts +++ b/packages/ag-grid-community/src/rendering/cell/cellKeyboardListenerFeature.ts @@ -153,9 +153,7 @@ function onEnterKeyDown(beans: BeanCollection, cellCtrl: CellCtrl, event: Keyboa return; } - // re-run ALL validations, Enter key is used to commit the edit, so we want to ensure it's valid - editSvc?.populateModelValidationErrors(); - + // Enter commits, so validity must be current: checkNavWithValidation re-runs ALL validations first. if (editSvc?.checkNavWithValidation(undefined, event) === 'block-stop') { return; } @@ -175,11 +173,12 @@ function onEnterKeyDown(beans: BeanCollection, cellCtrl: CellCtrl, event: Keyboa const key = event.shiftKey ? KeyCode.UP : KeyCode.DOWN; navigation?.navigateToNextCell(null, key, cellCtrl.cellPosition, false); } else { - if (editSvc?.hasValidationErrors()) { + if (editSvc?.revalidateAndCheck()) { return; } - if (editSvc?.hasValidationErrors(cellCtrl)) { + // Already revalidated above; only the scoped answer is still needed. + if (editSvc?.checkValidated(cellCtrl)) { editSvc.revertSingleCellEdit(cellCtrl, true); } @@ -209,10 +208,8 @@ function onF2KeyDown(beans: BeanCollection, cellCtrl: CellCtrl, event: KeyboardE } if (editing) { - // re-run ALL validations, F2 is used to initiate a new edit. If we have one already in progress, - // we want to ensure it's valid before initiating a new edit cycle - editSvc?.populateModelValidationErrors(); - + // F2 starts a new edit cycle, so an in-progress one must be checked against fresh validations — + // checkNavWithValidation re-runs them all first. if (editSvc?.checkNavWithValidation(undefined, event) === 'block-stop') { return; } @@ -261,7 +258,7 @@ export function _processCellCharacter(beans: BeanCollection, cellCtrl: CellCtrl, if (key === KeyCode.SPACE) { onSpaceKeyDown(beans, cellCtrl, event); } else if (editSvc?.isCellEditable(cellCtrl, 'ui')) { - if (editSvc?.hasValidationErrors() && !editSvc?.hasValidationErrors(cellCtrl)) { + if (editSvc?.revalidateAndCheck() && !editSvc.checkValidated(cellCtrl)) { return; } diff --git a/packages/ag-grid-enterprise/e2e/rich-select-scroll-picker.spec.ts b/packages/ag-grid-enterprise/e2e/rich-select-scroll-picker.spec.ts new file mode 100644 index 00000000000..30aae990fd0 --- /dev/null +++ b/packages/ag-grid-enterprise/e2e/rich-select-scroll-picker.spec.ts @@ -0,0 +1,127 @@ +import { expect, test } from '@playwright/test'; +import path from 'node:path'; + +// Needs a real browser: the picker only closes when native layout and scrolling are involved. + +const enterpriseUmd = path.join(__dirname, '..', 'dist', 'ag-grid-enterprise.js'); + +const LANGUAGES = ['English', 'Spanish', 'French', 'Portuguese', '(other)']; + +async function createGrid(page: import('@playwright/test').Page): Promise { + await page.setViewportSize({ width: 800, height: 600 }); + await page.setContent( + '' + + '
' + ); + await page.addScriptTag({ path: enterpriseUmd }); + await page.evaluate((languages) => { + const gridDiv = document.querySelector('#myGrid')!; + (window as any).gridApi = (window as any).agGrid.createGrid(gridDiv, { + defaultColDef: { width: 200, editable: true }, + columnDefs: [ + // Wide first column pushes the second column off-screen (as in the reported plnkr). + { + headerName: 'Sync', + field: 'language', + cellEditor: 'agRichSelectCellEditor', + cellEditorParams: { values: languages }, + width: 800, + }, + // The reported column: filterList + allowTyping. + { + field: 'language', + cellEditor: 'agRichSelectCellEditor', + cellEditorParams: { values: languages, filterList: true, allowTyping: true }, + }, + ], + rowData: new Array(100).fill(null).map(() => ({ language: 'English' })), + }); + }, LANGUAGES); + await expect(page.locator('.ag-root-wrapper')).toBeVisible(); +} + +const pickerRows = (page: import('@playwright/test').Page) => page.locator('.ag-rich-select-row'); + +/** Screen rect of the row-0 cell in the given (rendered) column index. */ +async function cellRect(page: import('@playwright/test').Page, colIndex: number) { + return page.evaluate((idx) => { + const cells = document.querySelectorAll('#myGrid [row-index="0"] [col-id]'); + const r = cells[idx].getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, height: r.height }; + }, colIndex); +} + +test.describe('Rich Select picker on a barely-visible scrolled column (AG-16390)', () => { + test.beforeEach(async ({ page }) => { + await createGrid(page); + }); + + test('baseline: F2 opens the picker on a fully-visible column cell', async ({ page }) => { + const rect = await cellRect(page, 0); + await page.mouse.click(rect.left + 20, rect.top + rect.height / 2); + await page.keyboard.press('F2'); + await expect(pickerRows(page).first()).toBeVisible(); + }); + + test('F2 opens the picker on a barely-visible scrolled-in column cell', async ({ page }) => { + // Slowly scroll right so the second column is only barely visible on the right edge. + await page.mouse.move(400, 150); + await page.mouse.wheel(40, 0); + await page.waitForTimeout(150); + + // The second column's row-0 cell is now a thin visible sliver; click it, then F2. + const rect = await cellRect(page, 1); + await page.mouse.click(rect.left + 4, rect.top + rect.height / 2); + await page.keyboard.press('F2'); + + // Editing starts, but the values popup must also be shown (the bug: it is not). + await expect(pickerRows(page).first()).toBeVisible(); + }); +}); + +test.describe('Rich Select picker on a barely-visible scrolled row (AG-16390, vertical)', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 600, height: 400 }); + await page.setContent( + '' + + '
' + ); + await page.addScriptTag({ path: enterpriseUmd }); + await page.evaluate((languages) => { + (window as any).gridApi = (window as any).agGrid.createGrid( + document.querySelector('#myGrid')!, + { + defaultColDef: { width: 250, editable: true }, + columnDefs: [ + { field: 'a', cellEditor: 'agRichSelectCellEditor', cellEditorParams: { values: languages } }, + ], + rowData: new Array(100).fill(null).map(() => ({ a: 'English' })), + } + ); + }, LANGUAGES); + await expect(page.locator('.ag-root-wrapper')).toBeVisible(); + }); + + test('F2 opens the picker on a barely-visible scrolled-in row cell', async ({ page }) => { + // Scroll down a partial row so a cell sits as a thin sliver at the bottom edge. + await page.mouse.move(150, 150); + await page.mouse.wheel(0, 55); + await page.waitForTimeout(150); + + const rect = await page.evaluate(() => { + const gridBottom = document.querySelector('#myGrid')!.getBoundingClientRect().bottom; + for (const c of Array.from(document.querySelectorAll('#myGrid [row-index] [col-id="a"]'))) { + const r = c.getBoundingClientRect(); + if (r.top < gridBottom - 3 && r.bottom > gridBottom + 3) { + return { left: r.left, top: r.top }; + } + } + throw new Error('no bottom-straddling row cell found'); + }); + // Click the visible sliver near its top edge, then F2. + await page.mouse.click(rect.left + 20, rect.top + 3); + await page.keyboard.press('F2'); + + await expect(pickerRows(page).first()).toBeVisible(); + }); +}); diff --git a/packages/ag-grid-enterprise/src/richSelect/richSelectCellEditor.ts b/packages/ag-grid-enterprise/src/richSelect/richSelectCellEditor.ts index 8a8c38f0577..343fd80c7f3 100644 --- a/packages/ag-grid-enterprise/src/richSelect/richSelectCellEditor.ts +++ b/packages/ag-grid-enterprise/src/richSelect/richSelectCellEditor.ts @@ -381,7 +381,9 @@ export class RichSelectCellEditor ext if (focusAfterAttached) { const focusableEl = richSelect.getFocusableElement() as HTMLInputElement; - focusableEl.focus(); + // preventScroll: focusing a partly-visible cell would scroll it into view, and that scroll + // arrives after showPicker() below has wired its bodyScroll listener, closing the picker. + focusableEl.focus({ preventScroll: true }); if (allowTyping && eventKey?.length !== 1) { focusableEl.select(); diff --git a/packages/ag-grid-react/src/reactUi/cells/cellComp.tsx b/packages/ag-grid-react/src/reactUi/cells/cellComp.tsx index 7e2b629e64e..d9c8fccc66c 100644 --- a/packages/ag-grid-react/src/reactUi/cells/cellComp.tsx +++ b/packages/ag-grid-react/src/reactUi/cells/cellComp.tsx @@ -99,7 +99,7 @@ const CellComp = ({ cellCtrl.stopEditing(true); cellCtrl.focusCell({ forceBrowserFocus: true }); } else { - cellCtrl.cellEditorAttached(); + beans.editSvc?.onEditorAttached(cellCtrl); cellCtrl.enableEditorTooltipFeature(cellEditor); } }); @@ -266,7 +266,7 @@ const CellComp = ({ const init = useCallback(() => { const spanReady = !cellCtrl.isCellSpanning() || eWrapper.current; const eRef = eGui.current; - if (!eRef || !spanReady || !cellCtrl || !cellCtrl.isAlive() || context.isDestroyed()) { + if (!eRef || !spanReady || !cellCtrl?.isAlive() || context.isDestroyed()) { compBean.current = context.destroyBean(compBean.current); return; } diff --git a/packages/ag-grid-react/src/reactUi/cells/popupEditorComp.tsx b/packages/ag-grid-react/src/reactUi/cells/popupEditorComp.tsx index 418bd3919b7..c0833ec6f66 100644 --- a/packages/ag-grid-react/src/reactUi/cells/popupEditorComp.tsx +++ b/packages/ag-grid-react/src/reactUi/cells/popupEditorComp.tsx @@ -58,7 +58,7 @@ const PopupEditorComp = (props: { eChild: ePopupGui, closeOnEsc: true, closedCallback: (e?: MouseEvent | TouchEvent | KeyboardEvent) => { - cellCtrl.onPopupEditorClosed(e); + editSvc!.onPopupEditorClosed(cellCtrl, e); }, anchorToElement: eParentCell, positionCallback, diff --git a/testing/behavioural/src/cell-editing/cell-editing-batch.test.ts b/testing/behavioural/src/cell-editing/cell-editing-batch.test.ts index acaec540bbc..0aa8554b8e1 100644 --- a/testing/behavioural/src/cell-editing/cell-editing-batch.test.ts +++ b/testing/behavioural/src/cell-editing/cell-editing-batch.test.ts @@ -341,4 +341,75 @@ describe('Cell Editing Batch', () => { expect(stringCell).not.toHaveClass(/ag-cell-batch-edit/); expect(api.getDisplayedRowAtIndex(0)?.data?.string1).toBe('test'); }); + + // Mid-batch Escape closes the editor without ending the batch. It is still a close, so it must + // balance its cellEditingStarted — listeners tracking open editors otherwise never see it end. + test('batch: Escape on an untouched editor still fires cellEditingStopped', async () => { + const api = await gridMgr.createGridAndWait('batchEscapeEvents', { + columnDefs: [{ field: 'string1' }, { field: 'number' }], + rowData: rowDataFactory(), + defaultColDef: { editable: true }, + }); + + const started: any[] = []; + const stopped: any[] = []; + api.addEventListener('cellEditingStarted', (e) => started.push(e)); + api.addEventListener('cellEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + api.setFocusedCell(0, 'string1'); + api.startEditingCell({ rowIndex: 0, colKey: 'string1' }); + await asyncSetTimeout(1); + + expect(started).toHaveLength(1); + expect(stopped).toHaveLength(0); + + await userEvent.keyboard('{Escape}'); + await asyncSetTimeout(1); + + expect(api.getCellEditorInstances()).toHaveLength(0); + expect(stopped).toHaveLength(1); + }); + + // Reverting mid-batch re-creates the editor before closing it, but the session that fired + // cellEditingStarted is the same one: every editor closed here still has to report it. + test.each(['singleCell', 'fullRow'] as const)( + 'batch (%s): api.stopEditing(true) fires cellEditingStopped for every editor it closes', + async (editType) => { + const api = await gridMgr.createGridAndWait(`batchApiCancel-${editType}`, { + columnDefs: [{ field: 'string1' }, { field: 'number' }], + rowData: rowDataFactory(), + defaultColDef: { editable: true }, + editType, + }); + + const started: any[] = []; + const stopped: any[] = []; + api.addEventListener('cellEditingStarted', (e) => started.push(e)); + api.addEventListener('cellEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const grid = getGridElement(api)! as HTMLElement; + const cell = grid.querySelector('[row-index="0"] [col-id="string1"]')!; + await userEvent.dblClick(cell); + const input = await waitForInput(grid, cell); + await userEvent.clear(input); + await userEvent.type(input, 'typed'); + await asyncSetTimeout(1); + + expect(started.length).toBeGreaterThan(0); + expect(stopped).toHaveLength(0); + + api.stopEditing(true); // cancels the in-flight edits, keeping the batch open + await asyncSetTimeout(1); + + expect(api.getCellEditorInstances()).toHaveLength(0); + expect(stopped).toHaveLength(started.length); + expect(api.getDisplayedRowAtIndex(0)?.data?.string1).toBe('test'); // nothing written + } + ); }); diff --git a/testing/behavioural/src/cell-editing/cell-editing-edge-cases.test.ts b/testing/behavioural/src/cell-editing/cell-editing-edge-cases.test.ts index 030c5f268e4..2eb2247f6a4 100644 --- a/testing/behavioural/src/cell-editing/cell-editing-edge-cases.test.ts +++ b/testing/behavioural/src/cell-editing/cell-editing-edge-cases.test.ts @@ -966,4 +966,32 @@ describe('Cell Editing: edge cases', () => { `); }); }); + + // isEditing answers about the cell it is given. A row with no controller (unrendered, or an index + // past the end) must report false, not fall back to "is anything in the grid editing". + test('isEditing is scoped to the given cell, not the rendered controller', async () => { + const api = await gridMgr.createGridAndWait('isEditingScoped', { + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData: [ + { id: 'ROW_0', a: 'A0', b: 'B0' }, + { id: 'ROW_1', a: 'A1', b: 'B1' }, + ], + defaultColDef: { editable: true }, + getRowId: (params) => params.data.id, + }); + + const colA = api.getColumn('a')!; + const colB = api.getColumn('b')!; + + api.startEditingCell({ rowIndex: 0, colKey: 'a' }); + await asyncSetTimeout(0); + + expect(api.isEditing({ rowIndex: 0, rowPinned: null, column: colA })).toBe(true); + // Same row, different column: rendered, so this was always correct. + expect(api.isEditing({ rowIndex: 0, rowPinned: null, column: colB })).toBe(false); + // Rendered row that is not editing. + expect(api.isEditing({ rowIndex: 1, rowPinned: null, column: colA })).toBe(false); + // No such row, so no controller — the answer must still be about this cell. + expect(api.isEditing({ rowIndex: 900, rowPinned: null, column: colA })).toBe(false); + }); }); diff --git a/testing/behavioural/src/cell-editing/cell-editing-full-row-batch-styles.test.ts b/testing/behavioural/src/cell-editing/cell-editing-full-row-batch-styles.test.ts index d005cb922c5..2bc1ae566ff 100644 --- a/testing/behavioural/src/cell-editing/cell-editing-full-row-batch-styles.test.ts +++ b/testing/behavioural/src/cell-editing/cell-editing-full-row-batch-styles.test.ts @@ -627,9 +627,10 @@ describe('Cell Editing: full-row batch styles', () => { const row0Still = cellA0Still.closest('[row-index="0"]'); expect(row0Still).toHaveClass(/ag-row-batch-edit/); + // Every editor that opened also closed, Escape included, so the counts match. expect(eventTracker.counts).toEqual({ cellEditingStarted: 5, - cellEditingStopped: 4, + cellEditingStopped: 5, cellValueChanged: 0, rowValueChanged: 0, cellEditRequest: 0, diff --git a/testing/behavioural/src/cell-editing/cell-editing-purge.test.ts b/testing/behavioural/src/cell-editing/cell-editing-purge.test.ts new file mode 100644 index 00000000000..c63a08ec334 --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-purge.test.ts @@ -0,0 +1,483 @@ +import { findByTestId } from '@testing-library/dom'; +import { userEvent } from '@testing-library/user-event'; + +import type { CellEditingStoppedEvent, GridApi, GridOptions } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + NumberEditorModule, + PinnedRowModule, + RenderApiModule, + TextEditorModule, + agTestIdFor, + getGridElement, + setupAgTestIds, +} from 'ag-grid-community'; +import { BatchEditModule, PivotModule, RowGroupingEditModule, RowGroupingModule } from 'ag-grid-enterprise'; + +import { GridRows, TestGridsManager, asyncSetTimeout, waitForInput } from '../test-utils'; + +/** + * A row or column leaving the grid ends its own edits rather than a sweep noticing later. These cover the + * departures that leave the node alive, which a destroy hook on its own would miss. + */ +describe('Cell editing: purging edits on departure', () => { + const gridsManager = new TestGridsManager({ + includeDefaultModules: true, + modules: [ + ClientSideRowModelModule, + NumberEditorModule, + PinnedRowModule, + TextEditorModule, + RenderApiModule, + BatchEditModule, + PivotModule, + RowGroupingModule, + RowGroupingEditModule, + ], + }); + + beforeAll(() => setupAgTestIds()); + afterEach(() => gridsManager.reset()); + + /** Polls rather than reads once: a row renders a tick or two after the state change that added it. */ + const cell = (api: GridApi, rowId: string, colId: string): Promise => + findByTestId(getGridElement(api)! as HTMLElement, agTestIdFor.cell(rowId, colId)); + + /** Opens the cell's editor and types `value` into it, leaving it uncommitted. */ + const type = async (api: GridApi, rowId: string, colId: string, value: string): Promise => { + const gridDiv = getGridElement(api)! as HTMLElement; + const target = await cell(api, rowId, colId); + await userEvent.dblClick(target); + const input = await waitForInput(gridDiv, target); + await userEvent.clear(input); + await userEvent.type(input, value); + await asyncSetTimeout(1); + }; + + /** Types `value` into the cell and commits with Enter, staging it as a pending batch edit. */ + const stage = async (api: GridApi, rowId: string, colId: string, value: string): Promise => { + await type(api, rowId, colId, `${value}{Enter}`); + }; + + // Entering pivot mode parks the primary columns out of colsList while leaving them alive, so a + // destroy-only hook would strand the edit staged on one. + test('entering pivot mode purges an edit staged on a parked primary column', async () => { + const api = await gridsManager.createGridAndWait('purge-pivot-enter', { + columnDefs: [ + { field: 'country', rowGroup: true }, + { field: 'sport', pivot: true }, + { field: 'gold', aggFunc: 'sum' }, + { field: 'note', editable: true }, + ], + rowData: [ + { id: '0', country: 'Russia', sport: 'Gymnastics', gold: 3, note: 'a' }, + { id: '1', country: 'USA', sport: 'Swimming', gold: 2, note: 'b' }, + ], + getRowId: (params) => params.data.id, + groupDefaultExpanded: -1, + } satisfies GridOptions); + + const stopped: CellEditingStoppedEvent[] = []; + api.addEventListener('cellEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + await new GridRows(api, 'pivot: grouped rows before any edit').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Russia ag-Grid-AutoColumn:"Russia" gold:3 + │ └── LEAF id:0 country:"Russia" sport:"Gymnastics" gold:3 note:"a" + └─┬ LEAF_GROUP id:row-group-country-USA ag-Grid-AutoColumn:"USA" gold:2 + · └── LEAF id:1 country:"USA" sport:"Swimming" gold:2 note:"b" + `); + + await stage(api, '1', 'note', 'EDITED'); + + expect(api.getEditingCells()).toHaveLength(1); + expect(stopped).toHaveLength(1); // the editor closed as it staged + + await new GridRows(api, 'pivot: note staged as pending on the primary column').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Russia ag-Grid-AutoColumn:"Russia" gold:3 + │ └── LEAF id:0 country:"Russia" sport:"Gymnastics" gold:3 note:"a" + └─┬ LEAF_GROUP id:row-group-country-USA ag-Grid-AutoColumn:"USA" gold:2 + · └── LEAF ⏳ id:1 country:"USA" sport:"Swimming" gold:2 note:⏳"EDITED" "b" + `); + + api.setGridOption('pivotMode', true); + await asyncSetTimeout(1); + + // The primary column is parked, so its staged edit went with it rather than lingering unreachable. + expect(api.getEditingCells()).toHaveLength(0); + expect(stopped).toHaveLength(1); // already stopped once; the purge must not fire a second + + await new GridRows(api, 'pivot: primaries parked, staged edit purged').check(` + ROOT id:ROOT_NODE_ID pivot_sport_Gymnastics_gold:3 pivot_sport_Swimming_gold:2 + ├─┬ LEAF_GROUP collapsed id:row-group-country-Russia ag-Grid-AutoColumn:"Russia" pivot_sport_Gymnastics_gold:3 pivot_sport_Swimming_gold:null + │ └── LEAF hidden id:0 pivot_sport_Gymnastics_gold:3 pivot_sport_Swimming_gold:3 + └─┬ LEAF_GROUP collapsed id:row-group-country-USA ag-Grid-AutoColumn:"USA" pivot_sport_Gymnastics_gold:null pivot_sport_Swimming_gold:2 + · └── LEAF hidden id:1 pivot_sport_Gymnastics_gold:2 pivot_sport_Swimming_gold:2 + `); + }); + + // Leaving pivot mode brings the parked primaries back. The purge must not have left the column in a + // half-torn-down state: a fresh edit on it has to behave normally. + test('a column purged by pivot parking is editable again once pivot mode is left', async () => { + const api = await gridsManager.createGridAndWait('purge-pivot-round-trip', { + columnDefs: [ + { field: 'country', rowGroup: true }, + { field: 'sport', pivot: true }, + { field: 'gold', aggFunc: 'sum' }, + { field: 'note', editable: true }, + ], + rowData: [ + { id: '0', country: 'Russia', sport: 'Gymnastics', gold: 3, note: 'a' }, + { id: '1', country: 'USA', sport: 'Swimming', gold: 2, note: 'b' }, + ], + getRowId: (params) => params.data.id, + groupDefaultExpanded: -1, + } satisfies GridOptions); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + await new GridRows(api, 'pivot round trip: grouped rows before the edit').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Russia ag-Grid-AutoColumn:"Russia" gold:3 + │ └── LEAF id:0 country:"Russia" sport:"Gymnastics" gold:3 note:"a" + └─┬ LEAF_GROUP id:row-group-country-USA ag-Grid-AutoColumn:"USA" gold:2 + · └── LEAF id:1 country:"USA" sport:"Swimming" gold:2 note:"b" + `); + + await stage(api, '1', 'note', 'EDITED'); + + api.setGridOption('pivotMode', true); + await asyncSetTimeout(1); + expect(api.getEditingCells()).toHaveLength(0); + + api.setGridOption('pivotMode', false); + await asyncSetTimeout(1); + + await new GridRows(api, 'pivot round trip: purged edit did not come back').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Russia ag-Grid-AutoColumn:"Russia" gold:3 + │ └── LEAF id:0 country:"Russia" sport:"Gymnastics" gold:3 note:"a" + └─┬ LEAF_GROUP id:row-group-country-USA ag-Grid-AutoColumn:"USA" gold:2 + · └── LEAF id:1 country:"USA" sport:"Swimming" gold:2 note:"b" + `); + + // The column survived its own purge, so it still stages and commits normally. + await stage(api, '1', 'note', 'AGAIN'); + expect(api.getEditingCells()).toHaveLength(1); + + await new GridRows(api, 'pivot round trip: fresh edit staged after the purge').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Russia ag-Grid-AutoColumn:"Russia" gold:3 + │ └── LEAF id:0 country:"Russia" sport:"Gymnastics" gold:3 note:"a" + └─┬ LEAF_GROUP id:row-group-country-USA ag-Grid-AutoColumn:"USA" gold:2 + · └── LEAF ⏳ id:1 country:"USA" sport:"Swimming" gold:2 note:⏳"AGAIN" "b" + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + await new GridRows(api, 'pivot round trip: fresh edit committed').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Russia ag-Grid-AutoColumn:"Russia" gold:3 + │ └── LEAF id:0 country:"Russia" sport:"Gymnastics" gold:3 note:"a" + └─┬ LEAF_GROUP id:row-group-country-USA ag-Grid-AutoColumn:"USA" gold:2 + · └── LEAF id:1 country:"USA" sport:"Swimming" gold:2 note:"AGAIN" + `); + }); + + // Static pinned rows leave their section without being destroyed, so they cannot report their own + // departure — the one case still resolved by a sweep, on pinnedRowsChanged. + test('replacing pinnedTopRowData purges an edit staged on the removed pinned row', async () => { + const api = await gridsManager.createGridAndWait('purge-pinned', { + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData: [{ id: '0', a: 'A0', b: 'B0' }], + pinnedTopRowData: [{ id: 'PIN', a: 'PIN-A', b: 'PIN-B' }], + defaultColDef: { editable: true }, + getRowId: (params) => params.data.id, + } satisfies GridOptions); + + const stopped: CellEditingStoppedEvent[] = []; + api.addEventListener('cellEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + await new GridRows(api, 'pinned row present before the edit').check(` + PINNED_TOP id:PIN a:"PIN-A" b:"PIN-B" + ROOT id:ROOT_NODE_ID + └── LEAF id:0 a:"A0" b:"B0" + `); + + await stage(api, 'PIN', 'a', 'EDITED'); + + expect(api.getEditingCells()).toHaveLength(1); + expect(stopped).toHaveLength(1); + + await new GridRows(api, 'pinned row edit staged as pending').check(` + PINNED_TOP id:PIN a:⏳"EDITED" "PIN-A" b:"PIN-B" + ROOT id:ROOT_NODE_ID + └── LEAF id:0 a:"A0" b:"B0" + `); + + api.setGridOption('pinnedTopRowData', [{ id: 'OTHER', a: 'OTHER-A', b: 'OTHER-B' }]); + await asyncSetTimeout(1); + + expect(api.getEditingCells()).toHaveLength(0); + expect(stopped).toHaveLength(1); + + await new GridRows(api, 'pinned row replaced, its staged edit purged').check(` + PINNED_TOP id:OTHER a:"OTHER-A" b:"OTHER-B" + ROOT id:ROOT_NODE_ID + └── LEAF id:0 a:"A0" b:"B0" + `); + }); + + // The purge runs before the node's position is cleared, so the event reports where the row actually + // was rather than a null index. + test('a row removed mid-edit reports its real rowIndex on cellEditingStopped', async () => { + const rowData = [ + { id: '0', a: 'A0', b: 'B0' }, + { id: '1', a: 'A1', b: 'B1' }, + ]; + const api = await gridsManager.createGridAndWait('purge-row-index', { + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData, + defaultColDef: { editable: true }, + getRowId: (params) => params.data.id, + } satisfies GridOptions); + + const stopped: CellEditingStoppedEvent[] = []; + api.addEventListener('cellEditingStopped', (e) => stopped.push(e)); + + await new GridRows(api, 'two rows before the edit').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 a:"A0" b:"B0" + └── LEAF id:1 a:"A1" b:"B1" + `); + + await type(api, '1', 'a', 'CHANGED'); + + await new GridRows(api, 'row 1 mid-edit, uncommitted').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 a:"A0" b:"B0" + └── LEAF 🖍️ id:1 a:🖍️"CHANGED" "A1" b:"B1" + `); + + api.applyTransaction({ remove: [rowData[1]] }); + await asyncSetTimeout(1); + + expect(stopped).toHaveLength(1); + expect(stopped[0].rowIndex).toBe(1); + expect(stopped[0].node.id).toBe('1'); + + await new GridRows(api, 'row 1 removed mid-edit').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 a:"A0" b:"B0" + `); + }); + + // The removal takes the cell controller with it, so the event's value has to be resolved from the model. + // Read as the raw field it would report neither the pending edit nor anything at all for a computed column. + test('a column removal reports the value being edited, computed or not', async () => { + const rowData = [{ id: '0', a: 'A0' }]; + const api = await gridsManager.createGridAndWait('purge-computed-value', { + columnDefs: [ + { field: 'a' }, + { + colId: 'shout', + editable: true, + valueGetter: ({ data }) => (data ? `${data.a}!` : null), + valueSetter: (params) => { + params.data.a = String(params.newValue).replace(/!$/, ''); + return true; + }, + }, + ], + rowData, + getRowId: (params) => params.data.id, + } satisfies GridOptions); + + const stopped: CellEditingStoppedEvent[] = []; + api.addEventListener('cellEditingStopped', (e) => stopped.push(e)); + + await type(api, '0', 'shout', 'LOUD'); + + await new GridRows(api, 'computed column mid-edit, uncommitted').check(` + ROOT id:ROOT_NODE_ID shout:null + └── LEAF 🖍️ id:0 a:"A0" shout:🖍️"LOUD" "A0!" + `); + + api.setGridOption('columnDefs', [{ field: 'a' }]); + await asyncSetTimeout(1); + + expect(stopped).toHaveLength(1); + expect(stopped[0].value).toBe('LOUD'); + expect(rowData[0].a).toBe('A0'); // the purge cancels, so nothing was written + + await new GridRows(api, 'the computed column and its edit are gone').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 a:"A0" + `); + }); + + // Tearing the grid down must not surface edit events: listeners are unsubscribing, and a stop reaching + // a half-destroyed grid has nothing meaningful to report. + test('destroying the grid mid-edit fires no cellEditingStopped', async () => { + const api = await gridsManager.createGridAndWait('purge-grid-destroy', { + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData: [{ id: '0', a: 'A0', b: 'B0' }], + defaultColDef: { editable: true }, + getRowId: (params) => params.data.id, + } satisfies GridOptions); + + const stopped: CellEditingStoppedEvent[] = []; + api.addEventListener('cellEditingStopped', (e) => stopped.push(e)); + + await new GridRows(api, 'one row before the edit').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 a:"A0" b:"B0" + `); + + await type(api, '0', 'a', 'CHANGED'); + + await new GridRows(api, 'editing before the grid is destroyed').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 a:🖍️"CHANGED" "A0" b:"B0" + `); + + await new GridRows(api, 'row 0 mid-edit before grid destroy').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 a:🖍️"CHANGED" "A0" b:"B0" + `); + + api.destroy(); + await asyncSetTimeout(1); + + expect(stopped).toHaveLength(0); + }); + + // The purge dispatches synchronously from the column rebuild, so a stopped listener must never see + // the departing column still in the grid's column list. + test('a column removal purges its edits without exposing the departing column to listeners', async () => { + const api = await gridsManager.createGridAndWait('purge-col-order', { + columnDefs: [ + { field: 'a', editable: true }, + { field: 'b', editable: true }, + ], + rowData: [{ id: '1', a: '1', b: '2' }], + getRowId: ({ data }) => data.id, + } satisfies GridOptions); + + const colsDuringStop: string[][] = []; + const displayedColsDuringStop: string[][] = []; + api.addEventListener('cellEditingStopped', () => { + colsDuringStop.push(api.getAllGridColumns().map((c) => c.getColId())); + displayedColsDuringStop.push(api.getAllDisplayedColumns().map((c) => c.getColId())); + }); + + await type(api, '1', 'a', 'EDITED'); + expect(api.getEditingCells()).toHaveLength(1); + + await new GridRows(api, 'editing a, before its column is removed').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:1 a:🖍️"EDITED" "1" b:"2" + `); + + api.setGridOption('columnDefs', [{ field: 'b', editable: true }]); + await asyncSetTimeout(1); + + expect(api.getEditingCells()).toHaveLength(0); + expect(colsDuringStop).toEqual([['b']]); // 'a' already out of colsList when the stop was observed + expect(displayedColsDuringStop).toEqual([['b']]); // ...and out of the derived state, not just colsList + + await new GridRows(api, 'column a removed, its edit purged').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 b:"2" + `); + }); + + // A row-level error is recorded against the row, not a cell, so purging the edit that caused it must + // take the error with it — nothing else revisits it until the row's next stop. + test('a column removal purges the row-level error its edit had caused', async () => { + const api = await gridsManager.createGridAndWait('purge-row-validation', { + columnDefs: [ + { field: 'a', editable: true }, + { field: 'b', editable: true }, + ], + rowData: [{ id: '1', a: '1', b: '2' }], + getRowId: ({ data }) => data.id, + editType: 'fullRow', + invalidEditValueMode: 'block', + getFullRowEditValidationErrors: ({ editorsState }) => + editorsState.find((e) => e.colId === 'a')?.newValue === 'BAD' ? ['a must not be BAD'] : [], + } satisfies GridOptions); + + const rowElement = () => getGridElement(api)!.querySelector('.ag-row[row-index="0"]')!; + + await type(api, '1', 'a', 'BAD'); + expect(rowElement().classList.contains('ag-row-editing-invalid')).toBe(true); + + await new GridRows(api, 'the edit in column a that breaks the row rule').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:1 a:🖍️"BAD" "1" b:"2" + `); + + api.setGridOption('columnDefs', [{ field: 'b', editable: true }]); + await asyncSetTimeout(1); + + // The row edit carries on through column b, so the stale error would still be styling it as invalid. + expect(api.getEditingCells()).toHaveLength(1); + expect(rowElement().classList.contains('ag-row-editing-invalid')).toBe(false); + + await new GridRows(api, 'column a gone: the row error went with its edit').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:1 b:"2" + `); + }); + + // The other side of it: what the row still holds can break the rule on its own, so the purge has to + // recompute the row error rather than assume the departing edit was the only cause. + test('a column removal keeps a row-level error the remaining edit still causes', async () => { + const api = await gridsManager.createGridAndWait('purge-row-validation-retained', { + columnDefs: [ + { field: 'a', editable: true }, + { field: 'b', editable: true }, + ], + rowData: [{ id: '1', a: '1', b: '2' }], + getRowId: ({ data }) => data.id, + editType: 'fullRow', + invalidEditValueMode: 'block', + // Either column can break the rule, so removing one leaves the other's breach standing. + getFullRowEditValidationErrors: ({ editorsState }) => + editorsState.some((e) => e.newValue === 'BAD') ? ['no cell may be BAD'] : [], + } satisfies GridOptions); + + const rowElement = () => getGridElement(api)!.querySelector('.ag-row[row-index="0"]')!; + + await type(api, '1', 'b', 'BAD'); + await type(api, '1', 'a', 'ALSO BAD'); + expect(rowElement().classList.contains('ag-row-editing-invalid')).toBe(true); + + await new GridRows(api, 'both edits in place, each breaking the row rule').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:1 a:🖍️"ALSO BAD" "1" b:🖍️"BAD" "2" + `); + + api.setGridOption('columnDefs', [{ field: 'b', editable: true }]); + await asyncSetTimeout(1); + + // b is still BAD, so the row is still invalid — and still blocked from committing. + expect(api.getEditingCells()).toHaveLength(1); + expect(rowElement().classList.contains('ag-row-editing-invalid')).toBe(true); + + await new GridRows(api, 'column a gone: the remaining edit still breaks the row rule').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:1 b:🖍️"BAD" "2" + `); + }); +}); diff --git a/testing/behavioural/src/cell-editing/cell-editing-regression.test.ts b/testing/behavioural/src/cell-editing/cell-editing-regression.test.ts index 60af6ee752a..15d0e7c8149 100644 --- a/testing/behavioural/src/cell-editing/cell-editing-regression.test.ts +++ b/testing/behavioural/src/cell-editing/cell-editing-regression.test.ts @@ -11,7 +11,7 @@ import { getGridElement, setupAgTestIds, } from 'ag-grid-community'; -import type { GridApi, GridOptions } from 'ag-grid-community'; +import type { CellEditingStoppedEvent, GridApi, GridOptions } from 'ag-grid-community'; import { CellSelectionModule, ClipboardModule, @@ -1757,4 +1757,88 @@ describe('Cell Editing Regression', () => { batchEditingStopped: 0, }); }); + + // Removing the edited row ends the edit without going through the stop pipeline. The row event pair + // must still balance, and the strategy must not keep the detached node alive. + test('full-row: removing the edited row fires rowEditingStopped', async () => { + const rowData = [ + { id: 'ROW_0', a: 'A0', b: 'B0' }, + { id: 'ROW_1', a: 'A1', b: 'B1' }, + ]; + const api = await gridMgr.createGridAndWait('fullRowRemoveWhileEditing', { + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + getRowId: (params) => params.data.id, + } satisfies GridOptions); + + const rowStarted: any[] = []; + const rowStopped: any[] = []; + api.addEventListener('rowEditingStarted', (e) => rowStarted.push(e)); + api.addEventListener('rowEditingStopped', (e) => rowStopped.push(e)); + + api.startEditingCell({ rowIndex: 0, colKey: 'a' }); + await asyncSetTimeout(1); + + expect(rowStarted).toHaveLength(1); + expect(rowStopped).toHaveLength(0); + + api.applyTransaction({ remove: [rowData[0]] }); + await asyncSetTimeout(1); + + expect(rowStopped).toHaveLength(1); + expect(api.getEditingCells()).toHaveLength(0); + + await new GridRows(api, 'full-row: edited row removed').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_1 a:"A1" b:"B1" + `); + + // The strategy released the row, so a fresh edit on the surviving row starts cleanly. + api.startEditingCell({ rowIndex: 0, colKey: 'a' }); + await asyncSetTimeout(1); + + expect(rowStarted).toHaveLength(2); + expect(rowStopped).toHaveLength(1); + }); + + // The edit dies with its row, so nothing was written anywhere. The stopped event must say so rather + // than advertise the in-flight editor value as the cell's new one. + test('removing the edited row reports cellEditingStopped with cancel semantics', async () => { + const rowData = [ + { id: 'ROW_0', a: 'A0', b: 'B0' }, + { id: 'ROW_1', a: 'A1', b: 'B1' }, + ]; + const api = await gridMgr.createGridAndWait('removeWhileEditingStoppedArgs', { + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData, + defaultColDef: { editable: true }, + getRowId: (params) => params.data.id, + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + + const stopped: CellEditingStoppedEvent[] = []; + api.addEventListener('cellEditingStopped', (e) => stopped.push(e)); + + const cell = gridDiv.querySelectorAll('.ag-row')[0].querySelector('[col-id="a"]')!; + await userEvent.dblClick(cell); + const input = await waitForInput(gridDiv, cell); + await userEvent.clear(input); + await userEvent.type(input, 'CHANGED'); + await asyncSetTimeout(1); + + api.applyTransaction({ remove: [rowData[0]] }); + await asyncSetTimeout(1); + + expect(stopped).toHaveLength(1); + expect(stopped[0].valueChanged).toBe(false); + expect(stopped[0].newValue).toBeUndefined(); + expect(stopped[0].oldValue).toBe('A0'); + + await new GridRows(api, 'edited row removed mid-edit').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_1 a:"A1" b:"B1" + `); + }); }); diff --git a/testing/behavioural/src/cell-editing/cell-editing-single-cell-batch-styles.test.ts b/testing/behavioural/src/cell-editing/cell-editing-single-cell-batch-styles.test.ts index 0fb0d172214..c3e5c0c1525 100644 --- a/testing/behavioural/src/cell-editing/cell-editing-single-cell-batch-styles.test.ts +++ b/testing/behavioural/src/cell-editing/cell-editing-single-cell-batch-styles.test.ts @@ -398,9 +398,10 @@ describe('Cell Editing: single-cell batch styles', () => { expect(cellA0Still).toHaveTextContent('CONFIRMED'); expect(cellA0Still).toHaveClass(/ag-cell-batch-edit/); + // Two editors opened (Enter-committed, then Escape-cancelled), so two stopped events. expect(eventTracker.counts).toEqual({ cellEditingStarted: 2, - cellEditingStopped: 1, + cellEditingStopped: 2, cellValueChanged: 0, rowValueChanged: 0, cellEditRequest: 0, diff --git a/testing/behavioural/src/cell-editing/cell-editing-validation-batch.test.ts b/testing/behavioural/src/cell-editing/cell-editing-validation-batch.test.ts new file mode 100644 index 00000000000..5694acf3f68 --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-validation-batch.test.ts @@ -0,0 +1,1717 @@ +import { getByTestId } from '@testing-library/dom'; +import { userEvent } from '@testing-library/user-event'; + +import type { + BatchEditingStoppedEvent, + CellEditingStoppedEvent, + ColDef, + GridApi, + GridOptions, + ICellEditorComp, + ICellEditorParams, +} from 'ag-grid-community'; +import { + ClientSideRowModelModule, + CustomEditorModule, + NumberEditorModule, + RenderApiModule, + ScrollApiModule, + TextEditorModule, + UndoRedoEditModule, + agTestIdFor, + getGridElement, + setupAgTestIds, +} from 'ag-grid-community'; +import { BatchEditModule } from 'ag-grid-enterprise'; + +import { + EditEventTracker, + GridRows, + TestGridsManager, + asyncSetTimeout, + waitForInput, + waitForPopup, +} from '../test-utils'; + +interface PersonRow { + athlete: string; + age: number; +} + +const columnDefs: ColDef[] = [ + { field: 'athlete', cellEditor: 'agTextCellEditor', cellEditorParams: { maxLength: 10 } }, + { field: 'age', cellEditor: 'agNumberCellEditor', cellEditorParams: { min: 0, max: 100 } }, +]; + +function makeRowData(): PersonRow[] { + return [ + { athlete: 'Alice', age: 23 }, + { athlete: 'Bob', age: 40 }, + ]; +} + +/** Carries its validation on the component, so nothing in the colDefs advertises validation. */ +class ValidatingEditor implements ICellEditorComp { + private eGui!: HTMLInputElement; + + public init(params: ICellEditorParams): void { + this.eGui = document.createElement('input'); + this.eGui.value = String(params.value ?? ''); + } + + public getGui(): HTMLElement { + return this.eGui; + } + + public afterGuiAttached(): void { + this.eGui.focus(); + } + + public getValue(): string { + return this.eGui.value; + } + + public getValidationErrors(): string[] | null { + return this.eGui.value === 'INVALID' ? ['Custom editor rejected value'] : null; + } +} + +/** Picker-style editor that commits itself via params.stopEditing(), like agSelectCellEditor on pick. */ +class DoneCellEditor implements ICellEditorComp { + private eGui!: HTMLElement; + private value: any; + + public init(params: ICellEditorParams): void { + this.value = params.value; + this.eGui = document.createElement('div'); + const button = document.createElement('button'); + button.textContent = 'Done'; + button.setAttribute('data-testid', 'done-button'); + button.addEventListener('click', () => { + this.value = 'picked'; + params.stopEditing(false); + }); + this.eGui.appendChild(button); + } + + public getGui(): HTMLElement { + return this.eGui; + } + + public getValue(): any { + return this.value; + } +} + +describe('Cell editing validation + batch editing', () => { + const gridsManager = new TestGridsManager({ + includeDefaultModules: true, + modules: [ + ClientSideRowModelModule, + CustomEditorModule, + NumberEditorModule, + TextEditorModule, + RenderApiModule, + ScrollApiModule, + BatchEditModule, + UndoRedoEditModule, + ], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridsManager.reset(); + vi.clearAllMocks(); + }); + + const cell = (api: GridApi, rowIndex: number, colId: string): HTMLElement => { + const gridDiv = getGridElement(api)! as HTMLElement; + return getByTestId(gridDiv, agTestIdFor.cell(String(rowIndex), colId)); + }; + + const editorCount = (api: GridApi): number => api.getCellEditorInstances().length; + + describe('editType: singleCell', () => { + const editType = 'singleCell' as const; + const create = async (invalidEditValueMode: 'revert' | 'block', rowData: PersonRow[]) => + gridsManager.createGridAndWait(`validation-batch-${editType}-${invalidEditValueMode}`, { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType, + invalidEditValueMode, + } satisfies GridOptions); + + // Block mode holds an invalid editor open, but a closed popup has no DOM left to hold. In a batch + // this must still leave the cell usable — re-opening it must give a working editor, not an + // inaccessible held one whose popup is gone. + test('block batch: an invalid popup editor closed by focus loss leaves the cell editable', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-batch-popup-block', { + columnDefs: [ + { field: 'athlete' }, + { + field: 'age', + cellEditor: 'agNumberCellEditor', + cellEditorPopup: true, + cellEditorParams: { min: 0, max: 100 }, + }, + ], + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + stopEditingWhenCellsLoseFocus: true, + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + + api.startBatchEdit(); + await asyncSetTimeout(1); + + await userEvent.dblClick(cell(api, 0, 'age')); + const popup = await waitForPopup(gridDiv); + const input = popup.querySelector('input')!; + await userEvent.clear(input); + await userEvent.type(input, '999'); // invalid + + await new GridRows(api, 'batch popup: invalid 999 open in popup').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Click away: the popup closes, so an invalid value cannot stay held in it. + await userEvent.click(document.body); + await asyncSetTimeout(1); + + expect(rowData[0].age).toBe(23); // nothing invalid committed + // The reverted attempt's error goes with it: this cell is neither editing nor invalid. + expect(api.getEditValidationErrors()).toEqual([]); + + await new GridRows(api, 'batch popup: invalid attempt reverted on popup close').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Re-open the cell: a fresh, usable popup editor must appear and accept a correction. + await userEvent.dblClick(cell(api, 0, 'age')); + const reopened = await waitForPopup(gridDiv); + const reopenedInput = reopened.querySelector('input')!; + expect(reopenedInput).toBeTruthy(); + expect(reopenedInput.disabled).toBe(false); + + await userEvent.clear(reopenedInput); + await userEvent.type(reopenedInput, '55{Enter}'); + await asyncSetTimeout(1); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[0].age).toBe(55); + expect(api.isBatchEditing()).toBe(false); + + await new GridRows(api, 'batch popup: corrected 55 committed, batch ended').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:55 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + // A staged batch edit's editor has already stopped (cellEditingStopped fired once). Removing its + // row purges the staged edit off-screen; that purge must NOT fire a second cellEditingStopped. + test('removing a staged batch row fires cellEditingStopped once, not twice', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const tracker = new EditEventTracker(api); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '55{Enter}'); // stage valid 55; editor closes, stopped #1 + await asyncSetTimeout(1); + + expect(tracker.counts.cellEditingStarted).toBe(1); + expect(tracker.counts.cellEditingStopped).toBe(1); + + await new GridRows(api, 'batch: 55 staged as pending before row removal').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:"Alice" age:⏳55 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.applyTransaction({ remove: [rowData[0]] }); + await asyncSetTimeout(1); + + // The purge of the staged edit must not emit a duplicate stopped for an already-closed editor. + expect(tracker.counts.cellEditingStopped).toBe(1); + tracker.destroy(); + + await new GridRows(api, 'batch: staged row removed, edit purged').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + // The same purge reached by removing the COLUMN rather than the row: _destroyEditor runs again + // for an edit whose editor already closed, and must not emit a second cellEditingStopped. + test('removing a staged batch column fires cellEditingStopped once, not twice', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const tracker = new EditEventTracker(api); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '55{Enter}'); // stage valid 55; editor closes, stopped #1 + await asyncSetTimeout(1); + + expect(tracker.counts.cellEditingStarted).toBe(1); + expect(tracker.counts.cellEditingStopped).toBe(1); + + await new GridRows(api, 'batch: 55 staged as pending before column removal').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:"Alice" age:⏳55 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.setGridOption('columnDefs', [{ field: 'athlete' }]); + await asyncSetTimeout(1); + + expect(tracker.counts.cellEditingStopped).toBe(1); + tracker.destroy(); + + await new GridRows(api, 'batch: staged column removed, edit purged').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" + └── LEAF id:1 athlete:"Bob" + `); + }); + + test('block: commitBatchEdit is rejected while invalid, then commits after the fix', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await userEvent.keyboard('{Enter}'); + await asyncSetTimeout(1); + + // block mode keeps the invalid editor open rather than staging it + expect(editorCount(api)).toBeGreaterThan(0); + + // Pre-commit: invalid 999 held in the open editor (🖍️ + ❌), nothing written yet. + await new GridRows(api, 'block: invalid 999 held open before commit').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + // Block mode rejects the commit: nothing is written, the editor stays open, and the + // batch session is held so the user can correct the value. No batchEditingStopped fires. + expect(rowData[0].age).toBe(23); + expect(api.isBatchEditing()).toBe(true); + expect(editorCount(api)).toBeGreaterThan(0); + expect(stopped).toHaveLength(0); + + // Rejected commit held the batch: invalid 999 still in the editor (🖍️ + ❌), data still 23. + await new GridRows(api, 'block: rejected commit held, invalid value still open').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Correct the value and re-commit: the batch now ends and the corrected value lands. + const ageInputAgain = await waitForInput(gridDiv, cell(api, 0, 'age')); + await userEvent.clear(ageInputAgain); + await userEvent.type(ageInputAgain, '55{Enter}'); + await asyncSetTimeout(1); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[0].age).toBe(55); + expect(api.isBatchEditing()).toBe(false); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([ + { rowIndex: 0, rowPinned: undefined, columnId: 'age', oldValue: 23, newValue: 55 }, + ]); + + // Batch ended, corrected value committed, no pending/invalid markers remain. + await new GridRows(api, 'block: corrected value committed, batch ended').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:55 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + // api.stopEditing() closes a batch's editors without ending the batch. Block mode must hold there + // as on every other stop path, or the invalid value and its staged edit are dropped silently. + test('block: api.stopEditing() holds the invalid editor rather than discarding the edit', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + + api.stopEditing(); + await asyncSetTimeout(1); + + expect(editorCount(api)).toBeGreaterThan(0); + expect(api.isBatchEditing()).toBe(true); + + await new GridRows(api, 'block: api.stopEditing() held the invalid editor').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Correcting it and stopping again closes the editor and stages the value as usual. + const fixed = await waitForInput(gridDiv, cell(api, 0, 'age')); + await userEvent.clear(fixed); + await userEvent.type(fixed, '55'); + await asyncSetTimeout(1); + + api.stopEditing(); + await asyncSetTimeout(1); + + expect(editorCount(api)).toBe(0); + + await new GridRows(api, 'block: corrected value staged after the hold').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:"Alice" age:⏳55 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[0].age).toBe(55); + expect(api.isBatchEditing()).toBe(false); + }); + + // The same hold on the focus-loss path, which closes a batch's editors through its own route. + test('block: losing grid focus holds the invalid editor rather than discarding the edit', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-batch-block-focus-loss', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + stopEditingWhenCellsLoseFocus: true, + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + + await userEvent.click(document.body); + await asyncSetTimeout(1); + + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'block: focus loss held the invalid editor').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + // Revert mode is untouched by the hold: the same stop discards the attempt and closes the editor. + test('revert: api.stopEditing() discards the invalid edit and closes the editor', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + + api.stopEditing(); + await asyncSetTimeout(1); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(23); + expect(api.getEditValidationErrors()).toEqual([]); // discarded, so nothing recorded against it + + await new GridRows(api, 'revert: invalid edit discarded on api.stopEditing()').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + test('block: valid staged edits commit cleanly and fire batchEditingStopped once', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '55{Enter}'); + await asyncSetTimeout(1); + + await new GridRows(api, 'valid value staged as pending 55 over 23').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:"Alice" age:⏳55 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[0].age).toBe(55); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([ + { rowIndex: 0, rowPinned: undefined, columnId: 'age', oldValue: 23, newValue: 55 }, + ]); + + await new GridRows(api, 'valid value committed to 55').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:55 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('revert: invalid value reverts, valid staged cell still commits', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // Stage a valid athlete edit first. + const athleteCell = cell(api, 0, 'athlete'); + await userEvent.dblClick(athleteCell); + const athleteInput = await waitForInput(gridDiv, athleteCell); + await userEvent.clear(athleteInput); + await userEvent.type(athleteInput, 'Amy{Enter}'); + await asyncSetTimeout(1); + + // Now enter an invalid age; in revert mode Enter discards it back to source. + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999{Enter}'); + await asyncSetTimeout(1); + + expect(editorCount(api)).toBe(0); + + await new GridRows(api, 'revert: athlete pending Amy, age reverted to 23').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:⏳"Amy" "Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[0].athlete).toBe('Amy'); + expect(rowData[0].age).toBe(23); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([ + { rowIndex: 0, rowPinned: undefined, columnId: 'athlete', oldValue: 'Alice', newValue: 'Amy' }, + ]); + + await new GridRows(api, 'revert: only valid athlete committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Amy" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('revert: commitBatchEdit with an invalid value still open discards it and ends the batch', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // Stage a valid athlete edit. + const athleteCell = cell(api, 0, 'athlete'); + await userEvent.dblClick(athleteCell); + const athleteInput = await waitForInput(gridDiv, athleteCell); + await userEvent.clear(athleteInput); + await userEvent.type(athleteInput, 'Amy{Enter}'); + await asyncSetTimeout(1); + + // Leave an invalid age in an OPEN editor — no Enter, so revert hasn't fired yet. + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + expect(editorCount(api)).toBeGreaterThan(0); + + // Mid-edit: athlete staged, age editor open holding the invalid 999 (🖍️ + ❌). + await new GridRows(api, 'revert: athlete staged, invalid age held open').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ⏳ ❌ id:0 athlete:⏳"Amy" "Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Revert mode: the commit discards the invalid value, commits the valid one, and ends the + // batch — it must NOT be rejected like block mode. + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(api.isBatchEditing()).toBe(false); + expect(editorCount(api)).toBe(0); + expect(rowData[0].athlete).toBe('Amy'); + expect(rowData[0].age).toBe(23); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([ + { rowIndex: 0, rowPinned: undefined, columnId: 'athlete', oldValue: 'Alice', newValue: 'Amy' }, + ]); + + await new GridRows(api, 'revert: invalid age discarded, valid athlete committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Amy" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('revert: reopening a valid staged cell with an invalid value commits the previous pending on batch commit', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // Stage a valid age (50) as the previous pending value. + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + let ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '50{Enter}'); + await asyncSetTimeout(1); + + await new GridRows(api, 'revert: age staged pending 50').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:"Alice" age:⏳50 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Reopen the same cell and type an invalid value, leaving the editor open. + await userEvent.dblClick(ageCell); + ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + expect(editorCount(api)).toBeGreaterThan(0); + + await new GridRows(api, 'revert: invalid 999 held open over previous pending 50').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:🖍️23 ⏳50 23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Commit: the invalid attempt is discarded back to the previous pending 50, which commits. + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(api.isBatchEditing()).toBe(false); + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(50); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([ + { rowIndex: 0, rowPinned: undefined, columnId: 'age', oldValue: 23, newValue: 50 }, + ]); + + await new GridRows(api, 'revert: previous pending 50 committed, invalid attempt discarded').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:50 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('cancelBatchEdit discards all staged edits', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '77{Enter}'); + await asyncSetTimeout(1); + + await new GridRows(api, 'age staged pending 77 before cancel').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:"Alice" age:⏳77 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.cancelBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[0].age).toBe(23); + expect(api.isBatchEditing()).toBe(false); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([]); + + await new GridRows(api, 'cancel restored source values').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('block: commitBatchEdit with nothing staged still ends the batch', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // No edits staged: the commit has nothing to reject, so it must not be mistaken for a + // validation hold — the batch ends cleanly rather than being stranded open. + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(api.isBatchEditing()).toBe(false); + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'no-op commit left the data untouched').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('block: a rejected commit must not overwrite the previous valid pending value', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // Stage a valid age (50) as the previous pending value. + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + let ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '50{Enter}'); + await asyncSetTimeout(1); + + // Valid 50 staged as the previous pending value before the invalid reopen. + await new GridRows(api, 'block: age staged pending 50').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:"Alice" age:⏳50 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Reopen the same cell and type an invalid value, leaving the editor open. + await userEvent.dblClick(ageCell); + ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + + // Block-mode commit is rejected: batch held, editor stays open, nothing written. + api.commitBatchEdit(); + await asyncSetTimeout(1); + expect(api.isBatchEditing()).toBe(true); + + // The rejected commit must NOT persist the invalid 999 over the staged pending 50: the + // previous valid value survives the hold (⏳50), so it can still commit once corrected. + await new GridRows(api, 'block: rejected commit held, previous pending 50 preserved').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:🖍️23 ⏳50 23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Correct the value and re-commit: the batch ends and the corrected value lands. + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '60{Enter}'); + await asyncSetTimeout(1); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + expect(api.isBatchEditing()).toBe(false); + expect(rowData[0].age).toBe(60); + + await new GridRows(api, 'block: corrected value committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:60 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); + + describe('editType: fullRow', () => { + const editType = 'fullRow' as const; + const create = async (invalidEditValueMode: 'revert' | 'block', rowData: PersonRow[]) => + gridsManager.createGridAndWait(`validation-batch-${editType}-${invalidEditValueMode}`, { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType, + invalidEditValueMode, + } satisfies GridOptions); + + test('block: commitBatchEdit is rejected while invalid, then commits after the fix', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await userEvent.keyboard('{Enter}'); + await asyncSetTimeout(1); + + // block mode keeps the invalid editor open rather than staging it + expect(editorCount(api)).toBeGreaterThan(0); + + // Pre-commit: invalid 999 held in the open editor (🖍️ + ❌), nothing written yet. + await new GridRows(api, 'block: invalid 999 held open before commit').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + // Block mode rejects the commit: nothing is written, the editor stays open, and the + // batch session is held so the user can correct the value. No batchEditingStopped fires. + expect(rowData[0].age).toBe(23); + expect(api.isBatchEditing()).toBe(true); + expect(editorCount(api)).toBeGreaterThan(0); + expect(stopped).toHaveLength(0); + + // Rejected commit held the batch: invalid 999 still in the editor (🖍️ + ❌), data still 23. + await new GridRows(api, 'block: rejected commit held, invalid value still open').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Correct the value and re-commit: the batch now ends and the corrected value lands. + const ageInputAgain = await waitForInput(gridDiv, cell(api, 0, 'age')); + await userEvent.clear(ageInputAgain); + await userEvent.type(ageInputAgain, '55{Enter}'); + await asyncSetTimeout(1); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[0].age).toBe(55); + expect(api.isBatchEditing()).toBe(false); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([ + { rowIndex: 0, rowPinned: undefined, columnId: 'age', oldValue: 23, newValue: 55 }, + ]); + + // Batch ended, corrected value committed, no pending/invalid markers remain. + await new GridRows(api, 'block: corrected value committed, batch ended').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:55 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('block: valid staged edits commit cleanly and fire batchEditingStopped once', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '55{Enter}'); + await asyncSetTimeout(1); + + await new GridRows(api, 'valid value staged as pending 55 over 23').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:"Alice" age:⏳55 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[0].age).toBe(55); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([ + { rowIndex: 0, rowPinned: undefined, columnId: 'age', oldValue: 23, newValue: 55 }, + ]); + + await new GridRows(api, 'valid value committed to 55').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:55 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('revert: invalid value reverts, valid staged cell still commits', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // Stage a valid athlete edit first. + const athleteCell = cell(api, 0, 'athlete'); + await userEvent.dblClick(athleteCell); + const athleteInput = await waitForInput(gridDiv, athleteCell); + await userEvent.clear(athleteInput); + await userEvent.type(athleteInput, 'Amy{Enter}'); + await asyncSetTimeout(1); + + // Now enter an invalid age; in revert mode Enter discards it back to source. + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999{Enter}'); + await asyncSetTimeout(1); + + expect(editorCount(api)).toBe(0); + + await new GridRows(api, 'revert: athlete pending Amy, age reverted to 23').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:⏳"Amy" "Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[0].athlete).toBe('Amy'); + expect(rowData[0].age).toBe(23); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([ + { rowIndex: 0, rowPinned: undefined, columnId: 'athlete', oldValue: 'Alice', newValue: 'Amy' }, + ]); + + await new GridRows(api, 'revert: only valid athlete committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Amy" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('revert: commitBatchEdit with an invalid value still open discards it and ends the batch', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // Stage a valid athlete edit. + const athleteCell = cell(api, 0, 'athlete'); + await userEvent.dblClick(athleteCell); + const athleteInput = await waitForInput(gridDiv, athleteCell); + await userEvent.clear(athleteInput); + await userEvent.type(athleteInput, 'Amy{Enter}'); + await asyncSetTimeout(1); + + // Leave an invalid age in an OPEN editor — no Enter, so revert hasn't fired yet. + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + expect(editorCount(api)).toBeGreaterThan(0); + + // Mid-edit: athlete staged, age editor open holding the invalid 999 (🖍️ + ❌). + await new GridRows(api, 'revert: athlete staged, invalid age held open').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:⏳"Amy" "Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Revert mode: the commit discards the invalid value, commits the valid one, and ends the + // batch — it must NOT be rejected like block mode. + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(api.isBatchEditing()).toBe(false); + expect(editorCount(api)).toBe(0); + expect(rowData[0].athlete).toBe('Amy'); + expect(rowData[0].age).toBe(23); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([ + { rowIndex: 0, rowPinned: undefined, columnId: 'athlete', oldValue: 'Alice', newValue: 'Amy' }, + ]); + + await new GridRows(api, 'revert: invalid age discarded, valid athlete committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Amy" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('revert: reopening a valid staged cell with an invalid value commits the previous pending on batch commit', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // Stage a valid age (50) as the previous pending value. + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + let ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '50{Enter}'); + await asyncSetTimeout(1); + + await new GridRows(api, 'revert: age staged pending 50').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:"Alice" age:⏳50 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Reopen the same cell and type an invalid value, leaving the editor open. + await userEvent.dblClick(ageCell); + ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + expect(editorCount(api)).toBeGreaterThan(0); + + await new GridRows(api, 'revert: invalid 999 held open over previous pending 50').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:🖍️23 ⏳50 23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Commit: the invalid attempt is discarded back to the previous pending 50, which commits. + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(api.isBatchEditing()).toBe(false); + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(50); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([ + { rowIndex: 0, rowPinned: undefined, columnId: 'age', oldValue: 23, newValue: 50 }, + ]); + + await new GridRows(api, 'revert: previous pending 50 committed, invalid attempt discarded').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:50 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('cancelBatchEdit discards all staged edits', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '77{Enter}'); + await asyncSetTimeout(1); + + await new GridRows(api, 'age staged pending 77 before cancel').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:"Alice" age:⏳77 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.cancelBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[0].age).toBe(23); + expect(api.isBatchEditing()).toBe(false); + expect(stopped).toHaveLength(1); + expect(stopped[0].changes).toEqual([]); + + await new GridRows(api, 'cancel restored source values').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('block: commitBatchEdit with nothing staged still ends the batch', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // No edits staged: the commit has nothing to reject, so it must not be mistaken for a + // validation hold — the batch ends cleanly rather than being stranded open. + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(api.isBatchEditing()).toBe(false); + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'no-op commit left the data untouched').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); + + test('singleCell revert: per-cell Escape reverts to previous pending value, other cell stays', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-batch-escape', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'revert', + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // Stage age = 50 (valid) as the previous pending value. + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + let ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '50{Enter}'); + await asyncSetTimeout(1); + + // Stage a valid athlete edit in the same batch. + const athleteCell = cell(api, 0, 'athlete'); + await userEvent.dblClick(athleteCell); + const athleteInput = await waitForInput(gridDiv, athleteCell); + await userEvent.clear(athleteInput); + await userEvent.type(athleteInput, 'Amy{Enter}'); + await asyncSetTimeout(1); + + await new GridRows(api, 'age pending 50, athlete pending Amy').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:⏳"Amy" "Alice" age:⏳50 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Re-open age, type 60, then Escape: reverts to previous pending (50), not source (23). + await userEvent.dblClick(ageCell); + ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '60'); + await userEvent.keyboard('{Escape}'); + await asyncSetTimeout(1); + + await new GridRows(api, 'age reverted to previous pending 50, athlete pending Amy remains').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:⏳"Amy" "Alice" age:⏳50 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[0].age).toBe(50); + expect(rowData[0].athlete).toBe('Amy'); + }); + + test('singleCell revert: commitBatchEdit discarding an open invalid editor still fires cellEditingStopped', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-batch-lifecycle-singleCell', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'revert', + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + const started: string[] = []; + const stoppedCells: string[] = []; + api.addEventListener('cellEditingStarted', (e) => started.push(e.column.getColId())); + api.addEventListener('cellEditingStopped', (e) => stoppedCells.push(e.column.getColId())); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // Open an invalid age editor and leave it open (no Enter) — cellEditingStarted has fired. + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + expect(started).toEqual(['age']); + expect(stoppedCells).toEqual([]); + + await new GridRows(api, 'singleCell revert: invalid 999 held open before commit').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + // The discarded invalid editor must balance its start with exactly one cellEditingStopped. + expect(api.isBatchEditing()).toBe(false); + expect(editorCount(api)).toBe(0); + expect(stoppedCells).toEqual(['age']); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'singleCell revert: invalid age discarded, editor closed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('fullRow revert: commitBatchEdit discarding an open invalid editor still fires cellEditingStopped', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-batch-lifecycle-fullRow', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'revert', + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + const started: string[] = []; + const stoppedCells: string[] = []; + api.addEventListener('cellEditingStarted', (e) => started.push(e.column.getColId())); + api.addEventListener('cellEditingStopped', (e) => stoppedCells.push(e.column.getColId())); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // fullRow opens the whole row; make age invalid and leave the row's editors open. + await userEvent.dblClick(cell(api, 0, 'age')); + const ageInput = await waitForInput(gridDiv, cell(api, 0, 'age')); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + expect(started).toContain('age'); + expect(stoppedCells).not.toContain('age'); + + await new GridRows(api, 'fullRow revert: invalid 999 held open before commit').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + // The discarded invalid age editor must balance its start with exactly one cellEditingStopped. + expect(api.isBatchEditing()).toBe(false); + expect(editorCount(api)).toBe(0); + expect(stoppedCells.filter((c) => c === 'age')).toEqual(['age']); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'fullRow revert: invalid age discarded, editors closed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('fullRow revert: a row-level error reverts EVERY edited cell in the row, not just the first', async () => { + // A row-level error flags the whole row, so every edited position must revert — including a + // later, cell-valid one, which would otherwise leak through and commit. + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-batch-fullrow-rowlevel-revert', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'revert', + // Whole row is invalid when the athlete name is "Invalid" (7 chars, so no cell-level ❌). + getFullRowEditValidationErrors: ({ editorsState }) => { + const athlete = editorsState.find((e) => e.colId === 'athlete')?.newValue; + return athlete === 'Invalid' ? ['Row is invalid'] : null; + }, + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // fullRow opens both editors; athlete makes the ROW invalid, age is individually valid. + await userEvent.dblClick(cell(api, 0, 'athlete')); + const athleteInput = await waitForInput(gridDiv, cell(api, 0, 'athlete')); + await userEvent.clear(athleteInput); + await userEvent.type(athleteInput, 'Invalid'); + + const ageInput = await waitForInput(gridDiv, cell(api, 0, 'age')); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '55'); + await asyncSetTimeout(1); + + // Both editors staged and held open: row-level error marks no single cell (no ❌), age is 55. + await new GridRows(api, 'fullRow revert: row invalid, both cells staged and held open').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:🖍️"Invalid" "Alice" age:🖍️55 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + // Whole-row revert: neither cell commits — not the row-invalidating athlete, nor the + // cell-valid age. age must NOT survive as 55 (the "only the first cell reverts" bug). + expect(api.isBatchEditing()).toBe(false); + expect(editorCount(api)).toBe(0); + expect(rowData[0].athlete).toBe('Alice'); + expect(rowData[0].age).toBe(23); + expect(stopped.flatMap((e) => e.changes ?? [])).toEqual([]); + + await new GridRows(api, 'fullRow revert: row-level error reverted both cells, nothing committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('fullRow block: one invalid cell blocks the whole row commit', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-batch-fullrow-block', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // fullRow opens the whole row; edit athlete (valid) and age (invalid) in the same row. + const athleteCell = cell(api, 0, 'athlete'); + await userEvent.dblClick(athleteCell); + const athleteInput = await waitForInput(gridDiv, athleteCell); + await userEvent.clear(athleteInput); + await userEvent.type(athleteInput, 'Amy'); + + const ageInput = await waitForInput(gridDiv, cell(api, 0, 'age')); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + // One invalid cell rejects the whole commit: NEITHER the invalid age NOR the valid athlete + // in the same row is persisted, the batch stays open, and no batchEditingStopped fires. + expect(rowData[0].athlete).toBe('Alice'); + expect(rowData[0].age).toBe(23); + expect(api.isBatchEditing()).toBe(true); + expect(editorCount(api)).toBeGreaterThan(0); + expect(stopped).toHaveLength(0); + + // Held row: the valid athlete edit (🖍️) is kept open alongside the invalid age (❌) — block + // mode commits neither until the row is valid, so the rejected commit stages nothing (🖍️, not + // ⏳) and nothing reached the data. + await new GridRows(api, 'fullRow block: whole row held, valid athlete + invalid age both open').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:🖍️"Amy" "Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('fullRow block: a blocked batch commit leaves nothing for redo to reapply', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-batch-fullrow-block-undo', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const athleteCell = cell(api, 0, 'athlete'); + await userEvent.dblClick(athleteCell); + const athleteInput = await waitForInput(gridDiv, athleteCell); + await userEvent.clear(athleteInput); + await userEvent.type(athleteInput, 'Amy'); + + const ageInput = await waitForInput(gridDiv, cell(api, 0, 'age')); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await asyncSetTimeout(1); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + // The blocked commit produced no undo action, so redo cannot resurrect the rejected value. + expect(api.getCurrentUndoSize()).toBe(0); + api.redoCellEditing(); + await asyncSetTimeout(1); + expect(rowData[0].athlete).toBe('Alice'); + expect(rowData[0].age).toBe(23); + }); + + test('fullRow block: a rejected commit holds the batch even when the invalid column is scrolled off-screen', async () => { + const wideCols: ColDef[] = Array.from({ length: 15 }, (_, i) => ({ + field: `c${i}`, + width: 200, + cellEditor: 'agNumberCellEditor', + cellEditorParams: { min: 0, max: 100 }, + })); + const rowData = [Object.fromEntries(wideCols.map((_, i) => [`c${i}`, i]))]; + const api = await gridsManager.createGridAndWait('validation-batch-wide-block', { + columnDefs: wideCols, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + suppressColumnVirtualisation: false, + suppressAnimationFrame: true, + }); + const gridDiv = getGridElement(api)! as HTMLElement; + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // Open the row, make the first column invalid, then scroll it far out of the viewport. + await userEvent.dblClick(cell(api, 0, 'c0')); + const firstInput = await waitForInput(gridDiv, cell(api, 0, 'c0')); + await userEvent.clear(firstInput); + await userEvent.type(firstInput, '999'); + api.ensureColumnVisible('c14'); + await asyncSetTimeout(0); + + // The invalid editing cell is kept mounted despite virtualisation, so the rejected commit is + // held: the batch stays open and no batchEditingStopped fires for a commit that never happened. + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(api.isBatchEditing()).toBe(true); + expect(editorCount(api)).toBeGreaterThan(0); + expect(stopped).toHaveLength(0); + expect(rowData[0].c0).toBe(0); + }); + + // A mid-batch `params.stopEditing()` is not a grid-wide stop (the batch stays open), so it reports + // false. The editor that asked to close must still close — only a block-mode hold may keep it. + test('block: an editor that commits itself via params.stopEditing() closes', async () => { + const api = await gridsManager.createGridAndWait('validation-batch-self-commit-editor', { + columnDefs: [{ field: 'athlete', cellEditor: DoneCellEditor }, { field: 'age' }], + rowData: makeRowData(), + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + + const stopped: CellEditingStoppedEvent[] = []; + api.addEventListener('cellEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + await userEvent.dblClick(cell(api, 0, 'athlete')); + await asyncSetTimeout(1); + expect(editorCount(api)).toBe(1); + + await userEvent.click(getByTestId(gridDiv, 'done-button')); + await asyncSetTimeout(1); + + // The editor asked to be closed and nothing is holding it: it must be gone, with a balanced event. + expect(editorCount(api)).toBe(0); + expect(stopped).toHaveLength(1); + + await new GridRows(api, 'block: self-committed editor closed, value staged').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF ⏳ id:0 athlete:⏳"picked" "Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + // A rejected commit holds the batch open. If the held edits then disappear, the next commit has no + // context to build, and must still end the batch rather than wedge it open forever. + test('block: a rejected commit does not wedge the batch when its edits are purged', async () => { + const api = await gridsManager.createGridAndWait('validation-batch-stale-block-rejected', { + columnDefs, + rowData: makeRowData(), + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + await userEvent.keyboard('{Enter}'); + await asyncSetTimeout(1); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + // Correctly rejected: the batch is held so the user can fix the value. + expect(api.isBatchEditing()).toBe(true); + + // The held edit vanishes — fresh row data means fresh nodes, so the purge drops every edit. + api.setGridOption('rowData', makeRowData()); + await asyncSetTimeout(1); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + // Nothing is left to reject, so the batch must end rather than stay stuck open. + expect(api.isBatchEditing()).toBe(false); + + await new GridRows(api, 'block: purged edits still end the batch').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + // With the grid's only validation on a custom editor, purging that editor leaves no probe able to + // rebuild the map, so the purge must drop the error itself or it rejects every later commit. + test('block: purging an invalid custom editor row does not strand its validation error', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-batch-custom-editor-purged', { + columnDefs: [{ field: 'athlete', cellEditor: ValidatingEditor }, { field: 'age' }], + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + + api.startBatchEdit(); + await asyncSetTimeout(1); + + // Stage a valid edit on Bob first — once Alice is invalid, block mode refuses to open any editor. + const ageCell = cell(api, 1, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '31{Enter}'); + await asyncSetTimeout(1); + + await new GridRows(api, 'Bob staged 31, nothing invalid yet').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF ⏳ id:1 athlete:"Bob" age:⏳31 40 + `); + + const athleteCell = cell(api, 0, 'athlete'); + await userEvent.dblClick(athleteCell); + const athleteInput = await waitForInput(gridDiv, athleteCell); + await userEvent.clear(athleteInput); + await userEvent.type(athleteInput, 'INVALID{Enter}'); + expect(editorCount(api)).toBeGreaterThan(0); + + await new GridRows(api, 'Alice held invalid, Bob staged 31').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice"❌ age:23 + └── LEAF ⏳ id:1 athlete:"Bob" age:⏳31 40 + `); + + // Alice goes: her edit is purged with no cell controller left to clear its validation. + api.applyTransaction({ remove: [rowData[0]] }); + await asyncSetTimeout(1); + expect(editorCount(api)).toBe(0); + + await new GridRows(api, 'Alice purged, Bob still staged and valid').check(` + ROOT id:ROOT_NODE_ID + └── LEAF ⏳ id:1 athlete:"Bob" age:⏳31 40 + `); + + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(rowData[1].age).toBe(31); + expect(api.isBatchEditing()).toBe(false); + + await new GridRows(api, 'purged custom-editor error does not reject a later commit').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 athlete:"Bob" age:31 + `); + }); + + // A rejected commit refreshes the batch rows before commitBatchEdit() reads the rejection, so a + // renderer can re-enter the stop — which must not clear the rejection it is nested inside. + test('block: a re-entrant commit during the rejection refresh cannot end the batch', async () => { + const rowData = makeRowData(); + let armed = false; + let reentered = false; + const api = await gridsManager.createGridAndWait('validation-batch-reentrant-stop', { + columnDefs: [ + ...columnDefs, + { + colId: 'reenter', + editable: false, + valueGetter: () => '', + cellRenderer: (params: { api: GridApi }) => { + if (armed) { + armed = false; // one shot: the nested commit refreshes these rows again + reentered = true; + params.api.commitBatchEdit(); + } + return ''; + }, + }, + ], + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridDiv = getGridElement(api)! as HTMLElement; + + const stopped: BatchEditingStoppedEvent[] = []; + api.addEventListener('batchEditingStopped', (e) => stopped.push(e)); + + api.startBatchEdit(); + await asyncSetTimeout(1); + + const ageCell = cell(api, 0, 'age'); + await userEvent.dblClick(ageCell); + const ageInput = await waitForInput(gridDiv, ageCell); + await userEvent.clear(ageInput); + await userEvent.type(ageInput, '999'); + + await new GridRows(api, 'invalid 999 held open before the re-entrant commit').check(` + ROOT id:ROOT_NODE_ID reenter:"" + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ reenter:"" + └── LEAF id:1 athlete:"Bob" age:40 reenter:"" + `); + + armed = true; + api.commitBatchEdit(); + await asyncSetTimeout(1); + + expect(reentered).toBe(true); + expect(api.isBatchEditing()).toBe(true); + expect(stopped).toHaveLength(0); + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'block: re-entrant stop leaves the rejection intact').check(` + ROOT id:ROOT_NODE_ID reenter:"" + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ reenter:"" + └── LEAF id:1 athlete:"Bob" age:40 reenter:"" + `); + }); +}); diff --git a/testing/behavioural/src/cell-editing/cell-editing-validation-editors.test.ts b/testing/behavioural/src/cell-editing/cell-editing-validation-editors.test.ts new file mode 100644 index 00000000000..fa8f225d6af --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-validation-editors.test.ts @@ -0,0 +1,669 @@ +import { userEvent } from '@testing-library/user-event'; + +import type { ColDef, GridApi, GridOptions, ICellEditorComp, ICellEditorParams } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + CustomEditorModule, + DateEditorModule, + NumberEditorModule, + TextEditorModule, + getGridElement, + setupAgTestIds, +} from 'ag-grid-community'; + +import { GridRows, TestGridsManager, waitForInput } from '../test-utils'; + +interface PersonRow { + athlete: string; + age: number; + when: Date; +} + +function makeRowData(): PersonRow[] { + return [ + { athlete: 'Alice', age: 23, when: new Date(2020, 0, 15) }, + { athlete: 'Bob', age: 40, when: new Date(2020, 5, 10) }, + ]; +} + +describe('Cell editing validation — editor types and custom hooks', () => { + const gridsManager = new TestGridsManager({ + includeDefaultModules: true, + modules: [ClientSideRowModelModule, NumberEditorModule, TextEditorModule, DateEditorModule, CustomEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridsManager.reset(); + vi.clearAllMocks(); + }); + + const cell = (api: GridApi, rowIndex: number, colId: string): HTMLElement => { + const gridElement = getGridElement(api)! as HTMLElement; + return gridElement.querySelector(`[row-index="${rowIndex}"] [col-id="${colId}"]`)!; + }; + + const editorCount = (api: GridApi): number => api.getCellEditorInstances().length; + + describe('Number editor — min violation', () => { + const columnDefs: ColDef[] = [ + { field: 'athlete' }, + { field: 'age', cellEditor: 'agNumberCellEditor', cellEditorParams: { min: 0, max: 100 } }, + ]; + + const create = (invalidEditValueMode: 'revert' | 'block', rowData: PersonRow[]) => + gridsManager.createGridAndWait(`number-min-${invalidEditValueMode}`, { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode, + } satisfies GridOptions); + + test('block: value below min keeps editor open, sets ARIA/validity, clears when made valid', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '-5'); + + // DOM/ARIA side-effects of an invalid edit, asserted live while the editor is open. + expect(ageInput.getAttribute('aria-invalid')).toBe('true'); + expect(ageInput.validationMessage).toContain('greater than or equal to 0'); + + await new GridRows(api, 'block: -5 typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + await user.keyboard('{Enter}'); + + // Blocked: editor stays open, value not committed. + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'block: invalid -5 held in the editor, marked ❌, data still 23').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + // Retype a valid value — invalid state clears without committing (still blocked open until Enter). + await user.clear(ageInput); + await user.type(ageInput, '55'); + expect(ageInput.getAttribute('aria-invalid')).toBe('false'); + expect(ageInput.validationMessage).toBe(''); + + await new GridRows(api, 'block: valid 55 held in the editor, no ❌, data still 23 until Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:"Alice" age:🖍️55 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('revert: value below min reverts and closes on Enter', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '-5'); + await new GridRows(api, 'revert: -5 typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'revert: invalid -5 reverted to source 23').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); + + describe('Number editor — custom getValidationErrors callback', () => { + // Combines internal min/max rules with a custom rule; returns internalErrors passthrough when present. + const columnDefs: ColDef[] = [ + { field: 'athlete' }, + { + field: 'age', + cellEditor: 'agNumberCellEditor', + cellEditorParams: { + min: 0, + max: 100, + getValidationErrors: ({ + value, + internalErrors, + }: { + value: number; + internalErrors: string[] | null; + }) => { + if (internalErrors) { + return [...internalErrors, 'Custom rule failed']; + } + if (value === 42) { + return ['42 is not allowed']; + } + return null; + }, + }, + }, + ]; + + const create = (invalidEditValueMode: 'revert' | 'block', rowData: PersonRow[]) => + gridsManager.createGridAndWait(`number-custom-${invalidEditValueMode}`, { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode, + } satisfies GridOptions); + + test('block: custom-only error keeps editor open and does not commit', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '42'); + expect(ageInput.validationMessage).toBe('42 is not allowed'); + + await new GridRows(api, 'block: custom-rejected 42 typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + await user.keyboard('{Enter}'); + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'block: custom-rejected 42 held in the editor, marked ❌').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('block: internalErrors passed through and combined with the custom rule', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + + // Both the internal max message and the appended custom message are present (joined with '. '). + expect(ageInput.validationMessage).toContain('less than or equal to 100'); + expect(ageInput.validationMessage).toContain('Custom rule failed'); + + await new GridRows(api, 'block: 999 typed against both rules, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + await user.keyboard('{Enter}'); + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'block: internal max + custom error hold invalid 999 in the editor ❌').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('block: a value passing every rule commits and closes', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '50'); + await new GridRows(api, 'block: valid 50 typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:"Alice" age:🖍️50 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(50); + + await new GridRows(api, 'valid 50 committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:50 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); + + describe('Text editor — maxLength behaviour', () => { + const columnDefs: ColDef[] = [ + { field: 'athlete', cellEditor: 'agTextCellEditor', cellEditorParams: { maxLength: 5 } }, + { field: 'age' }, + ]; + + // NOTE: maxLength maps to the native input `maxlength` attribute (setMaxLength). The input + // caps the typed length, so `getValidationErrors` never sees an over-length value and the + // edit commits (capped). maxLength does NOT surface as a validation error via typing. + test('block: maxLength caps the input rather than producing a validation error', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('text-maxlength-block', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const athleteCell = cell(api, 0, 'athlete'); + await user.dblClick(athleteCell); + const athleteInput = await waitForInput(gridElement, athleteCell); + await user.clear(athleteInput); + await user.type(athleteInput, 'ABCDEFGHIJ'); + + // Native maxlength caps the value; no validation error is produced. + expect(athleteInput.value.length).toBeLessThanOrEqual(5); + expect(athleteInput.getAttribute('aria-invalid')).not.toBe('true'); + expect(athleteInput.validationMessage).toBe(''); + + await new GridRows(api, 'block: over-long text typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:🖍️"ABCDE" "Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + await user.keyboard('{Enter}'); + + // Capped value commits normally. + expect(editorCount(api)).toBe(0); + expect(rowData[0].athlete).toBe('ABCDE'); + + // No validation error path: the capped value committed cleanly, no markers remain. + await new GridRows(api, 'block: maxLength-capped value committed cleanly').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"ABCDE" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); + + describe('Text editor — custom getValidationErrors callback', () => { + const columnDefs: ColDef[] = [ + { + field: 'athlete', + cellEditor: 'agTextCellEditor', + cellEditorParams: { + getValidationErrors: ({ value }: { value: string }) => + value === 'BAD' ? ['Name not allowed'] : null, + }, + }, + { field: 'age' }, + ]; + + const create = (invalidEditValueMode: 'revert' | 'block', rowData: PersonRow[]) => + gridsManager.createGridAndWait(`text-custom-${invalidEditValueMode}`, { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode, + } satisfies GridOptions); + + test('block: custom error keeps editor open and sets validity', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const athleteCell = cell(api, 0, 'athlete'); + await user.dblClick(athleteCell); + const athleteInput = await waitForInput(gridElement, athleteCell); + await user.clear(athleteInput); + await user.type(athleteInput, 'BAD'); + expect(athleteInput.getAttribute('aria-invalid')).toBe('true'); + expect(athleteInput.validationMessage).toBe('Name not allowed'); + + await new GridRows(api, 'block: rejected text typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice"❌ age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + await user.keyboard('{Enter}'); + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].athlete).toBe('Alice'); + + // Custom rule rejected "BAD": editor held open, flagged ❌, source name intact. + await new GridRows(api, 'block: custom text error held in the editor ❌').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice"❌ age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('revert: custom error reverts and closes on Enter', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const athleteCell = cell(api, 0, 'athlete'); + await user.dblClick(athleteCell); + const athleteInput = await waitForInput(gridElement, athleteCell); + await user.clear(athleteInput); + await user.type(athleteInput, 'BAD'); + await new GridRows(api, 'revert: rejected text typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice"❌ age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].athlete).toBe('Alice'); + + // Revert discarded "BAD" and closed the editor — source name restored, no markers. + await new GridRows(api, 'revert: custom text error reverted to source').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); + + describe('Date editor — custom getValidationErrors callback', () => { + // Date min/max relies on the native date input reporting `valueAsDate`, which is not driven + // reliably by simulated typing under jsdom; a custom rule exercises the DateEditor path instead. + const columnDefs: ColDef[] = [ + { field: 'athlete' }, + { + field: 'when', + cellEditor: 'agDateCellEditor', + cellEditorParams: { + getValidationErrors: ({ value }: { value: Date | null }) => + value && value.getUTCFullYear() < 2000 ? ['Date too early'] : null, + }, + }, + ]; + + const create = (invalidEditValueMode: 'revert' | 'block', rowData: PersonRow[]) => + gridsManager.createGridAndWait(`date-custom-${invalidEditValueMode}`, { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode, + } satisfies GridOptions); + + test('block: too-early date keeps editor open and does not commit', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const whenCell = cell(api, 0, 'when'); + await user.dblClick(whenCell); + const whenInput = await waitForInput(gridElement, whenCell); + await user.clear(whenInput); + await user.type(whenInput, '1995-06-15'); + expect(whenInput.validationMessage).toBe('Date too early'); + + await new GridRows(api, 'block: too-early date typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" when:"2020-01-15"❌ + └── LEAF id:1 athlete:"Bob" when:"2020-06-10" + `); + + await user.keyboard('{Enter}'); + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].when).toEqual(new Date(2020, 0, 15)); + + // Custom rule rejected the too-early date: editor held open ❌, source date intact. + await new GridRows(api, 'block: too-early date held in the editor ❌').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" when:"2020-01-15"❌ + └── LEAF id:1 athlete:"Bob" when:"2020-06-10" + `); + }); + + test('revert: too-early date reverts and closes on Enter', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const whenCell = cell(api, 0, 'when'); + await user.dblClick(whenCell); + const whenInput = await waitForInput(gridElement, whenCell); + await user.clear(whenInput); + await user.type(whenInput, '1995-06-15'); + await new GridRows(api, 'revert: too-early date typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" when:"2020-01-15"❌ + └── LEAF id:1 athlete:"Bob" when:"2020-06-10" + `); + + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].when).toEqual(new Date(2020, 0, 15)); + + // Revert discarded the invalid date and closed the editor — source date restored. + await new GridRows(api, 'revert: too-early date reverted to source').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" when:"2020-01-15" + └── LEAF id:1 athlete:"Bob" when:"2020-06-10" + `); + }); + }); + + describe('fullRow — per-cell custom validation blocks the whole row', () => { + const columnDefs: ColDef[] = [ + { field: 'athlete' }, + { + field: 'age', + cellEditor: 'agNumberCellEditor', + cellEditorParams: { + getValidationErrors: ({ value }: { value: number }) => + value === 42 ? ['42 is not allowed'] : null, + }, + }, + ]; + + test('block: an invalid cell keeps every row editor open on Enter', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('fullrow-custom-block', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '42'); + await new GridRows(api, 'fullRow block: invalid age typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + await user.keyboard('{Enter}'); + + // Full-row edit opens editors on every editable cell; the invalid cell blocks all of them. + expect(editorCount(api)).toBeGreaterThan(1); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'fullRow block: invalid age 42 marked ❌ holds the whole row open').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); + + describe('Custom cell editor (CustomEditorModule) implementing getValidationErrors', () => { + class ValidatingEditor implements ICellEditorComp { + private eGui!: HTMLInputElement; + + public init(params: ICellEditorParams): void { + this.eGui = document.createElement('input'); + this.eGui.value = String(params.value ?? ''); + } + + public getGui(): HTMLElement { + return this.eGui; + } + + public afterGuiAttached(): void { + this.eGui.focus(); + } + + public getValue(): string { + return this.eGui.value; + } + + public getValidationErrors(): string[] | null { + return this.eGui.value === 'INVALID' ? ['Custom editor rejected value'] : null; + } + } + + test('block: custom editor validation error keeps the editor open', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('custom-editor-block', { + columnDefs: [{ field: 'athlete', cellEditor: ValidatingEditor }, { field: 'age' }], + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const athleteCell = cell(api, 0, 'athlete'); + await user.dblClick(athleteCell); + const athleteInput = await waitForInput(gridElement, athleteCell); + await user.clear(athleteInput); + await user.type(athleteInput, 'INVALID'); + await new GridRows(api, 'block: custom editor value typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].athlete).toBe('Alice'); + + await new GridRows(api, 'block: custom editor rejected "INVALID", held in the editor ❌').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice"❌ age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); + + describe('Composite custom cell editor (multiple inputs)', () => { + // Renders two inputs; the value lives in the second, so the first is an "arbitrary" input. + // GridRows must not compare its DOM value against getValue() — that input never holds it. + class CompositeEditor implements ICellEditorComp { + private eGui!: HTMLElement; + private eValue!: HTMLInputElement; + + public init(params: ICellEditorParams): void { + this.eGui = document.createElement('div'); + const decorative = document.createElement('input'); + decorative.value = 'decorative'; + this.eValue = document.createElement('input'); + this.eValue.value = String(params.value ?? ''); + this.eGui.appendChild(decorative); + this.eGui.appendChild(this.eValue); + } + + public getGui(): HTMLElement { + return this.eGui; + } + + public afterGuiAttached(): void { + this.eValue.focus(); + } + + public getValue(): string { + return this.eValue.value; + } + } + + test('a valid value held open is not reported as a DOM value mismatch', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('custom-editor-composite', { + columnDefs: [{ field: 'athlete', cellEditor: CompositeEditor }, { field: 'age' }], + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const athleteCell = cell(api, 0, 'athlete'); + await user.dblClick(athleteCell); + const valueInput = await waitForInput(gridElement, athleteCell, { selector: 'input:nth-of-type(2)' }); + await user.clear(valueInput); + await user.type(valueInput, 'Amy'); + + expect(editorCount(api)).toBeGreaterThan(0); + + // The first (decorative) input holds "decorative" ≠ getValue() "Amy"; GridRows tolerates it + // because a composite editor's value need not map to any single input. + await new GridRows(api, 'composite editor: valid value held open, no value-mismatch error').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); +}); diff --git a/testing/behavioural/src/cell-editing/cell-editing-validation-filter-change-react.test.tsx b/testing/behavioural/src/cell-editing/cell-editing-validation-filter-change-react.test.tsx new file mode 100644 index 00000000000..7245f75d30a --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-validation-filter-change-react.test.tsx @@ -0,0 +1,397 @@ +import { waitFor } from '@testing-library/dom'; +import { act, cleanup, render } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { vi } from 'vitest'; + +import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'; +import { + CellApiModule, + ClientSideRowModelModule, + ColumnApiModule, + CustomEditorModule, + ModuleRegistry, + NumberEditorModule, + RowApiModule, + TextEditorModule, + TextFilterModule, + ValidationModule, + getGridElement, + setupAgTestIds, +} from 'ag-grid-community'; +import type { CustomCellEditorProps } from 'ag-grid-react'; +import { AgGridReact, useGridCellEditor } from 'ag-grid-react'; + +import { GridRows, asyncSetTimeout, ignoreConsoleLicenseKeyError, waitForPopup } from '../test-utils'; + +interface PersonRow { + athlete: string; + age: number; + phone?: string; + hideable?: string; +} + +const PHONE_REGEX = /^\(\d{3}\)\s\d{3}-\d{4}$/; + +// Records the props last handed to the phone editor (TC2: verify cellEditorParams reach the component). +let lastPhoneProps: CustomCellEditorProps | undefined; + +// Faithful port of docs reactFunctionalTs/phoneEditor.tsx (the editor the ticket was reported against). +const PhoneEditor = memo((props: CustomCellEditorProps) => { + lastPhoneProps = props; + const { value, onValueChange, validate, cellStartedEdit, eventKey } = props; + const inputRef = useRef(null); + const [internalValue, setInternalValue] = useState(value || ''); + + const getValidationErrors = useCallback( + () => (PHONE_REGEX.test(internalValue.trim()) ? null : ['Invalid phone format. Use (123) 456-7890']), + [internalValue] + ); + const getValidationElement = useCallback(() => inputRef.current!, []); + + useGridCellEditor({ getValidationErrors, getValidationElement }); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + if (cellStartedEdit && eventKey?.length === 1) { + setInternalValue(eventKey); + } + }, []); + + const onChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setInternalValue(val); + onValueChange(val); + setTimeout(() => validate?.()); + }; + + return ( + validate?.()} + /> + ); +}); + +interface NumericRow { + name: string; + number: number; +} + +// Faithful port of the reported popup NumericEditor: value > 100 is invalid, validation element is the wrapper. +const NumericEditor = memo((props: CustomCellEditorProps) => { + const { value, onValueChange, validate } = props; + const wrapperRef = useRef(null); + const inputRef = useRef(null); + const [internalValue, setInternalValue] = useState(value ?? null); + + const getValidationErrors = useCallback( + () => (internalValue != null && internalValue > 100 ? ['Value over 100'] : null), + [internalValue] + ); + const getValidationElement = useCallback(() => wrapperRef.current!, []); + + useGridCellEditor({ getValidationErrors, getValidationElement }); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const onChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + const parsed = raw === '' ? null : parseInt(raw, 10); + setInternalValue(parsed); + onValueChange(parsed); + setTimeout(() => validate?.()); + }; + + return ( +
+ +
+ ); +}); + +async function renderGrid(options: { + columnDefs: ColDef[]; + rowData: T[]; + editType?: 'singleCell' | 'fullRow'; +}): Promise<{ api: GridApi; gridDiv: HTMLElement; user: ReturnType }> { + let readyResolve!: (api: GridApi) => void; + const readyPromise = new Promise((resolve) => { + readyResolve = resolve; + }); + + render( +
+ + columnDefs={options.columnDefs} + rowData={options.rowData} + defaultColDef={{ editable: true, filter: true }} + editType={options.editType ?? 'singleCell'} + invalidEditValueMode="block" + stopEditingWhenCellsLoseFocus + onGridReady={(params: GridReadyEvent) => readyResolve(params.api)} + /> +
+ ); + + const api = await readyPromise; + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + return { api, gridDiv, user }; +} + +describe('Cell editing validation — custom editor state after focus/filter/column changes (React)', () => { + beforeAll(() => { + ModuleRegistry.registerModules([ + ValidationModule, + ClientSideRowModelModule, + ColumnApiModule, + NumberEditorModule, + TextEditorModule, + CustomEditorModule, + TextFilterModule, + // GridRows reads the grid through the API: the row model, and each cell's value. + RowApiModule, + CellApiModule, + ]); + setupAgTestIds(); + }); + + beforeEach(() => ignoreConsoleLicenseKeyError()); + + afterEach(() => { + cleanup(); + lastPhoneProps = undefined; + vi.clearAllMocks(); + }); + + const cellOf = (gridDiv: HTMLElement, rowIndex: number, colId: string): HTMLElement => + gridDiv.querySelector(`[row-index="${rowIndex}"] [col-id="${colId}"]`)!; + + const inputIn = (container: HTMLElement, selector = 'input'): Promise => + waitFor(() => { + const input = container.querySelector(selector); + if (!input) { + throw new Error(`input not found for selector ${selector}`); + } + return input; + }); + + const phoneColumns: ColDef[] = [ + { + field: 'athlete', + cellEditorParams: { + getValidationErrors: ({ value }: { value: string }) => + !value || value.length < 3 ? ['Name must be at least 3 characters long'] : null, + }, + }, + { field: 'age', cellEditor: 'agNumberCellEditor', cellEditorParams: { min: 0, max: 100 } }, + // A marker param to assert it flows to the component on (re-)open (TC2). + { + field: 'phone', + headerName: 'Custom Phone Editor', + cellEditor: PhoneEditor, + cellEditorParams: { marker: 42 }, + }, + { field: 'hideable', hide: true }, + ]; + + const makeRowData = (): PersonRow[] => [ + { athlete: 'Alice', age: 23, hideable: 'x' }, + { athlete: 'Bob', age: 40, hideable: 'y' }, + ]; + + // TC1 baseline: built-in editor over a populated value discards the in-flight edit on filter change. + test('TC1 built-in: invalid in-flight age is discarded when the filter changes', async () => { + const rowData = makeRowData(); + const { api, gridDiv, user } = await renderGrid({ columnDefs: phoneColumns, rowData }); + + await user.dblClick(cellOf(gridDiv, 0, 'age')); + const ageInput = await inputIn(cellOf(gridDiv, 0, 'age')); + await user.clear(ageInput); + await user.type(ageInput, '999'); + + await waitFor(() => expect(api.getEditingCells()).toHaveLength(1)); + + await new GridRows(api, 'built-in: invalid age in flight').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + + act(() => { + api.setColumnsVisible(['hideable'], true); + api.onFilterChanged(); + }); + await asyncSetTimeout(0); + + await waitFor(() => expect(api.getCellEditorInstances()).toHaveLength(0)); + expect(api.getEditingCells()).toHaveLength(0); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'built-in: the filter change discarded the invalid age').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + }); + + // TC1 bug: custom editor over an undefined value must behave like the built-in editor. + test('TC1 custom (undefined source): invalid in-flight phone is discarded when the filter changes', async () => { + const rowData = makeRowData(); + const { api, gridDiv, user } = await renderGrid({ columnDefs: phoneColumns, rowData }); + + await user.dblClick(cellOf(gridDiv, 0, 'phone')); + const phoneInput = await inputIn(cellOf(gridDiv, 0, 'phone'), '.phone-cell-editor'); + await user.clear(phoneInput); + await user.type(phoneInput, 'ab'); // invalid format + await asyncSetTimeout(0); // flush editor's async validate() + + await waitFor(() => expect(api.getEditingCells()).toHaveLength(1)); + + act(() => { + api.setColumnsVisible(['hideable'], true); + api.onFilterChanged(); + }); + await asyncSetTimeout(0); + + await waitFor(() => expect(api.getCellEditorInstances()).toHaveLength(0)); + expect(api.getEditingCells()).toHaveLength(0); + expect(rowData[0].phone).toBeUndefined(); + + await new GridRows(api, 'custom: the filter change discarded the invalid phone').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + + // The reporter's symptom was an orphaned error + stuck focus. Prove the cell re-opens cleanly. + await user.dblClick(cellOf(gridDiv, 0, 'phone')); + const reopened = await inputIn(cellOf(gridDiv, 0, 'phone'), '.phone-cell-editor'); + expect(reopened.disabled).toBe(false); + await waitFor(() => expect(api.getCellEditorInstances()).toHaveLength(1)); + + await new GridRows(api, 'custom: the cell re-opened cleanly').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + }); + + // TC2: after the filter change closes the editor, re-opening must still pass cellEditorParams. + test('TC2 custom: re-opening after a filter change still receives its cellEditorParams', async () => { + const rowData = makeRowData(); + const { api, gridDiv, user } = await renderGrid({ columnDefs: phoneColumns, rowData }); + + await user.dblClick(cellOf(gridDiv, 0, 'phone')); + let phoneInput = await inputIn(cellOf(gridDiv, 0, 'phone'), '.phone-cell-editor'); + await user.clear(phoneInput); + await user.type(phoneInput, 'ab'); + await asyncSetTimeout(0); + + await new GridRows(api, 'custom: invalid phone in flight before the filter change').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + + act(() => { + api.setColumnsVisible(['hideable'], true); + api.onFilterChanged(); + }); + await asyncSetTimeout(0); + await waitFor(() => expect(api.getCellEditorInstances()).toHaveLength(0)); + + await new GridRows(api, 'custom: editor closed by the filter change').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + + lastPhoneProps = undefined; + + // Re-open the same cell. + await user.dblClick(cellOf(gridDiv, 0, 'phone')); + phoneInput = await inputIn(cellOf(gridDiv, 0, 'phone'), '.phone-cell-editor'); + + await new GridRows(api, 'custom: re-opened editor on the source value').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + + await waitFor(() => expect(lastPhoneProps).toBeDefined()); + expect(lastPhoneProps!.colDef?.field).toBe('phone'); + expect(lastPhoneProps!.column).toBeDefined(); + expect(lastPhoneProps!.node).toBeDefined(); + expect((lastPhoneProps!.colDef?.cellEditorParams as { marker: number })?.marker).toBe(42); + }); + + // TC3: a popup custom editor blocked on an invalid value stays editable after focus loss + re-open. + test('TC3 popup custom: invalid value + focus loss + re-open leaves the cell editable', async () => { + const rowData: NumericRow[] = [ + { name: 'Bob', number: 10 }, + { name: 'Harry', number: 3 }, + ]; + const { api, gridDiv, user } = await renderGrid({ + columnDefs: [ + { + headerName: 'Provided Text', + field: 'name', + cellEditorParams: { + getValidationErrors: ({ value }: { value: string }) => + !value || value.length > 10 ? ['this is an error'] : null, + }, + }, + { headerName: 'Custom Numeric', field: 'number', cellEditor: NumericEditor, cellEditorPopup: true }, + ], + rowData, + }); + + await user.dblClick(cellOf(gridDiv, 0, 'number')); + let popup = await waitForPopup(gridDiv); + let numberInput = await inputIn(popup, '.numeric-input'); + await user.clear(numberInput); + await user.type(numberInput, '150'); // invalid: > 100 + await asyncSetTimeout(0); + + await new GridRows(api, 'popup: invalid value typed in the popup').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 name:"Bob" number:10❌ + └── LEAF id:1 name:"Harry" number:3 + `); + + // Click out of the grid — stopEditingWhenCellsLoseFocus triggers a stop; block holds it open. + await user.click(document.body); + await asyncSetTimeout(0); + + // Re-open the same cell — a fresh, usable popup editor must appear. + await user.dblClick(cellOf(gridDiv, 0, 'number')); + popup = await waitForPopup(gridDiv); + numberInput = await inputIn(popup, '.numeric-input'); + + expect(numberInput.disabled).toBe(false); + expect(numberInput.readOnly).toBe(false); + + await user.clear(numberInput); + await user.type(numberInput, '50'); // valid correction + await user.keyboard('{Enter}'); + + await waitFor(() => expect(api.getCellEditorInstances()).toHaveLength(0)); + expect(rowData[0].number).toBe(50); + + await new GridRows(api, 'popup: the corrected value committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 name:"Bob" number:50 + └── LEAF id:1 name:"Harry" number:3 + `); + }); +}); diff --git a/testing/behavioural/src/cell-editing/cell-editing-validation-filter-change.test.ts b/testing/behavioural/src/cell-editing/cell-editing-validation-filter-change.test.ts new file mode 100644 index 00000000000..2f8c8e3761d --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-validation-filter-change.test.ts @@ -0,0 +1,449 @@ +import { userEvent } from '@testing-library/user-event'; + +import type { GridApi, GridOptions, ICellEditorComp, ICellEditorParams } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + CustomEditorModule, + NumberEditorModule, + TextEditorModule, + TextFilterModule, + getGridElement, + setupAgTestIds, +} from 'ag-grid-community'; + +import { GridRows, TestGridsManager, asyncSetTimeout, waitForInput, waitForPopup } from '../test-utils'; + +interface PersonRow { + athlete: string; + age: number; + // Seeded as undefined so the custom editor starts from a null/undefined source value. + phone?: string; + hideable?: string; +} + +function makeRowData(): PersonRow[] { + return [ + { athlete: 'Alice', age: 23, hideable: 'x' }, + { athlete: 'Bob', age: 40, hideable: 'y' }, + ]; +} + +// Faithful port of the reported PhoneEditor: getValidationErrors + getValidationElement, driving +// validation via an async params.validate() on input/blur (as the React editor did). +const PHONE_REGEX = /^\(\d{3}\)\s\d{3}-\d{4}$/; +let lastPhoneParams: ICellEditorParams | undefined; + +class PhoneEditor implements ICellEditorComp { + private params!: ICellEditorParams; + private eInput!: HTMLInputElement; + + public init(params: ICellEditorParams): void { + lastPhoneParams = params; + this.params = params; + this.eInput = document.createElement('input'); + this.eInput.type = 'text'; + this.eInput.value = String(params.value ?? ''); + + this.eInput.addEventListener('input', () => { + setTimeout(() => this.params.validate?.()); + }); + this.eInput.addEventListener('blur', () => this.params.validate?.()); + } + + public getGui(): HTMLElement { + return this.eInput; + } + + public afterGuiAttached(): void { + this.eInput.focus(); + this.eInput.select(); + } + + public getValue(): string { + return this.eInput.value; + } + + public getValidationErrors(): string[] | null { + const trimmed = (this.eInput.value || '').trim(); + return PHONE_REGEX.test(trimmed) ? null : ['Invalid phone format. Use (123) 456-7890']; + } + + public getValidationElement(): HTMLElement { + return this.eInput; + } +} + +interface NumericRow { + name: string; + number: number; +} + +// Faithful port of the reported popup NumericEditor (TC3): getValidationErrors reads the grid-managed +// value, getValidationElement returns a wrapper div, and updateValue drives synchronous validation. +class NumericEditor implements ICellEditorComp { + private params!: ICellEditorParams; + private eGui!: HTMLElement; + private eInput!: HTMLInputElement; + private value: number | null = null; + // Push validation into the model on each keystroke; PullOnlyNumericEditor turns this off. + protected pushValidation = true; + + public init(params: ICellEditorParams): void { + this.params = params; + this.eGui = document.createElement('div'); + this.eGui.className = 'numeric-editor-wrapper'; + this.eInput = document.createElement('input'); + this.eInput.className = 'numeric-input'; + this.eGui.appendChild(this.eInput); + + const startValue = params.value == null ? '' : String(params.value); + this.updateValue(startValue); + this.eInput.value = this.value == null ? '' : String(this.value); + + this.eInput.addEventListener('input', (event) => this.updateValue((event.target as HTMLInputElement).value)); + } + + private updateValue(val: string): void { + this.value = val === '' ? null : parseInt(val, 10); + if (this.pushValidation) { + this.params.validate?.(); + } + } + + public getGui(): HTMLElement { + return this.eGui; + } + + public afterGuiAttached(): void { + this.eInput.focus(); + } + + public getValue(): number | null { + return this.value; + } + + public isPopup(): boolean { + return true; + } + + public getValidationErrors(): string[] | null { + return this.value != null && this.value > 100 ? ['Value over 100'] : null; + } + + public getValidationElement(): HTMLElement { + return this.eGui; + } +} + +// Pull-only popup editor: exposes validity via getValidationErrors but never pushes it through +// params.validate(), so the validation model stays empty until the grid reads it on stop. +class PullOnlyNumericEditor extends NumericEditor { + protected override pushValidation = false; +} + +describe('Cell editing validation — custom editor state after focus/filter/column changes', () => { + const gridsManager = new TestGridsManager({ + includeDefaultModules: true, + modules: [ClientSideRowModelModule, NumberEditorModule, TextEditorModule, CustomEditorModule, TextFilterModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridsManager.reset(); + lastPhoneParams = undefined; + vi.clearAllMocks(); + }); + + const cell = (api: GridApi, rowIndex: number, colId: string): HTMLElement => { + const gridElement = getGridElement(api)! as HTMLElement; + return gridElement.querySelector(`[row-index="${rowIndex}"] [col-id="${colId}"]`)!; + }; + + const editorCount = (api: GridApi): number => api.getCellEditorInstances().length; + + const createPhoneGrid = (rowData: PersonRow[]) => + gridsManager.createGridAndWait('filter-change-edit', { + columnDefs: [ + { + field: 'athlete', + cellEditorParams: { + getValidationErrors: ({ value }: { value: string }) => + !value || value.length < 3 ? ['Name must be at least 3 characters long'] : null, + }, + }, + { field: 'age', cellEditor: 'agNumberCellEditor', cellEditorParams: { min: 0, max: 100 } }, + { field: 'phone', headerName: 'Custom Phone Editor', cellEditor: PhoneEditor }, + { field: 'hideable', hide: true }, + ], + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + stopEditingWhenCellsLoseFocus: true, + } satisfies GridOptions); + + test('built-in: invalid in-flight age is discarded when the filter changes', async () => { + const rowData = makeRowData(); + const api = await createPhoneGrid(rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + + expect(editorCount(api)).toBeGreaterThan(0); + expect(api.getEditingCells()).toHaveLength(1); + + await new GridRows(api, 'built-in: invalid age in flight').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + + api.setColumnsVisible(['hideable'], true); + api.onFilterChanged(); + + expect(editorCount(api)).toBe(0); + expect(api.getEditingCells()).toHaveLength(0); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'built-in: the filter change discarded the invalid age').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + }); + + test('custom (undefined source): invalid in-flight phone is discarded when the filter changes', async () => { + const rowData = makeRowData(); + const api = await createPhoneGrid(rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const phoneCell = cell(api, 0, 'phone'); + await user.dblClick(phoneCell); + const phoneInput = await waitForInput(gridElement, phoneCell); + await user.clear(phoneInput); + await user.type(phoneInput, 'ab'); // invalid format + await asyncSetTimeout(0); // flush the editor's async validate() + + expect(api.getEditingCells()).toHaveLength(1); + + await new GridRows(api, 'custom: invalid phone in flight').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + + api.setColumnsVisible(['hideable'], true); + api.onFilterChanged(); + await asyncSetTimeout(0); + + // Expected: custom editor behaves like the built-in — edit discarded, editor closed, no orphan. + expect(editorCount(api)).toBe(0); + expect(api.getEditingCells()).toHaveLength(0); + expect(rowData[0].phone).toBeUndefined(); + + await new GridRows(api, 'custom: the filter change discarded the invalid phone').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + + // The reporter's symptom was an orphaned error + stuck focus. Prove the cell re-opens cleanly. + await user.dblClick(cell(api, 0, 'phone')); + const reopened = await waitForInput(getGridElement(api)! as HTMLElement, cell(api, 0, 'phone')); + expect(reopened.disabled).toBe(false); + expect(editorCount(api)).toBe(1); + + await new GridRows(api, 'custom: the cell re-opened cleanly').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + }); + + test('custom: re-opening after a filter change still receives its params', async () => { + const rowData = makeRowData(); + const api = await createPhoneGrid(rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const phoneCell = cell(api, 0, 'phone'); + await user.dblClick(phoneCell); + let phoneInput = await waitForInput(gridElement, phoneCell); + await user.clear(phoneInput); + await user.type(phoneInput, 'ab'); + await asyncSetTimeout(0); + + await new GridRows(api, 'custom: invalid phone in flight before the filter change').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + + api.setColumnsVisible(['hideable'], true); + api.onFilterChanged(); + await asyncSetTimeout(0); + + await new GridRows(api, 'custom: editor closed by the filter change').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + + lastPhoneParams = undefined; + + const phoneCellAgain = cell(api, 0, 'phone'); + await user.dblClick(phoneCellAgain); + phoneInput = await waitForInput(gridElement, phoneCellAgain); + + await new GridRows(api, 'custom: re-opened editor on the source value').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23 hideable:"x" + └── LEAF id:1 athlete:"Bob" age:40 hideable:"y" + `); + + expect(lastPhoneParams).toBeDefined(); + expect(lastPhoneParams!.colDef?.field).toBe('phone'); + expect(lastPhoneParams!.column).toBeDefined(); + expect(lastPhoneParams!.node).toBeDefined(); + }); + + // TC3: a popup custom editor blocked on an invalid value must still be editable after the grid + // loses focus and the cell is re-opened — its inputs must not come back disabled. + test('TC3 popup custom: invalid value + focus loss + re-open leaves the cell editable', async () => { + const numericGrid = () => + gridsManager.createGridAndWait('numeric-popup-block', { + columnDefs: [ + { + headerName: 'Provided Text', + field: 'name', + cellEditorParams: { + getValidationErrors: ({ value }: { value: string }) => + !value || value.length > 10 ? ['this is an error'] : null, + }, + }, + { headerName: 'Custom Numeric', field: 'number', cellEditor: NumericEditor, cellEditorPopup: true }, + ], + rowData: [ + { name: 'Bob', number: 10 }, + { name: 'Harry', number: 3 }, + ] satisfies NumericRow[], + defaultColDef: { editable: true, filter: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + stopEditingWhenCellsLoseFocus: true, + } satisfies GridOptions); + + const api = await numericGrid(); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const numberCell = cell(api, 0, 'number'); + await user.dblClick(numberCell); + let popup = await waitForPopup(gridElement); + let numberInput = popup.querySelector('.numeric-input')!; + await user.clear(numberInput); + await user.type(numberInput, '150'); // invalid: > 100 + + // Click out of the grid — stopEditingWhenCellsLoseFocus stops editing and closes the popup. + await user.click(document.body); + await asyncSetTimeout(0); + + // Re-open the same cell — a fresh, usable popup editor must appear. + await user.dblClick(cell(api, 0, 'number')); + popup = await waitForPopup(gridElement); + numberInput = popup.querySelector('.numeric-input')!; + + // The input must be usable: present, not disabled/readonly, and correctable. + expect(numberInput).toBeTruthy(); + expect(numberInput.disabled).toBe(false); + expect(numberInput.readOnly).toBe(false); + + await user.clear(numberInput); + await user.type(numberInput, '50'); // valid correction + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBe(0); + expect((api.getRenderedNodes()[0].data as NumericRow).number).toBe(50); + + await new GridRows(api, 'TC3: invalid value corrected after focus loss + re-open').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 name:"Bob" number:50 + └── LEAF id:1 name:"Harry" number:3 + `); + }); + + // TC3 variant: a pull-only popup editor (validity only via getValidationErrors) leaves the validation + // model empty while typing, so onPopupEditorClosed must read fresh validation before deciding. The + // popup DOM is already gone, so block mode reverts rather than holds — a hold leaves the cell dead. + test('TC3 pull-only popup: invalid value + focus loss reverts and leaves the cell editable', async () => { + const api = await gridsManager.createGridAndWait('numeric-popup-pull-only-block', { + columnDefs: [ + { headerName: 'Provided Text', field: 'name' }, + { + headerName: 'Custom Numeric', + field: 'number', + cellEditor: PullOnlyNumericEditor, + cellEditorPopup: true, + }, + ], + rowData: [ + { name: 'Bob', number: 10 }, + { name: 'Harry', number: 3 }, + ] satisfies NumericRow[], + defaultColDef: { editable: true, filter: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + stopEditingWhenCellsLoseFocus: true, + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const numberCell = cell(api, 0, 'number'); + await user.dblClick(numberCell); + let popup = await waitForPopup(gridElement); + let numberInput = popup.querySelector('.numeric-input')!; + await user.clear(numberInput); + await user.type(numberInput, '150'); // invalid: > 100, never pushed to the model + + // Click out of the grid — the popup closes; the invalid held value must revert, not orphan. + await user.click(document.body); + await asyncSetTimeout(0); + + expect(editorCount(api)).toBe(0); + expect(api.getEditingCells()).toHaveLength(0); + expect((api.getRenderedNodes()[0].data as NumericRow).number).toBe(10); + + await new GridRows(api, 'pull-only popup: invalid attempt reverted on focus loss').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 name:"Bob" number:10 + └── LEAF id:1 name:"Harry" number:3 + `); + + // Re-open the same cell — a fresh, usable popup editor must appear and commit a correction. + await user.dblClick(cell(api, 0, 'number')); + popup = await waitForPopup(gridElement); + numberInput = popup.querySelector('.numeric-input')!; + expect(numberInput.disabled).toBe(false); + await user.clear(numberInput); + await user.type(numberInput, '50'); + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBe(0); + expect((api.getRenderedNodes()[0].data as NumericRow).number).toBe(50); + + await new GridRows(api, 'pull-only popup: corrected 50 committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 name:"Bob" number:50 + └── LEAF id:1 name:"Harry" number:3 + `); + }); +}); diff --git a/testing/behavioural/src/cell-editing/cell-editing-validation-navigation.test.ts b/testing/behavioural/src/cell-editing/cell-editing-validation-navigation.test.ts new file mode 100644 index 00000000000..20e2bf4e8cf --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-validation-navigation.test.ts @@ -0,0 +1,691 @@ +import { userEvent } from '@testing-library/user-event'; + +import type { ColDef, GridApi, GridOptions } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + NumberEditorModule, + ScrollApiModule, + TextEditorModule, + getGridElement, + setupAgTestIds, +} from 'ag-grid-community'; + +import { GridRows, TestGridsManager, asyncSetTimeout, waitForInput } from '../test-utils'; + +interface PersonRow { + athlete: string; + age: number; +} + +const columnDefs: ColDef[] = [ + { field: 'athlete', cellEditor: 'agTextCellEditor', cellEditorParams: { maxLength: 10 } }, + { field: 'age', cellEditor: 'agNumberCellEditor', cellEditorParams: { min: 0, max: 100 } }, +]; + +function makeRowData(): PersonRow[] { + return [ + { athlete: 'Alice', age: 23 }, + { athlete: 'Bob', age: 40 }, + { athlete: 'Carol', age: 31 }, + ]; +} + +describe('Cell editing validation interacting with navigation', () => { + const gridsManager = new TestGridsManager({ + includeDefaultModules: true, + modules: [ClientSideRowModelModule, NumberEditorModule, ScrollApiModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridsManager.reset(); + vi.clearAllMocks(); + }); + + const cell = (api: GridApi, rowIndex: number, colId: string): HTMLElement => { + const gridElement = getGridElement(api)! as HTMLElement; + return gridElement.querySelector(`[row-index="${rowIndex}"] [col-id="${colId}"]`)!; + }; + + const editorCount = (api: GridApi): number => api.getCellEditorInstances().length; + + // --- singleCell block: Tab / Shift+Tab out of an invalid cell is prevented --- + + test('singleCell block: Tab from an invalid cell is blocked, editor stays, no commit', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-singleCell-block-tab', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await new GridRows(api, 'singleCell block: invalid age typed, before Tab').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{Tab}'); + + expect(editorCount(api)).toBeGreaterThan(0); + expect(cell(api, 0, 'age').querySelector('input')).toBeTruthy(); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'singleCell block: Tab blocked, age held invalid ❌, data still 23').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + test('singleCell block: Shift+Tab from an invalid cell is blocked, editor stays, no commit', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-singleCell-block-shifttab', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await new GridRows(api, 'singleCell block: invalid age typed, before Shift+Tab').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{Shift>}{Tab}{/Shift}'); + + expect(editorCount(api)).toBeGreaterThan(0); + expect(cell(api, 0, 'age').querySelector('input')).toBeTruthy(); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'block: Shift+Tab blocked, invalid value still held').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + // --- singleCell revert: Tab / Shift+Tab out of an invalid cell reverts --- + + test('singleCell revert: Tab from an invalid cell reverts and does not commit', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-singleCell-revert-tab', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'revert', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await new GridRows(api, 'singleCell revert: invalid age typed, before Tab').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{Tab}'); + + // The invalid edit reverted: the source cell no longer holds an open editor and data is unchanged. + expect(cell(api, 0, 'age').querySelector('input')).toBeFalsy(); + expect(rowData[0].age).toBe(23); + + // Source value restored on the reverted cell (no pending marker on age row 0). + await new GridRows(api, 'singleCell revert: source value restored').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + ├── LEAF 🖍️ id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + test('singleCell revert: Shift+Tab from an invalid cell reverts and does not commit', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-singleCell-revert-shifttab', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'revert', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await new GridRows(api, 'singleCell revert: invalid age typed, before Shift+Tab').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{Shift>}{Tab}{/Shift}'); + + expect(cell(api, 0, 'age').querySelector('input')).toBeFalsy(); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'revert: Shift+Tab discarded the invalid value').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:"Alice" age:23 + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + // --- fullRow block: navigation blocked while a cell in the row is invalid --- + + test('fullRow block: Tab between cells within the row is blocked, editors stay, focus stays in row', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-fullRow-block-within', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + // Full-row edit opens editors for every editable cell (athlete + age). + await user.dblClick(cell(api, 0, 'athlete')); + const ageInput = await waitForInput(gridElement, cell(api, 0, 'age')); + await user.clear(ageInput); + await user.type(ageInput, '999'); + + // Move focus back to the (valid) athlete cell, then Tab towards the invalid age cell. + const athleteInput = await waitForInput(gridElement, cell(api, 0, 'athlete')); + athleteInput.focus(); + await new GridRows(api, 'fullRow block: invalid age typed, before Tab within the row').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{Tab}'); + + const rowEl = gridElement.querySelector('[row-index="0"]')!; + expect(editorCount(api)).toBe(2); + expect(cell(api, 0, 'athlete').querySelector('input')).toBeTruthy(); + expect(rowEl.contains(document.activeElement)).toBe(true); + // Moving within the row under edit is legitimate, so the caret hands over to the next editor. + expect(document.activeElement).toBe(cell(api, 0, 'age').querySelector('input')); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'fullRow block: whole row editing, age held invalid ❌, data still 23').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + test('fullRow block: Tab that would leave the last cell of the row is blocked', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-fullRow-block-leave', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + // Open the row via the last editable cell (age) and make it invalid, then Tab forward. + await user.dblClick(cell(api, 0, 'age')); + const ageInput = await waitForInput(gridElement, cell(api, 0, 'age')); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await new GridRows(api, 'fullRow block: invalid age typed, before Tab off the row').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{Tab}'); + + // Block guarantee: the row's editors stay open, no value commits, the next row is NOT + // entered for editing, and focus stays pinned to the invalid row (it must not leak to the + // next row's cell). + const rowEl = gridElement.querySelector('[row-index="0"]')!; + expect(editorCount(api)).toBe(2); + expect(rowData[0].age).toBe(23); + expect(cell(api, 1, 'athlete').querySelector('input')).toBeFalsy(); + expect(rowEl.contains(document.activeElement)).toBe(true); + + await new GridRows(api, 'fullRow block: Tab off last cell blocked, row 1 not entered, age ❌').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + test('singleCell block: Tab off the last column does not leak focus to the next row', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-singleCell-block-leave', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + // age is the last column; make it invalid, then Tab forward (would cross into the next row). + await user.dblClick(cell(api, 0, 'age')); + const ageInput = await waitForInput(gridElement, cell(api, 0, 'age')); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await new GridRows(api, 'singleCell block: invalid age typed, before Tab off the last column').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{Tab}'); + + const rowEl = gridElement.querySelector('[row-index="0"]')!; + expect(cell(api, 0, 'age').querySelector('input')).toBeTruthy(); + expect(rowData[0].age).toBe(23); + expect(cell(api, 1, 'athlete').querySelector('input')).toBeFalsy(); + expect(rowEl.contains(document.activeElement)).toBe(true); + + await new GridRows(api, 'block: Tab off the last column held the row').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + // --- Arrow keys don't navigate (or commit) while an editor is open --- + + test('singleCell block: arrow keys while editing an invalid cell do not navigate or commit', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-singleCell-block-arrows', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + + await new GridRows(api, 'singleCell block: invalid age typed, before the arrow keys').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + + // An open editor swallows arrow keys: no navigation happens, so the editor stays put and nothing commits. + expect(editorCount(api)).toBeGreaterThan(0); + expect(cell(api, 0, 'age').querySelector('input')).toBeTruthy(); + expect(cell(api, 1, 'age').querySelector('input')).toBeFalsy(); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'block: arrow keys neither navigated nor committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + // --- enterNavigatesVertically + invalid value in block mode --- + + test('singleCell block: enterNavigatesVertically + invalid value blocks the vertical move', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-singleCell-block-enterVertical', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + enterNavigatesVertically: true, + enterNavigatesVerticallyAfterEdit: true, + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await new GridRows(api, 'singleCell block: invalid age typed, before Enter').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{Enter}'); + + // Enter on an invalid cell is blocked before any vertical navigation, so the editor stays here. + expect(editorCount(api)).toBeGreaterThan(0); + expect(cell(api, 0, 'age').querySelector('input')).toBeTruthy(); + expect(cell(api, 1, 'age').querySelector('input')).toBeFalsy(); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'block: the vertical move was blocked, nothing committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + // --- valid value + Tab commits / navigates --- + + test('singleCell: valid value + Tab commits and moves to the next cell', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-singleCell-valid-tab', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '55'); + await new GridRows(api, 'singleCell: valid age typed, before Tab').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:"Alice" age:🖍️55 23 + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{Tab}'); + + expect(rowData[0].age).toBe(55); + // Editing moved off the original cell. + expect(cell(api, 0, 'age').querySelector('input')).toBeFalsy(); + + // Value committed to the row, no lingering pending marker. + await new GridRows(api, 'singleCell: committed value on Tab').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:55 + ├── LEAF 🖍️ id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + test('fullRow: valid value + Tab moves within the row and commits on stopEditing', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-fullRow-valid-tab', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'athlete')); + const ageInput = await waitForInput(gridElement, cell(api, 0, 'age')); + await user.clear(ageInput); + await user.type(ageInput, '55'); + + const athleteInput = await waitForInput(gridElement, cell(api, 0, 'athlete')); + athleteInput.focus(); + await new GridRows(api, 'fullRow: valid age typed, before Tab').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:"Alice" age:🖍️55 23 + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{Tab}'); + + // Valid full-row edit: Tab is not blocked, the row keeps editing (both editors stay open). + expect(editorCount(api)).toBe(2); + expect(rowData[0].age).toBe(23); + + // Mid-edit: row 0 carries a pending (🖍️) age of 55, not yet committed to data. + await new GridRows(api, 'fullRow: pending value mid-edit').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:"Alice" age:🖍️55 23 + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + // Committing the row persists the pending value. + api.stopEditing(); + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(55); + + // Row committed: age persisted, no pending marker remaining. + await new GridRows(api, 'fullRow: committed value after stopEditing').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:55 + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + test('fullRow + suppressStartEditOnTab: valid edit + Tab keeps the row editing without crashing', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('nav-fullRow-suppressStartEditOnTab', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + suppressStartEditOnTab: true, + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'athlete')); + const ageInput = await waitForInput(gridElement, cell(api, 0, 'age')); + await user.clear(ageInput); + await user.type(ageInput, '55'); + + const athleteInput = await waitForInput(gridElement, cell(api, 0, 'athlete')); + athleteInput.focus(); + await new GridRows(api, 'fullRow: valid age typed, before Tab with suppressStartEditOnTab').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:"Alice" age:🖍️55 23 + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + await user.keyboard('{Tab}'); + + // Still a valid row edit in progress; committing persists the value. + expect(editorCount(api)).toBe(2); + + await new GridRows(api, 'fullRow: Tab left the row editing, its value still pending').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:"Alice" age:🖍️55 23 + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + + api.stopEditing(); + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(55); + + await new GridRows(api, 'fullRow: the pending value committed on stopEditing').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:55 + ├── LEAF id:1 athlete:"Bob" age:40 + └── LEAF id:2 athlete:"Carol" age:31 + `); + }); + + test('fullRow block: blocked Tab pins focus to the FIRST invalid cell, not the tabbed-from cell', async () => { + const twoNumberCols: ColDef[] = [ + { field: 'age', cellEditor: 'agNumberCellEditor', cellEditorParams: { min: 0, max: 100 } }, + { field: 'score', cellEditor: 'agNumberCellEditor', cellEditorParams: { min: 0, max: 10 } }, + ]; + const rowData = [ + { age: 23, score: 5 }, + { age: 40, score: 7 }, + ]; + const api = await gridsManager.createGridAndWait('nav-fullRow-first-invalid', { + columnDefs: twoNumberCols, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + }); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + // Make the FIRST column (age) invalid; leave the last column (score) valid. + await user.dblClick(cell(api, 0, 'age')); + const ageInput = await waitForInput(gridElement, cell(api, 0, 'age')); + await user.clear(ageInput); + await user.type(ageInput, '999'); + + // Focus the valid last cell (score) and Tab off it — navigation is blocked by age. + const scoreInput = await waitForInput(gridElement, cell(api, 0, 'score')); + scoreInput.focus(); + await new GridRows(api, 'fullRow block: age invalid and score valid, before Tab').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 age:23❌ score:5 + └── LEAF id:1 age:40 score:7 + `); + + await user.keyboard('{Tab}'); + + // Focus is pinned to the first invalid cell (age), not the tabbed-from cell (score). + expect(cell(api, 0, 'age').contains(document.activeElement)).toBe(true); + expect(cell(api, 0, 'score').contains(document.activeElement)).toBe(false); + // The caret lands in the invalid cell's editor input, ready for correction — not the cell shell. + expect(document.activeElement).toBe(cell(api, 0, 'age').querySelector('input')); + expect(rowData[0].age).toBe(23); + expect(rowData[0].score).toBe(5); + + await new GridRows(api, 'fullRow block: only age is ❌, score stays valid, neither committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 age:23❌ score:5 + └── LEAF id:1 age:40 score:7 + `); + }); + + test('fullRow block: blocked Tab pins focus to the first invalid cell in a wide, horizontally-scrolled grid', async () => { + const wideCols: ColDef[] = Array.from({ length: 15 }, (_, i) => ({ + field: `c${i}`, + width: 200, + cellEditor: 'agNumberCellEditor', + cellEditorParams: { min: 0, max: 100 }, + })); + const rowData = [ + Object.fromEntries(wideCols.map((_, i) => [`c${i}`, i])), + Object.fromEntries(wideCols.map((_, i) => [`c${i}`, i + 1])), + ]; + const api = await gridsManager.createGridAndWait('nav-fullRow-block-wide-invalid', { + columnDefs: wideCols, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + suppressColumnVirtualisation: false, + suppressAnimationFrame: true, + }); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + // Open the row and make the FIRST column (c0) invalid. + await user.dblClick(cell(api, 0, 'c0')); + const firstInput = await waitForInput(gridElement, cell(api, 0, 'c0')); + await user.clear(firstInput); + await user.type(firstInput, '999'); + + // Scroll the far end into view; c0 is now well outside the horizontal viewport. + await new GridRows(api, 'fullRow block wide: c0 invalid, before scrolling it out of view').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 c0:0❌ c1:1 c2:2 c3:3 c4:4 c5:5 c6:6 c7:7 c8:8 c9:9 c10:10 c11:11 c12:12 c13:13 c14:14 + └── LEAF id:1 c0:1 c1:2 c2:3 c3:4 c4:5 c5:6 c6:7 c7:8 c8:9 c9:10 c10:11 c11:12 c12:13 c13:14 c14:15 + `); + + api.ensureColumnVisible('c14'); + await asyncSetTimeout(0); + + // Focus the last (valid, visible) cell's editor and Tab forward — block mode must pin focus + // to the first invalid cell (c0), not leak to row 1 nor stay on the tabbed-from cell. + const lastInput = await waitForInput(gridElement, cell(api, 0, 'c14')); + lastInput.focus(); + await new GridRows(api, 'fullRow block wide: scrolled to c14, before Tab').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 c0:0❌ c1:1 c2:2 c3:3 c4:4 c5:5 c6:6 c7:7 c8:8 c9:9 c10:10 c11:11 c12:12 c13:13 c14:14 + └── LEAF id:1 c0:1 c1:2 c2:3 c3:4 c4:5 c5:6 c6:7 c7:8 c8:9 c9:10 c10:11 c11:12 c12:13 c13:14 c14:15 + `); + + await user.keyboard('{Tab}'); + await asyncSetTimeout(0); + + // Focus pinned to the first invalid cell (c0), scrolled back into view; nothing committed, + // and the next row was not entered. + expect(cell(api, 0, 'c0').contains(document.activeElement)).toBe(true); + expect(rowData[0].c0).toBe(0); + expect(cell(api, 1, 'c0')?.querySelector('input')).toBeFalsy(); + + await new GridRows(api, 'fullRow block: c0 still invalid, the whole row uncommitted').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 c0:0❌ c1:1 c2:2 c3:3 c4:4 c5:5 c6:6 c7:7 c8:8 c9:9 c10:10 c11:11 c12:12 c13:13 c14:14 + └── LEAF id:1 c0:1 c1:2 c2:3 c3:4 c4:5 c5:6 c6:7 c7:8 c8:9 c9:10 c10:11 c11:12 c12:13 c13:14 c14:15 + `); + }); +}); diff --git a/testing/behavioural/src/cell-editing/cell-editing-validation-react.test.tsx b/testing/behavioural/src/cell-editing/cell-editing-validation-react.test.tsx new file mode 100644 index 00000000000..af81a5dd085 --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-validation-react.test.tsx @@ -0,0 +1,363 @@ +import { waitFor } from '@testing-library/dom'; +import { act, cleanup, render } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; +import { vi } from 'vitest'; + +import type { ColDef, GridApi } from 'ag-grid-community'; +import { + CellApiModule, + ClientSideRowModelModule, + ColumnApiModule, + ModuleRegistry, + NumberEditorModule, + RowApiModule, + TextEditorModule, + setupAgTestIds, +} from 'ag-grid-community'; +import { AgGridReact } from 'ag-grid-react'; + +import { GridRows, ignoreConsoleLicenseKeyError } from '../test-utils'; + +interface PersonRow { + athlete: string; + age: number; +} + +const columnDefs: ColDef[] = [ + { field: 'athlete', cellEditor: 'agTextCellEditor', cellEditorParams: { maxLength: 10 } }, + { field: 'age', cellEditor: 'agNumberCellEditor', cellEditorParams: { min: 0, max: 100 } }, +]; + +function makeRowData(): PersonRow[] { + return [ + { athlete: 'Alice', age: 23 }, + { athlete: 'Bob', age: 40 }, + ]; +} + +// React re-implements the cell/row view layer (reactUi) with async editor mounting, so the +// validation fix — which lives in the shared edit service — must be verified under React too. +describe('Cell editing validation modes (React)', () => { + beforeAll(() => { + // GridRows reads the grid through the API: the row model, and each cell's value. + ModuleRegistry.registerModules([ + ClientSideRowModelModule, + NumberEditorModule, + TextEditorModule, + RowApiModule, + CellApiModule, + ColumnApiModule, + ]); + setupAgTestIds(); + }); + beforeEach(() => ignoreConsoleLicenseKeyError()); + afterEach(() => cleanup()); + + const renderGrid = ( + rowData: PersonRow[], + editType: 'singleCell' | 'fullRow', + invalidEditValueMode: 'revert' | 'block' + ): { gridDiv: HTMLElement; getApi: () => GridApi } => { + let api: GridApi | undefined; + const rendered = render( +
+ + columnDefs={columnDefs} + rowData={rowData} + defaultColDef={{ editable: true }} + editType={editType} + invalidEditValueMode={invalidEditValueMode} + onGridReady={(params) => { + api = params.api; + }} + /> +
+ ); + return { gridDiv: rendered.container, getApi: () => api! }; + }; + + const ageInputOf = (gridDiv: HTMLElement): Promise => + waitFor(() => { + const input = gridDiv.querySelector('[col-id="age"] input'); + if (!input) { + throw new Error('age editor input not found'); + } + return input; + }); + + const ageCellOf = (gridDiv: HTMLElement): HTMLElement => + gridDiv.querySelector('[row-index="0"] [col-id="age"]')!; + + test.each(['singleCell', 'fullRow'] as const)( + 'block (%s): invalid value + api.stopEditing keeps editor(s) open and does not commit', + async (editType) => { + const rowData = makeRowData(); + const { gridDiv, getApi } = renderGrid(rowData, editType, 'block'); + await waitFor(() => expect(getApi()).toBeTruthy()); + + const user = userEvent.setup(); + await user.dblClick(ageCellOf(gridDiv)); + const input = await ageInputOf(gridDiv); + await user.clear(input); + await user.type(input, '999'); + + act(() => { + getApi().stopEditing(); + }); + + expect(getApi().getCellEditorInstances().length).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + } + ); + + test.each(['singleCell', 'fullRow'] as const)( + 'block (%s): invalid value + Enter keeps editor(s) open and does not commit', + async (editType) => { + const rowData = makeRowData(); + const { gridDiv, getApi } = renderGrid(rowData, editType, 'block'); + await waitFor(() => expect(getApi()).toBeTruthy()); + + const user = userEvent.setup(); + await user.dblClick(ageCellOf(gridDiv)); + const input = await ageInputOf(gridDiv); + await user.clear(input); + await user.type(input, '999'); + await user.keyboard('{Enter}'); + + expect(getApi().getCellEditorInstances().length).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + } + ); + + test.each(['singleCell', 'fullRow'] as const)( + 'revert (%s): invalid value + Enter closes editor(s) and reverts', + async (editType) => { + const rowData = makeRowData(); + const { gridDiv, getApi } = renderGrid(rowData, editType, 'revert'); + await waitFor(() => expect(getApi()).toBeTruthy()); + + const user = userEvent.setup(); + await user.dblClick(ageCellOf(gridDiv)); + const input = await ageInputOf(gridDiv); + await user.clear(input); + await user.type(input, '999'); + await user.keyboard('{Enter}'); + + await waitFor(() => expect(getApi().getCellEditorInstances().length).toBe(0)); + expect(rowData[0].age).toBe(23); + } + ); + + test.each(['singleCell', 'fullRow'] as const)( + 'block (%s): a blocked value corrected in place commits', + async (editType) => { + const rowData = makeRowData(); + const onCellValueChanged = vi.fn(); + let api: GridApi | undefined; + const rendered = render( +
+ + columnDefs={columnDefs} + rowData={rowData} + defaultColDef={{ editable: true }} + editType={editType} + invalidEditValueMode="block" + onCellValueChanged={onCellValueChanged} + onGridReady={(params) => { + api = params.api; + }} + /> +
+ ); + const gridDiv = rendered.container; + await waitFor(() => expect(api).toBeTruthy()); + + const user = userEvent.setup(); + await user.dblClick(ageCellOf(gridDiv)); + let input = await ageInputOf(gridDiv); + await user.clear(input); + await user.type(input, '999'); + await user.keyboard('{Enter}'); // blocked + + expect(api!.getCellEditorInstances().length).toBeGreaterThan(0); + expect(onCellValueChanged).not.toHaveBeenCalled(); + + input = await ageInputOf(gridDiv); + await user.clear(input); + await user.type(input, '55'); + await user.keyboard('{Enter}'); // corrected, commits + + await waitFor(() => expect(api!.getCellEditorInstances().length).toBe(0)); + expect(rowData[0].age).toBe(55); + expect(onCellValueChanged).toHaveBeenCalledTimes(1); + } + ); + + test.each(['singleCell', 'fullRow'] as const)( + 'block (%s): Tab off the last invalid cell does not leak focus to the next row', + async (editType) => { + const rowData = makeRowData(); + const { gridDiv, getApi } = renderGrid(rowData, editType, 'block'); + await waitFor(() => expect(getApi()).toBeTruthy()); + + const user = userEvent.setup(); + // age is the last column; open it, make it invalid, then Tab forward. + await user.dblClick(ageCellOf(gridDiv)); + const input = await ageInputOf(gridDiv); + await user.clear(input); + await user.type(input, '999'); + await user.keyboard('{Tab}'); + + const row0 = gridDiv.querySelector('[row-index="0"]')!; + expect(getApi().getCellEditorInstances().length).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + expect(gridDiv.querySelector('[row-index="1"] [col-id="athlete"] input')).toBeFalsy(); + expect(row0.contains(document.activeElement)).toBe(true); + } + ); + + // React drives onPopupEditorClosed through its own popupEditorComp, so the shared CellCtrl fix has + // to be verified here too: a blocked popup close must revert its own cell, not end the row edit. + test('block (fullRow): a popup closing on an invalid value leaves the row editing', async () => { + const rowData = makeRowData(); + let api: GridApi | undefined; + const rendered = render( +
+ + columnDefs={[ + { field: 'athlete', cellEditor: 'agTextCellEditor' }, + { + field: 'age', + cellEditor: 'agNumberCellEditor', + cellEditorPopup: true, + cellEditorParams: { min: 0, max: 100 }, + }, + ]} + rowData={rowData} + defaultColDef={{ editable: true }} + editType="fullRow" + invalidEditValueMode="block" + stopEditingWhenCellsLoseFocus + onGridReady={(params) => { + api = params.api; + }} + /> +
+ ); + const gridDiv = rendered.container; + await waitFor(() => expect(api).toBeTruthy()); + + const user = userEvent.setup(); + await user.dblClick(ageCellOf(gridDiv)); + // The popup editor mounts outside the cell, so it is not found by the in-cell lookup. + const input = await waitFor(() => { + const el = document.querySelector('.ag-popup input'); + if (!el) { + throw new Error('popup editor input not found'); + } + return el; + }); + await user.clear(input); + await user.type(input, '999'); // above max: the row is held invalid + + // Focus a sibling: the popup closes, which must not end the row edit. + const athleteInput = await waitFor(() => { + const el = gridDiv.querySelector('[col-id="athlete"] input'); + if (!el) { + throw new Error('athlete editor input not found'); + } + return el; + }); + await user.click(athleteInput); + await act(async () => { + await Promise.resolve(); + }); + + expect(rowData[0].age).toBe(23); // nothing invalid written + expect(api!.getEditingCells().length).toBeGreaterThan(0); // row edit survived the popup close + + await new GridRows(api!, 'react popup: invalid value reverted, row still editing').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + // The other half of the same React path: a valid popup value must be staged as the popup closes, so + // the row stop still commits it. + test('block (fullRow): a valid popup value survives the popup close and commits with the row', async () => { + const rowData = makeRowData(); + let api: GridApi | undefined; + const rendered = render( +
+ + columnDefs={[ + { field: 'athlete', cellEditor: 'agTextCellEditor' }, + { + field: 'age', + cellEditor: 'agNumberCellEditor', + cellEditorPopup: true, + cellEditorParams: { min: 0, max: 100 }, + }, + ]} + rowData={rowData} + defaultColDef={{ editable: true }} + editType="fullRow" + invalidEditValueMode="block" + stopEditingWhenCellsLoseFocus + onGridReady={(params) => { + api = params.api; + }} + /> +
+ ); + const gridDiv = rendered.container; + await waitFor(() => expect(api).toBeTruthy()); + + const user = userEvent.setup(); + await user.dblClick(ageCellOf(gridDiv)); + const input = await waitFor(() => { + const el = document.querySelector('.ag-popup input'); + if (!el) { + throw new Error('popup editor input not found'); + } + return el; + }); + await user.clear(input); + await user.type(input, '42'); // valid + + const athleteInput = await waitFor(() => { + const el = gridDiv.querySelector('[col-id="athlete"] input'); + if (!el) { + throw new Error('athlete editor input not found'); + } + return el; + }); + await user.click(athleteInput); // closes the popup, staging its value + await act(async () => { + await Promise.resolve(); + }); + + expect(rowData[0].age).toBe(23); // full-row commits on the row stop, not on the popup close + + await new GridRows(api!, 'react popup: valid value staged by the popup close').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ⏳ id:0 athlete:"Alice" age:🖍️42 23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + + await user.type(athleteInput, '{Enter}'); + await act(async () => { + await Promise.resolve(); + }); + + expect(rowData[0].age).toBe(42); + + await new GridRows(api!, 'react popup: staged value committed by the row stop').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:42 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); +}); diff --git a/testing/behavioural/src/cell-editing/cell-editing-validation-stop-paths.test.ts b/testing/behavioural/src/cell-editing/cell-editing-validation-stop-paths.test.ts new file mode 100644 index 00000000000..326bbde7dba --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-validation-stop-paths.test.ts @@ -0,0 +1,1169 @@ +import { userEvent } from '@testing-library/user-event'; + +import type { GridApi, GridOptions, ICellEditorComp, ICellEditorParams } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + CustomEditorModule, + NumberEditorModule, + ScrollApiModule, + TextEditorModule, + TextFilterModule, + enableDevValidations, + getGridElement, + setupAgTestIds, +} from 'ag-grid-community'; + +import { + EditEventTracker, + GridRows, + TestGridsManager, + asyncSetTimeout, + waitForInput, + waitForPopup, +} from '../test-utils'; +import { ALL_SEVERITIES } from '../test-utils/dev-validations'; + +interface NumRow { + name: string; + number: number; +} + +// Inline custom editor that auto-commits via params.stopEditing() (a picker/"Done" pattern). Validity +// is pull-only: getValidationErrors reads the live value; the editor never calls params.validate(). +class AutoCommitEditor implements ICellEditorComp { + private params!: ICellEditorParams; + private eGui!: HTMLElement; + private eInput!: HTMLInputElement; + private value: number | null = null; + + public init(params: ICellEditorParams): void { + this.params = params; + this.eGui = document.createElement('div'); + this.eInput = document.createElement('input'); + this.eInput.className = 'auto-input'; + this.eGui.appendChild(this.eInput); + + const commit = document.createElement('button'); + commit.className = 'auto-commit'; + commit.addEventListener('click', () => this.params.stopEditing(false)); + this.eGui.appendChild(commit); + + this.value = params.value == null ? null : Number(params.value); + this.eInput.value = this.value == null ? '' : String(this.value); + this.eInput.addEventListener('input', (e) => { + const v = (e.target as HTMLInputElement).value; + this.value = v === '' ? null : Number(v); + }); + } + public getGui(): HTMLElement { + return this.eGui; + } + public afterGuiAttached(): void { + this.eInput.focus(); + } + public getValue(): number | null { + return this.value; + } + public getValidationErrors(): string[] | null { + return this.value != null && this.value > 100 ? ['Value over 100'] : null; + } + public getValidationElement(): HTMLElement { + return this.eInput; + } +} + +// Mirrors a Firefox date input: typed text stays buffered and only reaches the value (and therefore +// getValidationErrors) once the grid calls agFlushInput on the stop read. +class BufferedFlushEditor implements ICellEditorComp { + private eInput!: HTMLInputElement; + private buffered = ''; + private value: number | null = null; + + public init(params: ICellEditorParams): void { + this.eInput = document.createElement('input'); + this.eInput.className = 'buffered-input'; + this.value = params.value == null ? null : Number(params.value); + this.buffered = this.value == null ? '' : String(this.value); + this.eInput.value = this.buffered; + this.eInput.addEventListener('input', (e) => { + this.buffered = (e.target as HTMLInputElement).value; // withheld from value until flush + }); + } + public getGui(): HTMLElement { + return this.eInput; + } + public afterGuiAttached(): void { + this.eInput.focus(); + } + public agFlushInput(): void { + this.value = this.buffered === '' ? null : Number(this.buffered); + } + public getValue(): number | null { + return this.value; + } + public getValidationErrors(): string[] | null { + return this.value != null && this.value > 100 ? ['Value over 100'] : null; + } + public getValidationElement(): HTMLElement { + return this.eInput; + } +} + +interface PersonRow { + athlete: string; + age: number; +} + +// Pull-only text editor: validity is exposed only via getValidationErrors (never per-cell invalid here); +// it never calls params.validate() on keystroke, so the validation maps stay stale until a stop repopulates. +class PullOnlyTextEditor implements ICellEditorComp { + private eInput!: HTMLInputElement; + public init(params: ICellEditorParams): void { + this.eInput = document.createElement('input'); + this.eInput.type = 'text'; + this.eInput.value = params.value == null ? '' : String(params.value); + } + public getGui(): HTMLElement { + return this.eInput; + } + public afterGuiAttached(): void { + this.eInput.focus(); + this.eInput.select(); + } + public getValue(): string { + return this.eInput.value; + } + public getValidationErrors(): string[] | null { + return null; + } +} + +const gridsManager = new TestGridsManager({ + includeDefaultModules: true, + modules: [ + ClientSideRowModelModule, + NumberEditorModule, + TextEditorModule, + CustomEditorModule, + TextFilterModule, + ScrollApiModule, + ], +}); + +beforeAll(() => setupAgTestIds()); +afterEach(() => { + gridsManager.reset(); + vi.clearAllMocks(); +}); + +const cell = (api: GridApi, r: number, colId: string): HTMLElement => + (getGridElement(api)! as HTMLElement).querySelector(`[row-index="${r}"] [col-id="${colId}"]`)!; + +const editorCount = (api: GridApi): number => api.getCellEditorInstances().length; + +describe('Cell editing validation — block mode holds invalid values across all stop paths', () => { + const makeNumGrid = (rowData: NumRow[]) => + gridsManager.createGridAndWait('block-stop', { + columnDefs: [{ field: 'name' }, { field: 'number', cellEditor: AutoCommitEditor }], + rowData, + defaultColDef: { editable: true, filter: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + + // An editor that auto-commits via params.stopEditing() while its value is invalid must be HELD open + // in block mode (matching api.stopEditing()), not silently discarded and closed. + test('editor-initiated params.stopEditing() on an invalid value is held open', async () => { + const rowData: NumRow[] = [ + { name: 'Bob', number: 10 }, + { name: 'Harry', number: 3 }, + ]; + const api = await makeNumGrid(rowData); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + const tracker = new EditEventTracker(api); + + await user.dblClick(cell(api, 0, 'number')); + const input = await waitForInput(grid, cell(api, 0, 'number')); + + await new GridRows(api, 'block: editor opened on source value').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 name:"Bob" number:10 + └── LEAF id:1 name:"Harry" number:3 + `); + + await user.clear(input); + await user.type(input, '150'); // invalid: > 100 + + await new GridRows(api, 'block: invalid 150 typed, not yet auto-committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 name:"Bob" number:10 + └── LEAF id:1 name:"Harry" number:3 + `); + + // The editor auto-commits (its "Done" button calls params.stopEditing()). + await user.click(grid.querySelector('.auto-commit')!); + await asyncSetTimeout(0); + + // Block mode holds the editor open; nothing is committed. + expect(editorCount(api)).toBe(1); + expect(rowData[0].number).toBe(10); + expect(tracker.counts.cellEditingStopped).toBe(0); + + await new GridRows(api, 'block: invalid auto-commit held open').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 name:"Bob" number:10❌ + └── LEAF id:1 name:"Harry" number:3 + `); + + // Correct the value and commit with Enter: the editor closes and the value lands. + const inputAgain = await waitForInput(grid, cell(api, 0, 'number')); + await user.clear(inputAgain); + + await new GridRows(api, 'block: invalid value cleared, still held').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 name:"Bob" number:10❌ + └── LEAF id:1 name:"Harry" number:3 + `); + + await user.type(inputAgain, '50{Enter}'); + await asyncSetTimeout(0); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].number).toBe(50); + expect(tracker.counts.cellEditingStopped).toBe(1); + + await new GridRows(api, 'block: corrected value committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 name:"Bob" number:50 + └── LEAF id:1 name:"Harry" number:3 + `); + tracker.destroy(); + }); + + const makeRowLevelGrid = (rowData: PersonRow[]) => + gridsManager.createGridAndWait('block-rowlevel', { + columnDefs: [ + { field: 'athlete', cellEditor: PullOnlyTextEditor }, + { field: 'age', editable: false }, + ], + rowData, + defaultColDef: { editable: true, filter: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + getFullRowEditValidationErrors: ({ editorsState }) => { + const athlete = editorsState.find((e) => e.colId === 'athlete')?.newValue; + return athlete === 'Invalid' ? ['Row is invalid'] : []; + }, + } satisfies GridOptions); + + // A row-level-invalid value (valid per-cell, rejected by getFullRowEditValidationErrors) must not be + // committed by api.stopEditing() in block mode — the stop reads validation fresh, not from stale maps. + test('api.stopEditing() holds a row-level-invalid value', async () => { + const rowData: PersonRow[] = [{ athlete: 'Alice', age: 23 }]; + const api = await makeRowLevelGrid(rowData); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'athlete')); + const input = await waitForInput(grid, cell(api, 0, 'athlete')); + await user.clear(input); + await user.type(input, 'Invalid'); + + await new GridRows(api, 'block row-level: row-invalid value typed, before stop').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 athlete:"Alice" age:23 + `); + + api.stopEditing(); + await asyncSetTimeout(0); + + expect(rowData[0].athlete).toBe('Alice'); // held, not committed + expect(api.getEditingCells().length).toBeGreaterThan(0); + + // Held open: the invalid "Invalid" is staged as the pending value, the source "Alice" is unchanged. + await new GridRows(api, 'block row-level: invalid held on stopEditing').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 athlete:🖍️"Invalid" "Alice" age:23 + `); + }); + + // A filter change goes through the sort/filter handler, which cancels an invalid edit. It must + // repopulate validation first, or a stale read would commit the invalid row instead of discarding it. + test('a filter change discards a row-level-invalid value instead of committing it', async () => { + const rowData: PersonRow[] = [{ athlete: 'Alice', age: 23 }]; + const api = await makeRowLevelGrid(rowData); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'athlete')); + const input = await waitForInput(grid, cell(api, 0, 'athlete')); + await user.clear(input); + await user.type(input, 'Invalid'); + + await new GridRows(api, 'block row-level: row-invalid value typed, before filter change').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 athlete:"Alice" age:23 + `); + + api.onFilterChanged(); + await asyncSetTimeout(0); + + expect(rowData[0].athlete).toBe('Alice'); // discarded, not committed + expect(api.getEditingCells()).toHaveLength(0); + + // Invalid edit reverted: no pending value, source intact. + await new GridRows(api, 'block row-level: invalid discarded on filter change').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 athlete:"Alice" age:23 + `); + }); + + // A pull-only editor never reports through params.validate(), so the sort/filter handler is the only + // thing that can discover the value is invalid — it must repopulate before choosing cancel or commit. + test('a filter change discards a pull-only editor invalid value instead of committing it', async () => { + const rowData: NumRow[] = [{ name: 'Bob', number: 10 }]; + const api = await makeNumGrid(rowData); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'number')); + const input = grid.querySelector('.auto-input')!; + await user.clear(input); + await user.type(input, '150'); // invalid, and only ever visible via getValidationErrors() + + await new GridRows(api, 'pull-only: invalid 150 held open before the filter change').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 name:"Bob" number:10 + `); + + api.onFilterChanged(); + await asyncSetTimeout(1); + + expect(rowData[0].number).toBe(10); + expect(editorCount(api)).toBe(0); + expect(api.getEditingCells()).toHaveLength(0); + + await new GridRows(api, 'pull-only invalid discarded on filter change').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 name:"Bob" number:10 + `); + }); + + // Popups warn against fullRow (98) but the pairing is reachable, and a modal popup closes the moment + // focus reaches a sibling. Ending the row edit there commits every sibling at whatever half-typed + // value it holds, so the popup close only closes its own editor — the row owns its own stop. + test('full-row: a popup closing as focus moves to a sibling does not commit the row', async () => { + enableDevValidations({ throwOn: ALL_SEVERITIES, suppress: [98] }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const rowData = [{ number: 10, other: 5 }]; + const api = await gridsManager.createGridAndWait('block-fullrow-popup', { + columnDefs: [ + { field: 'number', cellEditor: 'agNumberCellEditor', cellEditorPopup: true }, + { field: 'other', cellEditor: 'agNumberCellEditor', cellEditorParams: { max: 100 } }, + ], + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + stopEditingWhenCellsLoseFocus: true, + } satisfies GridOptions); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + const committed: string[] = []; + api.addEventListener('cellValueChanged', (e) => committed.push(e.column.getColId())); + + await user.dblClick(cell(api, 0, 'number')); + await asyncSetTimeout(1); + + // The unsupported pairing must announce itself rather than fail silently. + expect(warnSpy).toHaveBeenCalled(); + + await new GridRows(api, 'full-row popup: row editors open, all valid').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 number:10 other:5 + `); + + // Clearing passes the sibling through null; committing the row here would persist that. + const otherInput = grid.querySelector('[col-id="other"] input')!; + await user.clear(otherInput); + await user.type(otherInput, '150'); + await asyncSetTimeout(0); + + expect(committed).toEqual([]); + expect(rowData[0].other).toBe(5); + expect(rowData[0].number).toBe(10); + + await new GridRows(api, 'full-row popup: sibling invalid, nothing committed').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ ❌ id:0 number:10 other:5❌ + `); + }); + + // Full-row block mode: an editor calling params.stopEditing() from a VALID cell while a sibling in + // the same row is invalid. The row stop is rejected, so that requesting cell's editor must not be + // torn down — a per-cell validity check would destroy it and orphan the still-open row session. + test('full-row: a valid cell auto-committing does not tear down its editor while a sibling is invalid', async () => { + const rowData = [{ number: 10, other: 5 }]; + const api = await gridsManager.createGridAndWait('block-fullrow-autocommit', { + columnDefs: [ + { field: 'number', cellEditor: AutoCommitEditor }, + { field: 'other', cellEditor: AutoCommitEditor }, + ], + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + // Full-row edit opens editors for the whole row. Make 'other' invalid, leave 'number' valid. + await user.dblClick(cell(api, 0, 'number')); + await asyncSetTimeout(0); + + await new GridRows(api, 'full-row: all row editors opened, all valid').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 number:10 other:5 + `); + + const otherInput = grid.querySelector(`[col-id="other"] .auto-input`)!; + await user.clear(otherInput); + await user.type(otherInput, '150'); // invalid sibling: > 100 + + const editorsBefore = editorCount(api); + expect(editorsBefore).toBeGreaterThan(1); + + await new GridRows(api, 'full-row: invalid sibling typed, row held open').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 number:10 other:5 + `); + + // The VALID 'number' cell auto-commits via its "Done" button. + await user.click(grid.querySelector(`[col-id="number"] .auto-commit`)!); + await asyncSetTimeout(0); + + // Row stop rejected by the invalid sibling: every editor (incl. the requesting one) stays open. + expect(editorCount(api)).toBe(editorsBefore); + expect(api.getEditingCells().length).toBeGreaterThan(1); + expect(rowData[0].number).toBe(10); + expect(rowData[0].other).toBe(5); + + await new GridRows(api, 'full-row: auto-commit rejected, editors still open').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ ❌ id:0 number:10 other:5❌ + `); + }); + + // A pull-only editor never reports through params.validate(), so the recorded state stays behind the + // editor until something validates: reading it must not be what brings it up to date. + test('getEditValidationErrors reads the recorded state, validateEdit refreshes it', async () => { + const rowData: NumRow[] = [{ name: 'Bob', number: 10 }]; + const api = await makeNumGrid(rowData); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'number')); + const input = await waitForInput(grid, cell(api, 0, 'number')); + await user.clear(input); + await user.type(input, '150'); // invalid, and unreported by this editor + + expect(api.getEditValidationErrors()).toEqual([]); + + const validated = api.validateEdit()!; + expect(validated.map((e) => [e.column.getColId(), e.messages])).toEqual([['number', ['Value over 100']]]); + expect(api.getEditValidationErrors()).toEqual(validated); // the refreshed state, read back + + await new GridRows(api, 'pull-only: invalid value recorded once validated').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ ❌ id:0 name:"Bob" number:10❌ + `); + }); + + // An editor that buffers input until agFlushInput (as a Firefox date segment does) must be flushed + // BEFORE the stop reads validity, or the newly-invalid value validates as the old valid one and is + // then flushed and committed — defeating block mode. + test('block: buffered input is flushed before validation, so a newly-invalid value is held', async () => { + const rowData: NumRow[] = [{ name: 'Bob', number: 10 }]; + const api = await gridsManager.createGridAndWait('block-buffered-flush', { + columnDefs: [{ field: 'name' }, { field: 'number', cellEditor: BufferedFlushEditor }], + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'number')); + const input = await waitForInput(grid, cell(api, 0, 'number')); + + await new GridRows(api, 'buffered: editor opened on source value').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 name:"Bob" number:10 + `); + + await user.clear(input); + await user.type(input, '150'); // invalid, but buffered — not yet in the editor's value + + await new GridRows(api, 'buffered: typed but not yet flushed').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 name:"Bob" number:10 + `); + + api.stopEditing(); + await asyncSetTimeout(0); + + // Flushed first, so 150 is seen as invalid: block mode holds it and commits nothing. + expect(rowData[0].number).toBe(10); + expect(editorCount(api)).toBe(1); + expect(api.getEditingCells()).toHaveLength(1); + + await new GridRows(api, 'buffered: flushed value seen as invalid and held').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ ❌ id:0 name:"Bob" number:10❌ + `); + }); + + // An explicit revalidation is the caller asking about what has been typed, so it flushes first — + // otherwise a buffered value validates as the old one, and the API disagrees with the next stop. + test('api.validateEdit() flushes buffered input before revalidating', async () => { + const rowData: NumRow[] = [{ name: 'Bob', number: 10 }]; + const api = await gridsManager.createGridAndWait('validate-edit-flush', { + columnDefs: [{ field: 'name' }, { field: 'number', cellEditor: BufferedFlushEditor }], + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'number')); + const input = await waitForInput(grid, cell(api, 0, 'number')); + await user.clear(input); + await user.type(input, '150'); // invalid, but withheld from the editor's value until flushed + + expect(api.getEditValidationErrors()).toEqual([]); // nothing has read the buffer yet + + const errors = api.validateEdit(); + expect(errors).toHaveLength(1); + expect(errors![0].messages).toEqual(['Value over 100']); + expect(errors![0].column.getColId()).toBe('number'); + expect(api.getEditValidationErrors()).toEqual(errors); // and it is recorded, not just reported + }); +}); + +describe('Cell editing validation — an edit ending off-screen still fires cellEditingStopped', () => { + const makeGrid = (rowData: NumRow[]) => + gridsManager.createGridAndWait('offscreen', { + columnDefs: [{ field: 'name' }, { field: 'number', cellEditor: AutoCommitEditor }], + rowData, + defaultColDef: { editable: true, filter: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + + // Hiding the edited column destroys its cell controller; ending the edit afterwards must still fire + // cellEditingStopped (the event pair must balance even though the cell is no longer rendered). + test('hiding the edited column then stopping fires cellEditingStopped', async () => { + const rowData: NumRow[] = [ + { name: 'Bob', number: 10 }, + { name: 'Harry', number: 3 }, + ]; + const api = await makeGrid(rowData); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + const tracker = new EditEventTracker(api); + + await user.dblClick(cell(api, 0, 'number')); + await waitForInput(grid, cell(api, 0, 'number')); + expect(api.getEditingCells()).toHaveLength(1); + expect(tracker.counts.cellEditingStarted).toBe(1); + + await new GridRows(api, 'offscreen: editing before the column is hidden').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 name:"Bob" number:10 + └── LEAF id:1 name:"Harry" number:3 + `); + + api.setColumnsVisible(['number'], false); // cell controller destroyed, edit retained + api.onFilterChanged(); + await asyncSetTimeout(0); + + expect(api.getEditingCells()).toHaveLength(0); + expect(tracker.counts.cellEditingStopped).toBe(1); + + await new GridRows(api, 'offscreen: column hidden, edit ended with no orphan').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 name:"Bob" number:10 + └── LEAF id:1 name:"Harry" number:3 + `); + tracker.destroy(); + }); + + // Removing the edited row via a transaction purges its edit; that purge must go through the editor + // teardown so cellEditingStopped fires and the held validation error is cleared (not orphaned). + test('removing the edited row via transaction fires cellEditingStopped and clears validation', async () => { + const rowData: NumRow[] = [ + { name: 'Bob', number: 10 }, + { name: 'Harry', number: 3 }, + ]; + const api = await makeGrid(rowData); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + const tracker = new EditEventTracker(api); + + await user.dblClick(cell(api, 0, 'number')); + const input = await waitForInput(grid, cell(api, 0, 'number')); + await user.clear(input); + await user.type(input, '150'); // invalid → validation populated + await asyncSetTimeout(0); + expect(tracker.counts.cellEditingStarted).toBe(1); + + await new GridRows(api, 'offscreen: invalid value held before the row is removed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 name:"Bob" number:10 + └── LEAF id:1 name:"Harry" number:3 + `); + + api.applyTransaction({ remove: [api.getRenderedNodes()[0].data] }); + await asyncSetTimeout(0); + + expect(tracker.counts.cellEditingStopped).toBe(1); + expect(api.getEditingCells()).toHaveLength(0); + + // The removed row's held validation error must not linger: re-opening the other row edits cleanly. + await new GridRows(api, 'after row removal: no orphaned edit/validation').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 name:"Harry" number:3 + `); + tracker.destroy(); + }); +}); + +describe('Cell editing validation — a held invalid value survives column virtualisation', () => { + const COLS = 30; + + // Scrolling the edited column out of view and back (column virtualisation) must NOT release the + // block: the grid keeps the editing cell mounted, so the invalid value stays held, not silently reset. + test('scrolling the edited column out and back keeps the invalid value held', async () => { + const columnDefs = Array.from({ length: COLS }, (_, i) => ({ + field: `c${i}`, + width: 200, + cellEditor: i === 0 ? AutoCommitEditor : undefined, + })); + const rowData = [Object.fromEntries(Array.from({ length: COLS }, (_, i) => [`c${i}`, i === 0 ? 10 : i]))]; + + const api = await gridsManager.createGridAndWait('virt-block', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + suppressColumnVirtualisation: false, + suppressAnimationFrame: true, + } satisfies GridOptions); + + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + const editorRendered = () => !!grid.querySelector(`[row-index="0"] [col-id="c0"] .auto-input`); + const lastColRendered = () => !!grid.querySelector(`[row-index="0"] [col-id="c${COLS - 1}"]`); + + await user.dblClick(grid.querySelector(`[row-index="0"] [col-id="c0"]`)!); + const input = await waitForInput(grid, grid.querySelector(`[row-index="0"] [col-id="c0"]`)!); + await user.clear(input); + await user.type(input, '150'); // invalid, held + await asyncSetTimeout(0); + expect(lastColRendered()).toBe(false); // far column virtualised out initially + + api.ensureColumnVisible(`c${COLS - 1}`); // scroll editing column out of view + await asyncSetTimeout(0); + expect(lastColRendered()).toBe(true); // virtualisation genuinely active + expect(editorRendered()).toBe(true); // editing cell kept mounted (doNotUnVirtualiseRow) + + api.ensureColumnVisible('c0'); // scroll back + await asyncSetTimeout(0); + + const editors = api.getCellEditorInstances(); + expect(editors).toHaveLength(1); + expect((editors[0] as AutoCommitEditor).getValue()).toBe(150); // typed value survived + expect((api.getRenderedNodes()[0].data as any).c0).toBe(10); // block held, nothing committed + expect(api.getEditingCells()).toHaveLength(1); + + await new GridRows(api, 'virtualisation: invalid value still held after scrolling back').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 c0:10 c1:1 c2:2 c3:3 c4:4 c5:5 c6:6 c7:7 c8:8 c9:9 c10:10 c11:11 c12:12 c13:13 c14:14 c15:15 c16:16 c17:17 c18:18 c19:19 c20:20 c21:21 c22:22 c23:23 c24:24 c25:25 c26:26 c27:27 c28:28 c29:29 + `); + }); + + // A popup close is not a row stop, so the blocked branch must revert only its own cell. Cancelling + // the row there would end the whole row edit along with the invalid value. + test('full-row: a popup closing on an invalid value reverts that cell only, leaving the row editing', async () => { + enableDevValidations({ throwOn: ALL_SEVERITIES, suppress: [98] }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const rowData = [{ name: 'a', number: 10 }]; + const api = await gridsManager.createGridAndWait('block-fullrow-popup-invalid', { + columnDefs: [ + { field: 'name', cellEditor: 'agTextCellEditor' }, + { + field: 'number', + cellEditor: 'agNumberCellEditor', + cellEditorPopup: true, + cellEditorParams: { max: 100 }, + }, + ], + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + stopEditingWhenCellsLoseFocus: true, + } satisfies GridOptions); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + const committed: string[] = []; + api.addEventListener('cellValueChanged', (e) => committed.push(e.column.getColId())); + + await user.dblClick(cell(api, 0, 'number')); + const popup = await waitForPopup(grid); + + // The unsupported popup/fullRow pairing must announce itself rather than fail silently. + expect(warnSpy).toHaveBeenCalled(); + + const numberInput = popup.querySelector('input')!; + await user.clear(numberInput); + await user.type(numberInput, '150'); // above max, so the row is held invalid + await asyncSetTimeout(0); + + await new GridRows(api, 'full-row popup: popup cell invalid, row held').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ ❌ id:0 name:"a" number:10❌ + `); + + // Focus a sibling: the popup closes, but that must not end the row edit. + const nameInput = grid.querySelector('[col-id="name"] input')!; + await user.click(nameInput); + await asyncSetTimeout(0); + + expect(committed).toEqual([]); // nothing invalid written + expect(api.getEditingCells().length).toBeGreaterThan(0); // row edit survived the popup close + expect(api.getEditValidationErrors()).toEqual([]); // the reverted cell keeps no error + + await new GridRows(api, 'full-row popup: invalid cell reverted, row still editing').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 name:"a" number:10 + `); + + // The surviving row edit is still usable: a sibling can be edited and committed. + await user.clear(grid.querySelector('[col-id="name"] input')!); + await user.type(grid.querySelector('[col-id="name"] input')!, 'edited{Enter}'); + await asyncSetTimeout(0); + + expect(rowData[0].name).toBe('edited'); + expect(rowData[0].number).toBe(10); + }); +}); + +describe('Cell editing validation — a full-row popup close only ends its own cell', () => { + const makePopupRowGrid = (rowData: { number: number; other: number }[]) => + gridsManager.createGridAndWait('fullrow-popup-close', { + columnDefs: [ + { + field: 'number', + cellEditor: 'agNumberCellEditor', + cellEditorPopup: true, + cellEditorParams: { max: 100 }, + }, + { field: 'other', cellEditor: 'agNumberCellEditor', cellEditorParams: { max: 100 } }, + ], + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + stopEditingWhenCellsLoseFocus: true, + } satisfies GridOptions); + + // The popup close ends that cell's editor while the row edit carries on, so the value typed into it + // has to be staged on the way out — the row stop that follows is the only thing that commits it. + test('full-row: a value typed in a popup survives the close and commits with the row', async () => { + enableDevValidations({ throwOn: ALL_SEVERITIES, suppress: [98] }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const rowData = [{ number: 10, other: 1 }]; + const api = await makePopupRowGrid(rowData); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'number')); + const popup = await waitForPopup(grid); + expect(warnSpy).toHaveBeenCalled(); // the unsupported popup/fullRow pairing announces itself + + const numberInput = popup.querySelector('input')!; + await user.clear(numberInput); + await user.type(numberInput, '42'); // valid + await asyncSetTimeout(0); + + // Focusing the sibling closes the popup; full-row commits on the row stop, not here. + const otherInput = grid.querySelector('[col-id="other"] input')!; + await user.click(otherInput); + await asyncSetTimeout(0); + + expect(rowData[0].number).toBe(10); // not committed yet + expect(api.getEditingCells().length).toBeGreaterThan(0); + + await new GridRows(api, 'full-row popup: typed value staged by the popup close').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ ⏳ id:0 number:🖍️42 10 other:1 + `); + + await user.type(otherInput, '{Enter}'); // row stop + await asyncSetTimeout(0); + + expect(rowData[0].number).toBe(42); + + await new GridRows(api, 'full-row popup: staged value committed by the row stop').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 number:42 other:1 + `); + }); + + // Block mode holds the row's commit for any error in it, but the revert decision is this cell's alone: + // reverting because a sibling is invalid would discard a value the user validly typed in the popup. + test('full-row: a popup closing while a sibling is invalid keeps its own typed value', async () => { + enableDevValidations({ throwOn: ALL_SEVERITIES, suppress: [98] }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // The sibling starts out above its max, so it is already invalid when the popup closes. + const rowData = [{ number: 10, other: 500 }]; + const api = await makePopupRowGrid(rowData); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'number')); + const popup = await waitForPopup(grid); + expect(warnSpy).toHaveBeenCalled(); // the unsupported popup/fullRow pairing announces itself + + const numberInput = popup.querySelector('input')!; + await user.clear(numberInput); + await user.type(numberInput, '42'); // valid, while the sibling is not + await asyncSetTimeout(0); + + const otherInput = grid.querySelector('[col-id="other"] input')!; + await user.click(otherInput); + await asyncSetTimeout(0); + + await new GridRows(api, 'full-row popup: valid popup value kept, invalid sibling held').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ ⏳ ❌ id:0 number:🖍️42 10 other:500❌ + `); + + // The row is still blocked by the sibling: fixing it commits both values, including the popup's. + await user.clear(otherInput); + await user.type(otherInput, '5{Enter}'); + await asyncSetTimeout(0); + + expect(rowData[0].number).toBe(42); + expect(rowData[0].other).toBe(5); + + await new GridRows(api, 'full-row popup: row committed once the sibling was fixed').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 number:42 other:5 + `); + }); + + // A row-level error belongs to no cell, so reverting the popup's value cannot clear it — it would only + // discard a value validly typed there while the row stays blocked either way. + test('full-row: a popup closing while the row itself is invalid keeps its own typed value', async () => { + enableDevValidations({ throwOn: ALL_SEVERITIES, suppress: [98] }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const rowData = [{ number: 10, other: 1 }]; + const api = await gridsManager.createGridAndWait('fullrow-popup-row-invalid', { + columnDefs: [ + { + field: 'number', + cellEditor: 'agNumberCellEditor', + cellEditorPopup: true, + cellEditorParams: { max: 100 }, + }, + { field: 'other', cellEditor: 'agNumberCellEditor', cellEditorParams: { max: 100 } }, + ], + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + stopEditingWhenCellsLoseFocus: true, + // Cross-field rule: every cell can be valid while the row is not. + getFullRowEditValidationErrors: ({ editorsState }) => { + const number = editorsState.find((e) => e.colId === 'number')?.newValue; + const other = editorsState.find((e) => e.colId === 'other')?.newValue; + return number > other ? ['number must not exceed other'] : []; + }, + } satisfies GridOptions); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'number')); + const popup = await waitForPopup(grid); + expect(warnSpy).toHaveBeenCalled(); // the unsupported popup/fullRow pairing announces itself + + const numberInput = popup.querySelector('input')!; + await user.clear(numberInput); + await user.type(numberInput, '42'); // within max, but above the sibling + await asyncSetTimeout(0); + + const otherInput = grid.querySelector('[col-id="other"] input')!; + await user.click(otherInput); + await asyncSetTimeout(0); + + expect(api.getEditValidationErrors()).toEqual([]); // the hold is the row's, no cell is at fault + + // No ❌: the diagram marks cell errors, and a row-level one belongs to no cell. The row is still + // held all the same — nothing is committed until the rule is satisfied below. + await new GridRows(api, 'full-row popup: valid popup value kept, row-level error held').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ ⏳ id:0 number:🖍️42 10 other:1 + `); + + // Satisfying the row rule commits both values, including the one typed in the popup. + await user.clear(otherInput); + await user.type(otherInput, '100{Enter}'); + await asyncSetTimeout(0); + + expect(rowData[0].number).toBe(42); + expect(rowData[0].other).toBe(100); + + await new GridRows(api, 'full-row popup: row committed once the row rule was satisfied').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 number:42 other:100 + `); + }); + + // A blocked value never reaches the edit model (the sync drops it), so the row rule keeps seeing the + // source value while it is held. The revert then restores the staged value: recompute, or the row + // carries the verdict on a value it no longer holds. + test('full-row: reverting a blocked popup value revalidates the row against the value left behind', async () => { + enableDevValidations({ throwOn: ALL_SEVERITIES, suppress: [98] }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const rowData = [{ number: 10, other: 50 }]; + const api = await gridsManager.createGridAndWait('fullrow-popup-revert-row-error', { + columnDefs: [ + { + field: 'number', + cellEditor: 'agNumberCellEditor', + cellEditorPopup: true, + cellEditorParams: { max: 100 }, + }, + { field: 'other', cellEditor: 'agNumberCellEditor', cellEditorParams: { max: 100 } }, + ], + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + stopEditingWhenCellsLoseFocus: true, + getFullRowEditValidationErrors: ({ editorsState }) => { + const number = editorsState.find((e) => e.colId === 'number')?.newValue; + return number != null && number > 20 ? ['number must not exceed 20'] : []; + }, + } satisfies GridOptions); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + const rowShowsInvalid = () => !!grid.querySelector('.ag-row[row-index="0"].ag-row-editing-invalid'); + const otherInput = () => grid.querySelector('[col-id="other"] input')!; + + // 42 breaks the row rule but not the cell's own max, so it is staged by the popup close. + await user.dblClick(cell(api, 0, 'number')); + const numberInput = (await waitForPopup(grid)).querySelector('input')!; + expect(warnSpy).toHaveBeenCalled(); // the unsupported popup/fullRow pairing announces itself + await user.clear(numberInput); + await user.type(numberInput, '42'); + await user.click(otherInput()); + await asyncSetTimeout(0); + + expect(rowShowsInvalid()).toBe(true); + await new GridRows(api, 'full-row popup: 42 staged, row rule broken by it').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ ⏳ id:0 number:🖍️42 10 other:50 + `); + + // Reopening and typing over its max drops 42 from the model, so the row rule now sees 10. + api.startEditingCell({ rowIndex: 0, colKey: 'number' }); + const reopened = (await waitForPopup(grid)).querySelector('input')!; + await user.clear(reopened); + await user.type(reopened, '500'); + await asyncSetTimeout(0); + + expect(rowShowsInvalid()).toBe(false); + + // Closing the popup reverts the blocked 500, leaving the staged 42 — which the rule rejects. + await user.click(otherInput()); + await asyncSetTimeout(0); + + expect(api.getEditValidationErrors()).toEqual([]); // no cell is at fault, the hold is the row's + expect(rowShowsInvalid()).toBe(true); + await new GridRows(api, 'full-row popup: revert restored 42, row rule broken again').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ ⏳ id:0 number:🖍️42 10 other:50 + `); + + // Block mode holds the row for its own error: nothing commits until the rule is satisfied. + await user.type(otherInput(), '{Enter}'); + await asyncSetTimeout(0); + + expect(rowData[0].number).toBe(10); + expect(api.getEditingCells().length).toBeGreaterThan(0); + }); + + // Escape on a held invalid value cancels the edit, which reverts it in place and so re-creates the + // editor before closing it. The close still belongs to the session that fired cellEditingStarted. + test('block: Escape in a popup holding an invalid value fires cellEditingStopped', async () => { + const rowData: NumRow[] = [{ name: 'Bob', number: 10 }]; + const api = await gridsManager.createGridAndWait('block-popup-escape', { + columnDefs: [ + { field: 'name' }, + { + field: 'number', + cellEditor: 'agNumberCellEditor', + cellEditorPopup: true, + cellEditorParams: { max: 100 }, + }, + ], + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + const tracker = new EditEventTracker(api); + + await user.dblClick(cell(api, 0, 'number')); + const popup = await waitForPopup(grid); + const input = popup.querySelector('input')!; + await user.clear(input); + await user.type(input, '150'); // above max: held by block mode + await asyncSetTimeout(0); + + expect(tracker.counts.cellEditingStarted).toBe(1); + expect(tracker.counts.cellEditingStopped).toBe(0); + + await user.keyboard('{Escape}'); + await asyncSetTimeout(0); + + expect(tracker.counts.cellEditingStopped).toBe(1); + expect(api.getEditingCells()).toHaveLength(0); + expect(rowData[0].number).toBe(10); // cancelled, so nothing written + + await new GridRows(api, 'popup escape: edit cancelled, nothing held').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 name:"Bob" number:10 + `); + tracker.destroy(); + }); + + // A popup editor that buffers input (as a Firefox date segment does) must be flushed before the close + // reads validity, or the stop that follows rejects a value whose popup DOM has already gone. + test('block: a buffered popup value is flushed before the close decides, not left held', async () => { + const rowData: NumRow[] = [{ name: 'Bob', number: 10 }]; + const api = await gridsManager.createGridAndWait('block-popup-buffered', { + columnDefs: [ + { field: 'name' }, + { field: 'number', cellEditor: BufferedFlushEditor, cellEditorPopup: true }, + ], + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + stopEditingWhenCellsLoseFocus: true, + } satisfies GridOptions); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + await user.dblClick(cell(api, 0, 'number')); + const popup = await waitForPopup(grid); + const input = popup.querySelector('.buffered-input')!; + await user.clear(input); + await user.type(input, '150'); // invalid once flushed, buffered until then + + await user.click(cell(api, 0, 'name')); // closes the popup + await asyncSetTimeout(0); + + // Seen as invalid at the close, so the edit is reverted rather than held with no editor to fix it. + expect(rowData[0].number).toBe(10); + expect(api.getEditingCells()).toHaveLength(0); + expect(editorCount(api)).toBe(0); + + await new GridRows(api, 'buffered popup: closed on an invalid value, nothing left held').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 name:"Bob" number:10 + `); + }); +}); + +describe('Cell editing validation — a rule removed while its error is held', () => { + // Neither validation hook, so nothing but the row callback can report an error while this editor is open. + class PlainEditor implements ICellEditorComp { + private eInput!: HTMLInputElement; + public init(params: ICellEditorParams): void { + this.eInput = document.createElement('input'); + this.eInput.value = params.value == null ? '' : String(params.value); + } + public getGui(): HTMLElement { + return this.eInput; + } + public afterGuiAttached(): void { + this.eInput.focus(); + } + public getValue(): number | null { + const value = this.eInput.value; + return value === '' ? null : Number(value); + } + } + + // Block mode holds the row on an error nothing revisits, so a rule dropped mid-edit would wedge the row: + // no rule left to satisfy, and the recorded verdict outliving the rule that formed it. + test('removing the last rule releases the row it was holding', async () => { + const rowData = [{ name: 'Bob', number: 23 }]; + const api = await gridsManager.createGridAndWait('rule-removed-mid-edit', { + columnDefs: [{ field: 'name' }, { field: 'number', editable: true, cellEditor: PlainEditor }], + rowData, + editType: 'fullRow', + invalidEditValueMode: 'block', + getFullRowEditValidationErrors: ({ editorsState }) => { + const number = editorsState.find((e) => e.colId === 'number')?.newValue; + return number != null && number > 30 ? ['number must not exceed 30'] : []; + }, + } satisfies GridOptions); + const grid = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + const numberInput = () => grid.querySelector('[col-id="number"] input')!; + + await user.dblClick(cell(api, 0, 'number')); + await user.clear(numberInput()); + await user.type(numberInput(), '40{Enter}'); + await asyncSetTimeout(0); + + expect(rowData[0].number).toBe(23); // held by the row rule + expect(api.getEditingCells().length).toBeGreaterThan(0); + + await new GridRows(api, 'row held by the rule, 40 not committed').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ id:0 name:"Bob" number:🖍️40 23 + `); + + api.setGridOption('getFullRowEditValidationErrors', undefined); + await user.type(numberInput(), '{Enter}'); + await asyncSetTimeout(0); + + expect(rowData[0].number).toBe(40); + expect(api.getEditingCells()).toEqual([]); + + await new GridRows(api, 'rule gone, the row it held commits').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 name:"Bob" number:40 + `); + }); +}); diff --git a/testing/behavioural/src/cell-editing/cell-editing-validation.test.ts b/testing/behavioural/src/cell-editing/cell-editing-validation.test.ts new file mode 100644 index 00000000000..23d9ce1a295 --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-validation.test.ts @@ -0,0 +1,513 @@ +import { userEvent } from '@testing-library/user-event'; + +import type { ColDef, GridApi, GridOptions } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + NumberEditorModule, + TextEditorModule, + getGridElement, + setupAgTestIds, +} from 'ag-grid-community'; + +import { GridRows, TestGridsManager, waitForInput } from '../test-utils'; + +interface PersonRow { + athlete: string; + age: number; +} + +const columnDefs: ColDef[] = [ + { field: 'athlete', cellEditor: 'agTextCellEditor', cellEditorParams: { maxLength: 10 } }, + { field: 'age', cellEditor: 'agNumberCellEditor', cellEditorParams: { min: 0, max: 100 } }, +]; + +function makeRowData(): PersonRow[] { + return [ + { athlete: 'Alice', age: 23 }, + { athlete: 'Bob', age: 40 }, + ]; +} + +describe('Cell editing validation modes', () => { + const gridsManager = new TestGridsManager({ + includeDefaultModules: true, + modules: [ClientSideRowModelModule, NumberEditorModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridsManager.reset(); + vi.clearAllMocks(); + }); + + const cell = (api: GridApi, rowIndex: number, colId: string): HTMLElement => { + const gridElement = getGridElement(api)! as HTMLElement; + return gridElement.querySelector(`[row-index="${rowIndex}"] [col-id="${colId}"]`)!; + }; + + const editorCount = (api: GridApi): number => api.getCellEditorInstances().length; + + describe.each(['singleCell', 'fullRow'] as const)('editType: %s', (editType) => { + const create = async (invalidEditValueMode: 'revert' | 'block', rowData: PersonRow[]) => + gridsManager.createGridAndWait(`validation-${editType}-${invalidEditValueMode}`, { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType, + invalidEditValueMode, + } satisfies GridOptions); + + test('block: invalid value + Enter keeps editor(s) open and does not commit', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(getGridElement(api)! as HTMLElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBeGreaterThan(0); + expect(ageCell.querySelector('input')).toBeTruthy(); + expect(rowData[0].age).toBe(23); + }); + + test('block: valid value + Enter commits and closes editor(s)', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(getGridElement(api)! as HTMLElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '55'); + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(55); + }); + + test('block: invalid value + api.stopEditing keeps editor(s) open and does not commit', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(getGridElement(api)! as HTMLElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + api.stopEditing(); + + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + }); + + test('block: invalid value + Escape reverts and closes editor(s)', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(getGridElement(api)! as HTMLElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await user.keyboard('{Escape}'); + // Escape stop is scheduled async in cellKeyboardListenerFeature. + await new Promise((r) => setTimeout(r)); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(23); + }); + + test('revert: invalid value + Enter reverts and closes editor(s)', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(getGridElement(api)! as HTMLElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(23); + }); + + test('revert: valid value + Enter commits and closes editor(s)', async () => { + const rowData = makeRowData(); + const api = await create('revert', rowData); + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(getGridElement(api)! as HTMLElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '55'); + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(55); + }); + + test('block: a blocked value can be corrected in place and then committed', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const user = userEvent.setup(); + const gridElement = getGridElement(api)! as HTMLElement; + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + let ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await user.keyboard('{Enter}'); + + // blocked: still editing, nothing committed + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + + // correct the value in the still-open editor and commit + ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '55'); + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(55); + }); + + test('block: a blocked commit does not fire cellValueChanged; a later valid commit does', async () => { + const rowData = makeRowData(); + const onCellValueChanged = vi.fn(); + const api = await gridsManager.createGridAndWait(`validation-${editType}-events`, { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType, + invalidEditValueMode: 'block', + onCellValueChanged, + } satisfies GridOptions); + const user = userEvent.setup(); + const gridElement = getGridElement(api)! as HTMLElement; + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + let ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await user.keyboard('{Enter}'); + + expect(onCellValueChanged).not.toHaveBeenCalled(); + + ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '55'); + await user.keyboard('{Enter}'); + + expect(onCellValueChanged).toHaveBeenCalledTimes(1); + expect(onCellValueChanged).toHaveBeenCalledWith(expect.objectContaining({ oldValue: 23, newValue: 55 })); + }); + + test('api.stopEditing(true) cancels the edit and reverts, regardless of mode', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(getGridElement(api)! as HTMLElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + api.stopEditing(true); + + expect(editorCount(api)).toBe(0); + expect(rowData[0].age).toBe(23); + }); + }); + + // Full-row editing opens every editable cell in the row at once, so a stop request may + // carry a rowNode with no specific column. These cases guard that cell-level validation + // is still honoured on that path. + describe('fullRow with a cell invalid in a non-focused column', () => { + const create = async (invalidEditValueMode: 'revert' | 'block', rowData: PersonRow[]) => + gridsManager.createGridAndWait(`validation-fullrow-other-${invalidEditValueMode}`, { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode, + } satisfies GridOptions); + + test('block: invalid age while focus is on athlete keeps editors open on stopEditing', async () => { + const rowData = makeRowData(); + const api = await create('block', rowData); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + // Open the whole row via the age cell, type an invalid value there. + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + + // Move focus to the (valid) athlete cell within the same row, then stop. + const athleteInput = await waitForInput(gridElement, cell(api, 0, 'athlete')); + athleteInput.focus(); + api.stopEditing(); + + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + + await new GridRows(api, 'fullRow block: invalid age in a non-focused column still holds the row ❌').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); + + describe('fullRow with row-level validation (getFullRowEditValidationErrors)', () => { + const create = async (invalidEditValueMode: 'revert' | 'block') => + gridsManager.createGridAndWait(`validation-fullrow-rowlevel-${invalidEditValueMode}`, { + columnDefs, + rowData: makeRowData(), + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode, + // Row is invalid when the athlete name is "Invalid" (7 chars, so no cell-level error). + getFullRowEditValidationErrors: ({ editorsState }) => { + const athlete = editorsState.find((e) => e.colId === 'athlete')?.newValue; + return athlete === 'Invalid' ? ['Row is invalid'] : null; + }, + } satisfies GridOptions); + + test('block: row-level error + Enter keeps editors open', async () => { + const api = await create('block'); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const athleteCell = cell(api, 0, 'athlete'); + await user.dblClick(athleteCell); + const athleteInput = await waitForInput(gridElement, athleteCell); + await user.clear(athleteInput); + await user.type(athleteInput, 'Invalid'); + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBeGreaterThan(0); + + // Row-level error: the "Invalid" name is staged (🖍️) but held; row-level rules mark no + // single cell, so there is no ❌ — unlike a cell-level error. + await new GridRows(api, 'fullRow block: row-level error holds the staged name, no cell ❌').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:🖍️"Invalid" "Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('block: row-level error + api.stopEditing keeps editors open', async () => { + const api = await create('block'); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const athleteCell = cell(api, 0, 'athlete'); + await user.dblClick(athleteCell); + const athleteInput = await waitForInput(gridElement, athleteCell); + await user.clear(athleteInput); + await user.type(athleteInput, 'Invalid'); + api.stopEditing(); + + expect(editorCount(api)).toBeGreaterThan(0); + + // Row-level error holds the row open on stopEditing too (staged 🖍️, no single-cell ❌). + await new GridRows(api, 'fullRow block: row-level error holds row open on stopEditing').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ id:0 athlete:🖍️"Invalid" "Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('revert: row-level error + Enter closes editors and reverts', async () => { + const api = await create('revert'); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const athleteCell = cell(api, 0, 'athlete'); + await user.dblClick(athleteCell); + const athleteInput = await waitForInput(gridElement, athleteCell); + await user.clear(athleteInput); + await user.type(athleteInput, 'Invalid'); + await user.keyboard('{Enter}'); + + expect(editorCount(api)).toBe(0); + expect(athleteCell).toHaveTextContent('Alice'); + + // Revert closes the whole row and restores source values — no pending/invalid markers. + await new GridRows(api, 'fullRow revert: row-level error reverted, row restored').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); + + describe('fullRow with multiple invalid cells', () => { + const twoNumberCols: ColDef[] = [ + { field: 'age', cellEditor: 'agNumberCellEditor', cellEditorParams: { min: 0, max: 100 } }, + { field: 'score', cellEditor: 'agNumberCellEditor', cellEditorParams: { min: 0, max: 10 } }, + ]; + + test('block: two invalid cells keep the whole row open and commit neither', async () => { + const rowData = [{ age: 23, score: 5 }]; + const api = await gridsManager.createGridAndWait('validation-fullrow-multi', { + columnDefs: twoNumberCols, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + }); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + + const scoreInput = await waitForInput(gridElement, cell(api, 0, 'score')); + await user.clear(scoreInput); + await user.type(scoreInput, '888'); + + api.stopEditing(); + + expect(editorCount(api)).toBeGreaterThan(0); + expect(rowData[0].age).toBe(23); + expect(rowData[0].score).toBe(5); + + await new GridRows(api, 'fullRow block: both age and score flagged ❌, neither committed').check(` + ROOT id:ROOT_NODE_ID + └── LEAF 🖍️ ❌ id:0 age:23❌ score:5❌ + `); + }); + }); + + // GridRows snapshots verify the rendered/pending row state (🖍️ markers, committed vs source + // values). editType renders differently, so these live outside the parametrised block. + describe('grid state snapshots', () => { + test('singleCell block: an invalid value that is corrected commits cleanly (no stale pending state)', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-snap-single', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + let ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await user.keyboard('{Enter}'); // blocked + + // blocked: cell actively editing (🖍️) and flagged invalid (❌); model keeps source value + await new GridRows(api, 'singleCell block: invalid value held, cell flagged').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '55'); + await user.keyboard('{Enter}'); // corrected, commits + + // editor closed, value committed, no lingering pending (🖍️) or invalid (❌) marker + await new GridRows(api, 'singleCell block: corrected value committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:55 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('fullRow block: correcting the invalid cell commits the whole row cleanly', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-snap-fullrow', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'fullRow', + invalidEditValueMode: 'block', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + let ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await user.keyboard('{Enter}'); // blocked, row stays open + + expect(editorCount(api)).toBeGreaterThan(0); + + // whole row actively editing (🖍️), the invalid cell flagged (❌) + await new GridRows(api, 'fullRow block: invalid cell holds the row open').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF 🖍️ ❌ id:0 athlete:"Alice" age:23❌ + └── LEAF id:1 athlete:"Bob" age:40 + `); + + ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '55'); + await user.keyboard('{Enter}'); // corrected, row commits + + await new GridRows(api, 'fullRow block: corrected row committed').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:55 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + + test('revert: invalid value discarded, source value restored', async () => { + const rowData = makeRowData(); + const api = await gridsManager.createGridAndWait('validation-snap-revert', { + columnDefs, + rowData, + defaultColDef: { editable: true }, + editType: 'singleCell', + invalidEditValueMode: 'revert', + } satisfies GridOptions); + const gridElement = getGridElement(api)! as HTMLElement; + const user = userEvent.setup(); + + const ageCell = cell(api, 0, 'age'); + await user.dblClick(ageCell); + const ageInput = await waitForInput(gridElement, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '999'); + await user.keyboard('{Enter}'); + + await new GridRows(api, 'revert: source value restored').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Alice" age:23 + └── LEAF id:1 athlete:"Bob" age:40 + `); + }); + }); +}); diff --git a/testing/behavioural/src/cell-editing/group-edit/group-edit-test-utils.ts b/testing/behavioural/src/cell-editing/group-edit/group-edit-test-utils.ts index 823f76f5e03..caeba435cab 100644 --- a/testing/behavioural/src/cell-editing/group-edit/group-edit-test-utils.ts +++ b/testing/behavioural/src/cell-editing/group-edit/group-edit-test-utils.ts @@ -46,22 +46,16 @@ function locateCellElements(api: GridApi, rowNode: IRowNode, colId: string) { return { gridDiv: gridDiv!, cell: cell!, rowIndex: rowIndex! }; } -const TYPE_ATTEMPTS = 3; - /** * Types `newValue` into the open editor of a cell and returns the input it was typed into. * - * A redraw landing part-way through typing replaces the editor input, discarding the keystrokes - * already dispatched into the now-detached element. Re-query the cell and retry on that, rather - * than committing a half-typed (or empty) editor and silently dropping the edit. + * A redraw part-way through typing replaces the input, discarding the keystrokes dispatched into the + * now-detached one, so retry rather than commit a half-typed editor. */ async function typeIntoEditor(api: GridApi, rowNode: IRowNode, colId: string, newValue: string) { for (let attempt = 0; attempt < TYPE_ATTEMPTS; ++attempt) { - // Re-query the cell each attempt — in jsdom, `ensureIndexVisible` and aggregation-driven - // redraws replace cell DOM elements, making an earlier reference stale. Scope the input - // lookup to this cell rather than the whole grid: when edits happen in sequence a previous - // editor's input can briefly linger in the DOM, and a grid-wide lookup would grab that - // stale input instead of the one just opened. + // Re-queried per attempt and cell-scoped: a previous editor's input can linger in the DOM + // long enough for a grid-wide lookup to find it instead of the one just opened. const { gridDiv, cell } = locateCellElements(api, rowNode, colId); const input = await waitForInput(gridDiv, cell); await userEvent.clear(input); @@ -74,23 +68,40 @@ async function typeIntoEditor(api: GridApi, rowNode: IRowNode, colId: string, ne throw new Error(`Could not type "${newValue}" into the editor of "${colId}": the input kept being replaced`); } +const TYPE_ATTEMPTS = 4; + export async function editCell(api: GridApi, rowNode: IRowNode, colId: string, newValue: string) { - // Let a redraw still pending from a previous edit settle before opening this editor, so it - // cannot detach this editor's input part-way through typing. + // A redraw still pending from a previous edit would detach this editor's input mid-typing. await asyncSetTimeout(0); - const { rowIndex } = locateCellElements(api, rowNode, colId); - api.setFocusedCell(rowIndex, colId, rowNode.rowPinned ?? undefined); - api.startEditingCell({ rowIndex, rowPinned: rowNode.rowPinned, colKey: colId }); - await asyncSetTimeout(0); + // Enter dispatched into a detached input commits nothing yet still leaves the grid not editing, + // so the stop event is the only proof the keystroke landed; without it, re-open and retype. + for (let attempt = 0; attempt < TYPE_ATTEMPTS; ++attempt) { + const { rowIndex } = locateCellElements(api, rowNode, colId); + api.setFocusedCell(rowIndex, colId, rowNode.rowPinned ?? undefined); + api.startEditingCell({ rowIndex, rowPinned: rowNode.rowPinned, colKey: colId }); + await asyncSetTimeout(0); - const input = await typeIntoEditor(api, rowNode, colId, newValue); - await userEvent.type(input, '{Enter}'); - await asyncSetTimeout(0); + const input = await typeIntoEditor(api, rowNode, colId, newValue); + + let stopped = false; + const onStopped = () => { + stopped = true; + }; + api.addEventListener('cellEditingStopped', onStopped); + if (input.isConnected) { + await userEvent.type(input, '{Enter}'); + await asyncSetTimeout(0); + } + api.removeEventListener('cellEditingStopped', onStopped); - // Enter dispatched into an input the grid has already discarded commits nothing. Fail here - // rather than leaving the test to report a confusing stale value several assertions later. - expect(api.getEditingCells()).toEqual([]); + if (stopped) { + expect(api.getEditingCells()).toEqual([]); + return; + } + await asyncSetTimeout(attempt * 2); + } + throw new Error(`Enter never reached the editor of "${colId}": its input kept being replaced`); } /** diff --git a/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-sibling.test.ts b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-sibling.test.ts index 5248a485b9d..12735eda569 100644 --- a/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-sibling.test.ts +++ b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-sibling.test.ts @@ -123,10 +123,12 @@ describe('editing with pinned sibling rows', () => { }); await asyncSetTimeout(0); + // No flag on the body row: the edit is on its pinned sibling, and pinned rows carry no + // row state flags (getRowStateFlags). await new GridRows(api, 'during edit').check(` PINNED_TOP id:t-top-1 country:"France" year:2020 sales:1000 region:"Europe" ROOT id:ROOT_NODE_ID - ├── LEAF 🖍️ id:1 country:"France" year:2020 sales:1000 region:"Europe" + ├── LEAF id:1 country:"France" year:2020 sales:1000 region:"Europe" ├── LEAF id:2 country:"France" year:2021 sales:1200 region:"Europe" ├── LEAF id:3 country:"Germany" year:2020 sales:1500 region:"Europe" ├── LEAF id:4 country:"Germany" year:2021 sales:1800 region:"Europe" diff --git a/testing/behavioural/src/cell-editing/rich-select/rich-select-scroll-picker.test.ts b/testing/behavioural/src/cell-editing/rich-select/rich-select-scroll-picker.test.ts new file mode 100644 index 00000000000..df3093b02af --- /dev/null +++ b/testing/behavioural/src/cell-editing/rich-select/rich-select-scroll-picker.test.ts @@ -0,0 +1,65 @@ +import { waitFor } from '@testing-library/dom'; + +import type { ColDef, GridApi, GridOptions } from 'ag-grid-community'; +import { ScrollApiModule, getGridElement } from 'ag-grid-community'; +import { RichSelectModule } from 'ag-grid-enterprise'; + +import { TestGridsManager, asyncSetTimeout, fakeElementAttribute } from '../../test-utils'; + +const LANGUAGES = ['English', 'Spanish', 'French', 'Portuguese', '(other)']; + +// 15 × 200px columns exceed the mocked 1000px viewport, so a far column is off-screen until editing +// scrolls it into view — guards that the Rich Select picker still opens for that scrolled-in edit. +function wideRichSelectColumns(): ColDef[] { + return Array.from({ length: 15 }, (_, i) => ({ + field: `c${i}`, + width: 200, + cellEditor: 'agRichSelectCellEditor', + cellEditorParams: { values: LANGUAGES }, + })); +} + +function rowData(): Record[] { + return [ + Object.fromEntries(wideRichSelectColumns().map((c) => [c.field!, 'English'])), + Object.fromEntries(wideRichSelectColumns().map((c) => [c.field!, 'Spanish'])), + ]; +} + +const pickerExpanded = (gridDiv: HTMLElement): boolean => !!gridDiv.querySelector('.ag-picker-expanded'); + +describe('Rich Select cell editor — picker opens for a scrolled-into-view edit', () => { + const gridMgr = new TestGridsManager({ + includeDefaultModules: true, + modules: [RichSelectModule, ScrollApiModule], + }); + + beforeEach(() => { + // VirtualList skips rendering rows when the viewport height is 0 (no layout in jsdom). + fakeElementAttribute('offsetHeight', 100, '.ag-virtual-list-viewport'); + }); + + afterEach(() => gridMgr.reset()); + + const create = (): Promise => + gridMgr.createGridAndWait('rich-select-scroll', { + columnDefs: wideRichSelectColumns(), + rowData: rowData(), + defaultColDef: { editable: true }, + editType: 'singleCell', + suppressColumnVirtualisation: false, + suppressAnimationFrame: true, + } satisfies GridOptions); + + test('picker opens when editing starts on an off-screen column scrolled into view', async () => { + const api = await create(); + const gridDiv = getGridElement(api)! as HTMLElement; + + // c8 sits outside the 1000px viewport; startEditingCell scrolls it into view as part of the start. + api.startEditingCell({ rowIndex: 0, colKey: 'c8' }); + await asyncSetTimeout(1); + + // The Rich Select picker must be shown for the freshly-started edit. + await waitFor(() => expect(pickerExpanded(gridDiv)).toBe(true)); + }); +}); diff --git a/testing/behavioural/src/cell-editing/set-value/cell-editing-bulk-edit.test.ts b/testing/behavioural/src/cell-editing/set-value/cell-editing-bulk-edit.test.ts index a4851de32b7..d8952964269 100644 --- a/testing/behavioural/src/cell-editing/set-value/cell-editing-bulk-edit.test.ts +++ b/testing/behavioural/src/cell-editing/set-value/cell-editing-bulk-edit.test.ts @@ -429,4 +429,51 @@ describe('Cell Editing: bulk edit', () => { // Should update in row order, then column order within each row expect(updateOrder).toEqual(['ROW_0:a', 'ROW_0:b', 'ROW_0:c', 'ROW_1:a', 'ROW_1:b', 'ROW_1:c']); }); + + // One Ctrl+Enter is one bulk edit, however many ranges it covers. A stopped event per range leaves + // the pair unbalanced, and consumers that latch on the first (undo/redo) drop every later range. + test('bulk edit across two cell ranges dispatches one started/stopped pair', async () => { + const api = await gridMgr.createGridAndWait('cellEditingBulkMultiRange', { + cellSelection: true, + defaultColDef: { editable: true }, + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData: [ + { id: 'ROW_0', a: 'A0', b: 'B0' }, + { id: 'ROW_1', a: 'A1', b: 'B1' }, + ], + getRowId: (params) => params.data.id, + }); + const eventTracker = new EditEventTracker(api); + const gridDiv = getGridElement(api)! as HTMLElement; + + const user = userEvent.setup({ skipHover: true }); + const cell = gridDiv.querySelectorAll('.ag-row')[0].querySelector('[col-id="a"]')!; + await user.click(cell); // focuses the cell, so Ctrl+Enter has a target + + // Clicking a cell can select it, and addCellRange appends: dropping that first makes the two + // ranges below the only ones, whatever the click did. + api.clearCellSelection(); + + // Two disjoint single-cell ranges: column 'a' of each row. + api.addCellRange({ rowStartIndex: 0, rowEndIndex: 0, columns: ['a'] }); + api.addCellRange({ rowStartIndex: 1, rowEndIndex: 1, columns: ['a'] }); + expect(api.getCellRanges()).toHaveLength(2); + + api.startEditingCell({ rowIndex: 0, colKey: 'a' }); + const input = await waitForInput(gridDiv, cell); + await user.clear(input); + await user.type(input, 'X'); + await user.keyboard('{Control>}{Enter}{/Control}'); + await asyncSetTimeout(0); + + // Every cell in both ranges takes the value, and the event pair stays balanced. + await new GridRows(api, 'after bulk edit across two ranges').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 a:"X" b:"B0" + └── LEAF id:ROW_1 a:"X" b:"B1" + `); + + expect(eventTracker.counts.bulkEditingStarted).toBe(1); + expect(eventTracker.counts.bulkEditingStopped).toBe(1); + }); }); diff --git a/testing/behavioural/src/columnHeaderEdit/editable-header-name-react.test.tsx b/testing/behavioural/src/columnHeaderEdit/editable-header-name-react.test.tsx index f00c14b170c..fd041b415a0 100644 --- a/testing/behavioural/src/columnHeaderEdit/editable-header-name-react.test.tsx +++ b/testing/behavioural/src/columnHeaderEdit/editable-header-name-react.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, waitFor } from '@testing-library/react'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; import React from 'react'; import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'; @@ -6,6 +6,8 @@ import { ModuleRegistry } from 'ag-grid-community'; import { AllEnterpriseModule } from 'ag-grid-enterprise'; import { AgGridReact } from 'ag-grid-react'; +import { ignoreConsoleLicenseKeyError } from '../test-utils'; + /** * The column header name refresh lives in the shared `headerCellCtrl`, driven by the grid-level * `columnHeaderNameChanged` event, but React re-implements the header view (`reactUi/header/headerCellComp.tsx`), @@ -18,6 +20,7 @@ describe('editable column header name (react)', () => { beforeAll(() => { ModuleRegistry.registerModules([AllEnterpriseModule]); + ignoreConsoleLicenseKeyError(); }); beforeEach(() => { @@ -39,7 +42,9 @@ describe('editable column header name (react)', () => { const headerText = () => document.querySelector('.ag-header-cell-text')?.textContent; await waitFor(() => expect(headerText()).toBe('Athlete')); - gridApi!.applyColumnState({ state: [{ colId: 'athlete', headerName: 'Competitor' }] }); + act(() => { + gridApi!.applyColumnState({ state: [{ colId: 'athlete', headerName: 'Competitor' }] }); + }); await waitFor(() => expect(headerText()).toBe('Competitor')); }); diff --git a/testing/behavioural/src/columns/column-api-extended.test.ts b/testing/behavioural/src/columns/column-api-extended.test.ts index 88cf84da28a..c99399f50c4 100644 --- a/testing/behavioural/src/columns/column-api-extended.test.ts +++ b/testing/behavioural/src/columns/column-api-extended.test.ts @@ -340,13 +340,13 @@ describe('Column API — extended coverage', () => { api.setColumnAggFunc('gold', null); await new GridColumns(api, `setColumnAggFunc deactivates gold → pivot result columns lose the measure`) .checkColumns(` - CENTER - ├── ag-Grid-AutoColumn "Group" width:200 - ├─┬ "2000" GROUP - │ └── pivot_year_2000_ "-" width:200 - └─┬ "2004" GROUP - └── pivot_year_2004_ "-" width:200 - `); + CENTER + ├── ag-Grid-AutoColumn "Group" width:200 + ├─┬ "2000" GROUP + │ └── pivot_year_2000_ "-" width:200 + └─┬ "2004" GROUP + └── pivot_year_2004_ "-" width:200 + `); }); test('changing the aggFunc of an active value column re-aggregates without a column rebuild', async () => { diff --git a/testing/behavioural/src/filters/filter-behaviour/floating-filter-editing.test.ts b/testing/behavioural/src/filters/filter-behaviour/floating-filter-editing.test.ts index b99a61cb9a0..02937e2c762 100644 --- a/testing/behavioural/src/filters/filter-behaviour/floating-filter-editing.test.ts +++ b/testing/behavioural/src/filters/filter-behaviour/floating-filter-editing.test.ts @@ -115,14 +115,14 @@ describe('Floating filter editing', () => { `); await new FilterDom(api, 'text floating editable', { mode: 'floating-filter', colId: 'country' }) .checkFilterDom(` - FLOATING FILTER country - input: "aus" - active: true - model: - filterType: "text" - type: "contains" - filter: "aus" - `); + FLOATING FILTER country + input: "aus" + active: true + model: + filterType: "text" + type: "contains" + filter: "aus" + `); // Clearing the floating input clears the model and restores all rows. typeInto(floatingInput(api, 'country'), ''); @@ -175,20 +175,20 @@ describe('Floating filter editing', () => { `); await new FilterDom(api, 'text floating read-only', { mode: 'floating-filter', colId: 'country' }) .checkFilterDom(` - FLOATING FILTER country - input: "ita OR ice" ⊘ - active: true - model: - filterType: "text" - operator: "OR" - conditions: - - filterType: "text" - type: "contains" - filter: "ita" - - filterType: "text" - type: "contains" - filter: "ice" - `); + FLOATING FILTER country + input: "ita OR ice" ⊘ + active: true + model: + filterType: "text" + operator: "OR" + conditions: + - filterType: "text" + type: "contains" + filter: "ita" + - filterType: "text" + type: "contains" + filter: "ice" + `); }); // --- Number --- @@ -265,15 +265,15 @@ describe('Floating filter editing', () => { `); await new FilterDom(api, 'number floating read-only', { mode: 'floating-filter', colId: 'age' }) .checkFilterDom(` - FLOATING FILTER age - input: "20-35" ⊘ - active: true - model: - filterType: "number" - type: "inRange" - filter: 20 - filterTo: 35 - `); + FLOATING FILTER age + input: "20-35" ⊘ + active: true + model: + filterType: "number" + type: "inRange" + filter: 20 + filterTo: 35 + `); }); test('number: non-numeric typing produces no model and leaves all rows', async () => { diff --git a/testing/behavioural/src/filters/filter-behaviour/group-filter.test.ts b/testing/behavioural/src/filters/filter-behaviour/group-filter.test.ts index 1835560667d..13f840080d7 100644 --- a/testing/behavioural/src/filters/filter-behaviour/group-filter.test.ts +++ b/testing/behavioural/src/filters/filter-behaviour/group-filter.test.ts @@ -505,16 +505,16 @@ describe('Group Column Filter — agGroupColumnFilter', () => { await switchGroupField('Country'); await new FilterDom(api, 'country level still shows Italy+France selected', { colId: 'country' }) .checkFilterDom(` - COLUMN FILTER (set) - mini-filter: "" - ☑ (Select All) - ☑ France - ☑ Italy - model: - values: - - "France" - - "Italy" - filterType: "set" - `); + COLUMN FILTER (set) + mini-filter: "" + ☑ (Select All) + ☑ France + ☑ Italy + model: + values: + - "France" + - "Italy" + filterType: "set" + `); }); }); diff --git a/testing/behavioural/src/selection/cell-selection-raf.test.ts b/testing/behavioural/src/selection/cell-selection-raf.test.ts index 574f4797912..21a3d23abec 100644 --- a/testing/behavioural/src/selection/cell-selection-raf.test.ts +++ b/testing/behavioural/src/selection/cell-selection-raf.test.ts @@ -279,10 +279,10 @@ describe('RowSpanService does not register listeners when enableCellSpan is not }); await new GridColumns(api, `data updates with enableCellSpan active are processed without error setup`) .checkColumns(` - CENTER - ├── name "Name" width:200 - └── value "Value" width:200 - `); + CENTER + ├── name "Name" width:200 + └── value "Value" width:200 + `); await new GridRows(api, `data updates with enableCellSpan active are processed without error setup`).check(` ROOT id:ROOT_NODE_ID ├── LEAF id:a name:"a" value:1 @@ -301,11 +301,11 @@ describe('RowSpanService does not register listeners when enableCellSpan is not api, `data updates with enableCellSpan active are processed without error after applyTransaction` ).check(` - ROOT id:ROOT_NODE_ID - ├── LEAF id:a name:"a" value:999 - ├── LEAF id:b name:"b" value:2 - └── LEAF id:c name:"c" value:3 - `); + ROOT id:ROOT_NODE_ID + ├── LEAF id:a name:"a" value:999 + ├── LEAF id:b name:"b" value:2 + └── LEAF id:c name:"c" value:3 + `); // Allow RowSpanService timeouts and events to process await asyncSetTimeout(50); diff --git a/testing/behavioural/src/test-utils/filters/advancedFilterBuilderHarness.ts b/testing/behavioural/src/test-utils/filters/advancedFilterBuilderHarness.ts index 4bf3b414f63..58faedd476c 100644 --- a/testing/behavioural/src/test-utils/filters/advancedFilterBuilderHarness.ts +++ b/testing/behavioural/src/test-utils/filters/advancedFilterBuilderHarness.ts @@ -1,3 +1,5 @@ +import { waitFor } from '@testing-library/dom'; + import type { GridApi } from 'ag-grid-community'; import { DragEventDispatcher } from '../drag-n-drop/drag-event-dispatcher'; @@ -105,14 +107,17 @@ export class AdvancedFilterBuilderHarness { throw new Error('Value pill not found on builder item'); } await firePointerLikeClick(pill); - await asyncSetTimeout(0); - // The column/operator pills carry hidden rich-select inputs; the value editor is the only visible one. - const editor = Array.from(item.querySelectorAll('input.ag-text-field-input')).find( - (input) => !input.closest('.ag-hidden') - ); - if (!editor) { - throw new Error('Value editor input did not open'); - } + // The column/operator pills carry hidden rich-select inputs; the value editor is the only + // visible one, and it mounts a macrotask or two after the click — poll rather than guess a delay. + const editor = await waitFor(() => { + const input = Array.from(item.querySelectorAll('input.ag-text-field-input')).find( + (candidate) => !candidate.closest('.ag-hidden') + ); + if (!input) { + throw new Error('Value editor input did not open'); + } + return input; + }); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; setter.call(editor, value); editor.dispatchEvent(new Event('input', { bubbles: true })); diff --git a/testing/behavioural/src/test-utils/gridRows/gridHtmlRows.ts b/testing/behavioural/src/test-utils/gridRows/gridHtmlRows.ts index 9985e35a91c..12f8b6bbccd 100644 --- a/testing/behavioural/src/test-utils/gridRows/gridHtmlRows.ts +++ b/testing/behavioural/src/test-utils/gridRows/gridHtmlRows.ts @@ -30,21 +30,43 @@ export interface SpannedCellInfo { span: number; } -/** Parse a `.ag-spanned-row [col-id]` cell. Pinned rows carry a prefixed `row-index` (`t-0`/`b-0`); - * the model index (matching `RowNode.rowIndex`) is the trailing number. Returns null if not a real span. */ +/** The cell key shared by the model-side and DOM-side invalid/editing lookups. Both must build it here, + * or a mismatch silently makes the cross-checks pass. */ +export function cellKey(rowIndex: number | null | undefined, pinned: string | null | undefined, colId: string) { + return `${rowKey(rowIndex, pinned)}:${colId}`; +} + +/** As {@link cellKey}, for row-scoped lookups. The pinned section is part of the key because a pinned row + * and a body row share a rowIndex. */ +export function rowKey(rowIndex: number | null | undefined, pinned: string | null | undefined) { + return `${rowIndex}:${pinned ?? ''}`; +} + +/** Model row index + pinned section of a `[row-index]` element. Pinned rows carry a prefixed `row-index` + * (`t-0`/`b-0`); the model index (matching `RowNode.rowIndex`) is the trailing number. */ +export function parseRowElement(rowElement: Element): { rowIndex: number; pinned: '' | 'top' | 'bottom' } { + const raw = rowElement.getAttribute('row-index'); + const rowIndex = raw != null ? Number(raw.replace(/^\D+/, '')) : NaN; + let pinned: '' | 'top' | 'bottom' = ''; + if (rowElement.closest('.ag-grid-pinned-top-rows')) { + pinned = 'top'; + } else if (rowElement.closest('.ag-grid-pinned-bottom-rows')) { + pinned = 'bottom'; + } + return { rowIndex, pinned }; +} + +/** Parse a `.ag-spanned-row [col-id]` cell. Returns null if not a real span. */ export function parseSpannedCell(cell: Element): SpannedCellInfo | null { const colId = cell.getAttribute('col-id'); const span = Number(cell.getAttribute('aria-rowspan')); - const raw = cell.closest('[row-index]')?.getAttribute('row-index'); - const anchorIndex = raw != null ? Number(raw.replace(/^\D+/, '')) : NaN; - if (!colId || !Number.isFinite(anchorIndex) || !Number.isFinite(span) || span <= 1) { + const rowElement = cell.closest('[row-index]'); + if (!colId || !rowElement || !Number.isFinite(span) || span <= 1) { return null; } - let pinned: '' | 'top' | 'bottom' = ''; - if (cell.closest('.ag-grid-pinned-top-rows')) { - pinned = 'top'; - } else if (cell.closest('.ag-grid-pinned-bottom-rows')) { - pinned = 'bottom'; + const { rowIndex: anchorIndex, pinned } = parseRowElement(rowElement); + if (!Number.isFinite(anchorIndex)) { + return null; } return { colId, pinned, anchorIndex, span }; } diff --git a/testing/behavioural/src/test-utils/gridRows/gridRows.ts b/testing/behavioural/src/test-utils/gridRows/gridRows.ts index 011ba420e8b..ce82c45679d 100644 --- a/testing/behavioural/src/test-utils/gridRows/gridRows.ts +++ b/testing/behavioural/src/test-utils/gridRows/gridRows.ts @@ -6,9 +6,10 @@ import type { Column, EditingCellPosition, GridApi, IRowNode, RowNode } from 'ag import { log } from '../utils'; import { addDiagramToError, collectGridRows, makeSnapshotTarget, runSnapshotCheck } from './grid-rows-helpers'; import type { SnapshotCheckTarget } from './grid-rows-helpers'; -import { getGridHTMLElement, parseSpannedCell } from './gridHtmlRows'; +import { cellKey, getGridHTMLElement, parseSpannedCell, rowKey } from './gridHtmlRows'; import type { GridRowsOptions } from './gridRowsOptions'; import { GridRowsDiagramTree } from './rows-diagram/gridRowsDiagramTree'; +import { captureDomInvalidCellKeys } from './rows-validation-dom/cell-helpers'; import { GridRowsDomValidator } from './rows-validation-dom/gridRowsDomValidator'; import type { GridRowsBugs } from './rows-validation/bugs'; import { gridRowsBugs } from './rows-validation/bugs'; @@ -43,11 +44,12 @@ export class GridRows { #indexMap: Map, number> | null = null; #displayedRowsSet: Set> | null = null; #editingCells: EditingCellPosition[] | null = null; - #editingRowIndices: Set | null = null; - #activelyEditingRowIndices: Set | null = null; - #batchPendingRowIndices: Set | null = null; + #editingRowKeys: Set | null = null; + #activelyEditingRowKeys: Set | null = null; + #batchPendingRowKeys: Set | null = null; #editingCellKeys: Set | null = null; #activeEditorCellKeys: Set | null = null; + #invalid: { model: Set; dom: Set; rows: Set } | null = null; readonly #detailGridRows: Map | GridApi, GridRows>; #rowSpanMap: Map | null = null; @@ -159,38 +161,40 @@ export class GridRows { return (this.#editingCells ??= this.api.getEditingCells?.() ?? []); } - /** Checks if a row (by rowIndex) has any editing or changed cells. */ + /** Checks if a row has any editing or changed cells. */ public isRowEditing(row: RowNode): boolean { - const indices = (this.#editingRowIndices ??= new Set(this.getEditingCells().map((c) => c.rowIndex))); - return indices.has(row.rowIndex!); + const keys = (this.#editingRowKeys ??= new Set( + this.getEditingCells().map((c) => rowKey(c.rowIndex, c.rowPinned)) + )); + return keys.has(rowKey(row.rowIndex, row.rowPinned)); } /** Checks if a row has any cell with an active editor (state === 'editing'). */ public isRowActivelyEditing(row: RowNode): boolean { - const indices = (this.#activelyEditingRowIndices ??= new Set( + const keys = (this.#activelyEditingRowKeys ??= new Set( this.getEditingCells() .filter((c) => c.state === 'editing') - .map((c) => c.rowIndex) + .map((c) => rowKey(c.rowIndex, c.rowPinned)) )); - return indices.has(row.rowIndex!); + return keys.has(rowKey(row.rowIndex, row.rowPinned)); } /** Checks if a row has any cell with a batch pending change (state !== 'editing'). */ public isRowBatchPending(row: RowNode): boolean { - const indices = (this.#batchPendingRowIndices ??= new Set( + const keys = (this.#batchPendingRowKeys ??= new Set( this.getEditingCells() .filter((c) => c.state !== 'editing') - .map((c) => c.rowIndex) + .map((c) => rowKey(c.rowIndex, c.rowPinned)) )); - return indices.has(row.rowIndex!); + return keys.has(rowKey(row.rowIndex, row.rowPinned)); } /** Checks if a specific cell is being edited or has pending changes. */ public isCellEditing(row: RowNode, colId: string): boolean { const keys = (this.#editingCellKeys ??= new Set( - this.getEditingCells().map((c) => `${c.rowIndex}:${c.rowPinned ?? ''}:${c.colId}`) + this.getEditingCells().map((c) => cellKey(c.rowIndex, c.rowPinned, c.colId)) )); - return keys.has(`${row.rowIndex}:${row.rowPinned ?? ''}:${colId}`); + return keys.has(cellKey(row.rowIndex, row.rowPinned, colId)); } /** Checks if a specific cell has an active editor (state === 'editing', not just 'changed'). */ @@ -198,9 +202,55 @@ export class GridRows { const keys = (this.#activeEditorCellKeys ??= new Set( this.getEditingCells() .filter((c) => c.state === 'editing') - .map((c) => `${c.rowIndex}:${c.rowPinned ?? ''}:${c.colId}`) + .map((c) => cellKey(c.rowIndex, c.rowPinned, c.colId)) )); - return keys.has(`${row.rowIndex}:${row.rowPinned ?? ''}:${colId}`); + return keys.has(cellKey(row.rowIndex, row.rowPinned, colId)); + } + + /** + * Cell keys (`rowIndex:rowPinned:colId`) currently failing cell-level validation. Read, never run: a + * snapshot that validated would repopulate the model and refresh the DOM under the test. + */ + #invalidCells(): { model: Set; dom: Set; rows: Set } { + if (this.#invalid) { + return this.#invalid; + } + const model = new Set(); + const rows = new Set(); + let dom = new Set(); + if (this.checkEditState) { + for (const err of this.api.getEditValidationErrors?.() ?? []) { + if (err.messages?.length) { + model.add(cellKey(err.rowIndex, err.rowPinned, err.column.getColId())); + rows.add(rowKey(err.rowIndex, err.rowPinned)); + } + } + const gridElement = getGridHTMLElement(this.api); + if (gridElement) { + dom = captureDomInvalidCellKeys(gridElement); + } + } + this.#invalid = { model, dom, rows }; + return this.#invalid; + } + + /** Whether a specific cell currently has a cell-level validation error. */ + public isCellInvalid(row: RowNode, colId: string): boolean { + return this.#invalidCells().model.has(cellKey(row.rowIndex, row.rowPinned, colId)); + } + + /** + * Whether the cell's editor shows an invalid DOM marker after the validation pass. The DOM + * validator cross-checks this against {@link isCellInvalid} to catch a model error whose editor + * exposes no markable validation element. + */ + public isCellDomInvalid(row: RowNode, colId: string): boolean { + return this.#invalidCells().dom.has(cellKey(row.rowIndex, row.rowPinned, colId)); + } + + /** Whether any cell in the row currently has a cell-level validation error. */ + public isRowInvalid(row: RowNode): boolean { + return this.#invalidCells().rows.has(rowKey(row.rowIndex, row.rowPinned)); } public loadErrors(): this { diff --git a/testing/behavioural/src/test-utils/gridRows/rows-diagram/formatting.ts b/testing/behavioural/src/test-utils/gridRows/rows-diagram/formatting.ts index f05fec38eda..23cdc8a12c1 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-diagram/formatting.ts +++ b/testing/behavioural/src/test-utils/gridRows/rows-diagram/formatting.ts @@ -149,6 +149,10 @@ export function formatRowColumns( result += ' ' + diagramColumnId + ':' + serialiseValue(value); } + if (checkEditState && gridRows.isCellInvalid(row, columnId)) { + result += '❌'; + } + result += gridRows.rowSpanMarker(row, columnId); const colDef = column.getColDef(); diff --git a/testing/behavioural/src/test-utils/gridRows/rows-diagram/nodeInfo.ts b/testing/behavioural/src/test-utils/gridRows/rows-diagram/nodeInfo.ts index 49f6d4985a4..50b77401aec 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-diagram/nodeInfo.ts +++ b/testing/behavioural/src/test-utils/gridRows/rows-diagram/nodeInfo.ts @@ -85,6 +85,9 @@ export function getRowStateFlags(gridRows: GridRows, row: RowNode): string { if (gridRows.isRowBatchPending(row)) { result += ' ⏳'; } + if (gridRows.isRowInvalid(row)) { + result += ' ❌'; + } } return result; } diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/cell-helpers.ts b/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/cell-helpers.ts index 06065a6a1fb..9150527535d 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/cell-helpers.ts +++ b/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/cell-helpers.ts @@ -1,3 +1,5 @@ +import { cellKey, parseRowElement } from '../gridHtmlRows'; + export const AUTO_GROUP_COL_ID = 'ag-Grid-AutoColumn'; export function isAutoGroupColumn(columnId: string): boolean { @@ -45,3 +47,76 @@ export function getGroupRowsActualText(wrapper: HTMLElement): string { const childCount = wrapper.querySelector('.ag-group-child-count')?.textContent?.trim() ?? ''; return value && childCount ? `${value} ${childCount}` : value || childCount; } + +/** + * The inline editor's text-like input, matching custom editors that render a bare input with no + * `ag-cell-editor` wrapper. Checkboxes carry no text value (see `validateCheckboxCell`), and a popup + * editor's input lives outside the cell, so both yield null. + */ +export function findEditorInput(cellElement: HTMLElement): HTMLInputElement | HTMLTextAreaElement | null { + return cellElement.querySelector( + 'input:not([type="checkbox"]):not([type="radio"]), textarea' + ); +} + +/** + * Whether `input` is a built-in AG editor input, whose `.value` tracks the editor's `getValue()`. + * A custom composite editor may render several arbitrary inputs with no such guarantee, so the + * DOM value cross-check is limited to these (invalid-marker checks still cover custom editors). + */ +export function isAgEditorInput(input: HTMLInputElement | HTMLTextAreaElement): boolean { + return input.classList.contains('ag-input-field-input') || input.classList.contains('ag-large-text-input'); +} + +/** + * Whether the cell's editor advertises an invalid state in the DOM (`aria-invalid="true"`, + * `setCustomValidity`, or the `invalid` class). Every control is inspected, not just the first: + * a composite editor may carry the custom-validity message on a later one. + */ +export function cellShowsInvalid(cellElement: HTMLElement): boolean { + if ( + cellElement.matches('[aria-invalid="true"], .invalid') || + cellElement.querySelector('[aria-invalid="true"], .invalid') + ) { + return true; + } + // customError only: native constraints (the number editor's own min/max params, pattern, required) + // fill validationMessage on their own, with no grid validation pass behind it. + const controls = cellElement.querySelectorAll( + 'input, textarea, select' + ); + for (let i = 0, len = controls.length; i < len; ++i) { + if (controls[i].validity.customError) { + return true; + } + } + return false; +} + +/** + * Cell keys (`rowIndex:rowPinned:colId`) whose editor shows an invalid DOM marker. Read after the + * validation pass has applied its markers, so a cell validated for the first time is included; a + * model error with no marker here then means the editor exposes no markable validation element. + * Inline editors only — a popup's markers render outside the cell, with nothing tying them back. + */ +export function captureDomInvalidCellKeys(gridElement: HTMLElement): Set { + const keys = new Set(); + // As in getGridRowsHtmlElements: a nested detail grid renders inside this element, and its row + // indexes collide with this grid's. + const gridRoot = gridElement.querySelector('.ag-root-wrapper'); + // Iterate the actively-editing cells (not just AG editor inputs) so custom editors are covered. + const cells = gridElement.querySelectorAll('.ag-cell-inline-editing[col-id]'); + for (let i = 0, len = cells.length; i < len; ++i) { + const cellElement = cells[i]; + const rowElement = cellElement.closest('[row-index]'); + if (!rowElement || !cellShowsInvalid(cellElement)) { + continue; + } + if (gridRoot && rowElement.closest('.ag-root-wrapper') !== gridRoot) { + continue; + } + const { rowIndex, pinned } = parseRowElement(rowElement); + keys.add(cellKey(rowIndex, pinned, cellElement.getAttribute('col-id')!)); + } + return keys; +} diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/gridRowDomCellValidator.ts b/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/gridRowDomCellValidator.ts index 50d7fc0ba0d..ac6da3b1c3e 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/gridRowDomCellValidator.ts +++ b/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/gridRowDomCellValidator.ts @@ -9,9 +9,11 @@ import { cellValueMismatchMsg, combineGroupValue, findCellElement, + findEditorInput, findGroupRowsWrapper, getGroupRowsActualText, hasSuppressCount, + isAgEditorInput, isAutoGroupColumn, } from './cell-helpers'; @@ -445,13 +447,33 @@ export class GridRowDomCellValidator { column: Column, rowErrors: GridRowErrors ): void { - const input = cellElement.querySelector( - '.ag-cell-editor input.ag-input-field-input, .ag-cell-editor textarea' - ); - if (!input) { - // Popup/custom editor — input lives outside the cell. + const columnId = column.getColId(); + const modelInvalid = this.gridRows.isCellInvalid(row, columnId); + + // A popup editor's markers live in the popup, which carries nothing tying it back to this cell. + if (!cellElement.classList.contains('ag-cell-popup-editing')) { + const domInvalid = this.gridRows.isCellDomInvalid(row, columnId); + rowErrors.add( + modelInvalid && + !domInvalid && + `Cell id:"${columnId}" has a validation error in the model but no invalid marker in the DOM (expected aria-invalid="true", a custom validity message, or the "invalid" class)` + ); + rowErrors.add( + !modelInvalid && + domInvalid && + `Cell id:"${columnId}" shows an invalid marker in the DOM but the model reports no validation error` + ); + } + + // An invalid cell deliberately keeps the rejected value in the editor while the model holds the + // last valid one, so the two diverge on purpose and only the markers above are comparable. + const input = modelInvalid ? null : findEditorInput(cellElement); + // A custom composite editor's getValue() need not map to any single input, so only compare + // the DOM value against the model for built-in AG editor inputs. + if (!input || !isAgEditorInput(input)) { return; } + const editValue = this.api.getCellValue({ rowNode: row, colKey: column, useFormatter: false, from: 'edit' }); const expectedForms = editValueAlternatives(editValue); const actualStr = input.value ?? ''; @@ -461,7 +483,6 @@ export class GridRowDomCellValidator { return; } - const columnId = column.getColId(); rowErrors.add( !expectedForms.includes(actualStr) && `Editor input value mismatch for column id:"${columnId}", expected one of ${JSON.stringify(expectedForms)}, got ${JSON.stringify(actualStr)}` diff --git a/testing/behavioural/src/test-utils/widgets/dropdowns.ts b/testing/behavioural/src/test-utils/widgets/dropdowns.ts index 1f711e5075f..b52a6a7fc1e 100644 --- a/testing/behavioural/src/test-utils/widgets/dropdowns.ts +++ b/testing/behavioural/src/test-utils/widgets/dropdowns.ts @@ -1,3 +1,5 @@ +import { waitFor } from '@testing-library/dom'; + import { asyncSetTimeout } from '../node-utils'; import { firePointerLikeClick } from '../test-utils-events'; @@ -54,16 +56,20 @@ export async function clickSelectOption(label: string, root: ParentNode = docume * a plain synthetic click has clientY 0 and always hits row 0. Assumes the list isn't scrolled. */ export async function selectRichSelectRow(label: string, root: ParentNode = document): Promise { - nudgeVirtualList(RICH_SELECT_VIEWPORT, root); - await asyncSetTimeout(0); - - const rows = Array.from(root.querySelectorAll('.ag-rich-select-row')); - const index = rows.findIndex((r) => r.textContent?.trim() === label); - if (index < 0) { - throw new Error( - `AgRichSelect row "${label}" not found. Available: ${rows.map((r) => r.textContent?.trim()).join(', ')}` - ); - } + let rows: HTMLElement[] = []; + let index = -1; + // Polled, not awaited once: the picker mounts its list a macrotask after the click, and the rows + // only exist once the nudge has run against the mounted viewport. + await waitFor(() => { + nudgeVirtualList(RICH_SELECT_VIEWPORT, root); + rows = Array.from(root.querySelectorAll('.ag-rich-select-row')); + index = rows.findIndex((r) => r.textContent?.trim() === label); + if (index < 0) { + throw new Error( + `AgRichSelect row "${label}" not found. Available: ${rows.map((r) => r.textContent?.trim()).join(', ')}` + ); + } + }); const list = root.querySelector('.ag-rich-select-list'); const listTop = list?.getBoundingClientRect().top ?? 0; diff --git a/testing/behavioural/src/toolbar/toolbar-action-button.test.ts b/testing/behavioural/src/toolbar/toolbar-action-button.test.ts index 852b0a04cb1..3bfcd3e19be 100644 --- a/testing/behavioural/src/toolbar/toolbar-action-button.test.ts +++ b/testing/behavioural/src/toolbar/toolbar-action-button.test.ts @@ -278,10 +278,10 @@ describe('Toolbar action button item with column chooser', () => { }); await new GridColumns(api, `pressing Escape from column chooser returns focus to the toolbar button setup`) .checkColumns(` - CENTER - ├── name "Name" width:200 - └── country "Country" width:200 - `); + CENTER + ├── name "Name" width:200 + └── country "Country" width:200 + `); await new GridRows(api, `pressing Escape from column chooser returns focus to the toolbar button setup`).check(` ROOT id:ROOT_NODE_ID └── LEAF id:0 name:"Alice" country:"Ireland" @@ -306,8 +306,8 @@ describe('Toolbar action button item with column chooser', () => { expect(document.activeElement).toBe(button); await new GridRows(api, `pressing Escape from column chooser returns focus to the toolbar button final state`) .check(` - ROOT id:ROOT_NODE_ID - └── LEAF id:0 name:"Alice" country:"Ireland" - `); + ROOT id:ROOT_NODE_ID + └── LEAF id:0 name:"Alice" country:"Ireland" + `); }); }); From c1fe3178742415db478980d60b19a1b294881f10 Mon Sep 17 00:00:00 2001 From: Salvatore Previti Date: Wed, 29 Jul 2026 11:35:10 +0100 Subject: [PATCH 2/8] AG-16216-adv-filter-fix2 (#14651) --- .../autocomplete/agAutocomplete.ts | 5 +- .../advanced-filter-header-dom.test.ts | 119 ++++++++++++++++++ .../af-autocomplete.test.ts | 27 ++++ 3 files changed, 148 insertions(+), 3 deletions(-) diff --git a/packages/ag-grid-enterprise/src/advancedFilter/autocomplete/agAutocomplete.ts b/packages/ag-grid-enterprise/src/advancedFilter/autocomplete/agAutocomplete.ts index 40cb8cf2352..b82c1814a27 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/autocomplete/agAutocomplete.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/autocomplete/agAutocomplete.ts @@ -1,5 +1,5 @@ import type { AgComponentPopupPositionParams } from 'ag-stack'; -import { RefPlaceholder, _isNothingFocused, _makeNull } from 'ag-stack'; +import { RefPlaceholder, _makeNull } from 'ag-stack'; import type { AgEvent, @@ -225,8 +225,7 @@ export class AgAutocomplete extends Component { } private setCaret(position: number, setFocus?: boolean): void { - if (setFocus || _isNothingFocused(this.beans)) { - // clicking on the list loses focus, so restore + if (setFocus) { this.eAutocompleteInput.getFocusableElement().focus(); } const eInput = this.eAutocompleteInput.getInputElement(); diff --git a/testing/behavioural/src/filters/advanced-filter-header-dom.test.ts b/testing/behavioural/src/filters/advanced-filter-header-dom.test.ts index e1aa90e333a..c8f8567e221 100644 --- a/testing/behavioural/src/filters/advanced-filter-header-dom.test.ts +++ b/testing/behavioural/src/filters/advanced-filter-header-dom.test.ts @@ -139,6 +139,11 @@ describe('Advanced Filter Header DOM', () => { { field: 'age', filter: true }, ]; + const athletes = [ + { athlete: 'Michael Phelps', age: 23 }, + { athlete: 'Usain Bolt', age: 25 }, + ]; + test('input stays enabled while the grid is still waiting for row data', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: filterableColumnDefs, @@ -253,4 +258,118 @@ describe('Advanced Filter Header DOM', () => { └── LEAF id:0 athlete:"Michael Phelps" age:23 `); }); + + test('does not focus the input when the grid initialises without row data', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: filterableColumnDefs, + enableAdvancedFilter: true, + }); + await asyncSetTimeout(0); + + await new FilterDom(api, 'init without row data').checkFilterDom(` + ADVANCED FILTER + input: "" + valid: true + buttons: Apply ⊘ | Builder + model: null + `); + await new GridRows(api, 'init without row data').check(` + ROOT id:ROOT_NODE_ID + `); + + expect(document.activeElement).toBe(document.body); + }); + + test('does not focus the input when the grid initialises with row data', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: filterableColumnDefs, + rowData: athletes, + enableAdvancedFilter: true, + }); + await asyncSetTimeout(0); + + await new FilterDom(api, 'init with row data').checkFilterDom(` + ADVANCED FILTER + input: "" + valid: true + buttons: Apply ⊘ | Builder + model: null + `); + await new GridRows(api, 'init with row data').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Michael Phelps" age:23 + └── LEAF id:1 athlete:"Usain Bolt" age:25 + `); + + expect(document.activeElement).toBe(document.body); + }); + + test('does not focus the input when the model is set via the API', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: filterableColumnDefs, + rowData: athletes, + enableAdvancedFilter: true, + }); + await asyncSetTimeout(0); + + api.setAdvancedFilterModel({ filterType: 'text', colId: 'athlete', type: 'contains', filter: 'Bolt' }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + await new FilterDom(api, 'model set via API').checkFilterDom(` + ADVANCED FILTER + input: "[Athlete] contains "Bolt"" + valid: true + buttons: Apply ⊘ | Builder + model: + filterType: "text" + colId: "athlete" + type: "contains" + filter: "Bolt" + `); + await new GridRows(api, 'model set via API').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 athlete:"Usain Bolt" age:25 + `); + + expect(document.activeElement).toBe(document.body); + }); + + test('restores focus to the input when cleared via the clear button', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: filterableColumnDefs, + rowData: athletes, + enableAdvancedFilter: true, + advancedFilterParams: { buttons: ['apply', 'clear'] }, + }); + await asyncSetTimeout(0); + + const harness = AdvancedFilterHarness.get(api); + await harness.applyExpression('[Athlete] contains "Bolt"'); + await asyncSetTimeout(0); + + const eClear = document.querySelector('.ag-advanced-filter-buttons [data-ref=clearFilterButton]')!; + eClear.focus(); + eClear.click(); + await asyncSetTimeout(0); + + // Clear only empties the editor - the applied model (and the filtered rows) stay until Apply. + await new FilterDom(api, 'cleared via clear button').checkFilterDom(` + ADVANCED FILTER + input: "" + valid: true + buttons: Apply | Clear | Builder + model: + filterType: "text" + colId: "athlete" + type: "contains" + filter: "Bolt" + `); + await new GridRows(api, 'cleared via clear button').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 athlete:"Usain Bolt" age:25 + `); + + expect(document.activeElement).toBe(harness.input); + }); }); diff --git a/testing/behavioural/src/filters/advanced-filter-regression/af-autocomplete.test.ts b/testing/behavioural/src/filters/advanced-filter-regression/af-autocomplete.test.ts index fa716438e7f..7582dca02f9 100644 --- a/testing/behavioural/src/filters/advanced-filter-regression/af-autocomplete.test.ts +++ b/testing/behavioural/src/filters/advanced-filter-regression/af-autocomplete.test.ts @@ -84,6 +84,33 @@ describe('Advanced Filter — autocomplete completion & editing', () => { expect(af.isAutocompleteOpen()).toBe(false); }); + test('selecting an entry with the mouse completes it and leaves the caret in the input', async () => { + const api = await gridsManager.createGridAndWait('grid1', OPTS); + const af = AdvancedFilterHarness.get(api); + + af.input.focus(); + await af.type('[Ath'); + expect(af.autocompleteEntries()).toEqual(['Athlete']); + + const eRow = document.querySelector('.ag-autocomplete-list .ag-autocomplete-row')!; + const mousedown = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + eRow.dispatchEvent(mousedown); + // The list must swallow the default action, otherwise the browser would blur the input and close the list. + expect(mousedown.defaultPrevented).toBe(true); + eRow.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await asyncSetTimeout(0); + + expect(document.activeElement).toBe(af.input); + await new FilterDom(api, 'entry selected with the mouse').checkFilterDom(` + ADVANCED FILTER + input: "[Athlete] " + valid: false — Expression has an error. Option is missing at end of expression. + buttons: Apply ⊘ | Builder + model: null + `); + await new GridRows(api, 'entry selected with the mouse').check(UNFILTERED); + }); + test('an inherited property name in filterOptions is not offered as an operator', async () => { const api = await gridsManager.createGridAndWait('grid1', { columnDefs: [ From b72cff6d86e616fed3e9a400a9a8d84fa50085b0 Mon Sep 17 00:00:00 2001 From: David Skewis Date: Wed, 29 Jul 2026 11:52:46 +0100 Subject: [PATCH 3/8] AG-17860 improve md dropdown styles (#14652) --- .../docs/components/Header.module.scss | 6 -- .../MarkdownActions.module.scss | 70 +++++++++++++------ .../markdown-actions/MarkdownActions.tsx | 2 +- 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss b/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss index 69a57631a59..5b135936b4e 100644 --- a/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss +++ b/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss @@ -7,10 +7,6 @@ .docsPageTitle { margin-bottom: $spacing-size-1; - span:nth-of-type(2) { - letter-spacing: -1.2px; - } - @media screen and (min-width: $breakpoint-docs-nav-medium) { line-height: var(--line-height-ultra-tight); } @@ -60,8 +56,6 @@ .version { font-size: var(--text-fs-sm); font-weight: var(--text-regular); - // The header's nth-of-type span rule would otherwise tighten this. - letter-spacing: normal; color: var(--color-text-tertiary); } diff --git a/external/ag-website-shared/src/components/markdown-actions/MarkdownActions.module.scss b/external/ag-website-shared/src/components/markdown-actions/MarkdownActions.module.scss index e47f981a380..1328a90c601 100644 --- a/external/ag-website-shared/src/components/markdown-actions/MarkdownActions.module.scss +++ b/external/ag-website-shared/src/components/markdown-actions/MarkdownActions.module.scss @@ -1,7 +1,7 @@ @use 'design-system' as *; -// Matches the height and border treatment of the shared Select trigger (see -// Select.module.scss) so this sits alongside one as a single control group. +// Matches the height, spacing and border treatment of the shared Select trigger +// (see Select.module.scss) so this sits alongside one as a single control group. // The segments carry their own corner radii rather than the container clipping // them, so the focus ring on the chevron is not cut off. $segment-radius: calc(var(--radius-sm) - 1px); @@ -13,6 +13,8 @@ $segment-radius: calc(var(--radius-sm) - 1px); border: 1px solid var(--color-input-border); border-radius: var(--radius-sm); background-color: var(--color-bg-primary); + box-shadow: var(--shadow-xs); + transition: border $transition-default-timing; &:hover { border-color: var(--color-input-border-hover); @@ -23,23 +25,37 @@ $segment-radius: calc(var(--radius-sm) - 1px); } } -.primaryAction { - display: flex; - align-items: center; - padding: 0 $spacing-size-3; +@mixin segment { border: none; - border-radius: $segment-radius 0 0 $segment-radius; background-color: transparent; - font-size: var(--text-fs-base); - font-weight: var(--text-regular); + box-shadow: none; color: var(--color-fg-primary); - white-space: nowrap; - // Re-assert the colour, otherwise the global button hover style fades the label. - &:hover { + &:hover, + &:active { background-color: var(--color-bg-secondary); color: var(--color-fg-primary); } + + &:focus-visible { + #{$selector-darkmode} & { + box-shadow: + 0 0 0 $spacing-size-1 color-mix(in srgb, var(--color-brand-200), var(--color-bg-primary) 60%), + var(--shadow-xs); + } + } +} + +.primaryAction { + @include segment; + + display: flex; + align-items: center; + padding: 0 $spacing-size-4; + border-radius: $segment-radius 0 0 $segment-radius; + font-size: var(--text-fs-base); + font-weight: var(--text-regular); + white-space: nowrap; } // Stacks the live label over a hidden copy of the longest one, so the button @@ -57,21 +73,19 @@ $segment-radius: calc(var(--radius-sm) - 1px); } .trigger { + @include segment; + display: flex; align-items: center; justify-content: center; - width: 28px; - padding: 0; - border: none; + padding: 0 $spacing-size-3; border-left: 1px solid var(--color-input-border); border-radius: 0 $segment-radius $segment-radius 0; - background-color: transparent; - color: var(--color-fg-primary); - // Re-assert the colour, otherwise the global button hover style fades the chevron. - &:hover { + // The open menu takes pointer events, so the segment stops matching :hover while + // it is the thing that opened it. Hold the tint until the menu closes. + &[data-state='open'] { background-color: var(--color-bg-secondary); - color: var(--color-fg-primary); } } @@ -97,6 +111,21 @@ $segment-radius: calc(var(--radius-sm) - 1px); background: color-mix(in srgb, var(--color-bg-primary), var(--color-fg-white) 12%); border: 1px solid color-mix(in srgb, var(--color-bg-primary), var(--color-fg-white) 22%); } + + &[data-state='open'] { + animation: menuScaleInDown 150ms cubic-bezier(0.4, 0, 1, 1); + } +} + +@keyframes menuScaleInDown { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0px) scale(1); + } } .item { @@ -106,7 +135,6 @@ $segment-radius: calc(var(--radius-sm) - 1px); padding: 0 12px; border-radius: 3px; font-size: var(--text-fs-base); - // Pinned so the "View as Markdown" link is not bolder than its sibling items. font-weight: var(--text-regular); line-height: 1; color: var(--color-text-primary); diff --git a/external/ag-website-shared/src/components/markdown-actions/MarkdownActions.tsx b/external/ag-website-shared/src/components/markdown-actions/MarkdownActions.tsx index 41d982127f9..0facab47bda 100644 --- a/external/ag-website-shared/src/components/markdown-actions/MarkdownActions.tsx +++ b/external/ag-website-shared/src/components/markdown-actions/MarkdownActions.tsx @@ -94,7 +94,7 @@ export const MarkdownActions: FunctionComponent = ({ markdownHref }) => { - + View as Markdown From a8e0dc175a9d6d1c858f10646ec55f5fc44271a5 Mon Sep 17 00:00:00 2001 From: Tak Tran Date: Wed, 29 Jul 2026 12:05:50 +0100 Subject: [PATCH 4/8] AG-17996 - Add consent fields to trial licence and contact forms (#14646) * AG-17996 - remove unused trial licence form variants Only the allFields variant was ever rendered: no call site passed a `type` to the TrialLicenceForm dispatcher, so DEFAULT_TRIAL_LICENSE_FORM ('allFields') always won and the emailOnly/original variants were dead. Promote the allFields implementation to TrialLicenceForm and drop the dispatcher along with the now-unused TrialLicenceFormType and DEFAULT_TRIAL_LICENSE_FORM constants. * AG-17996 - add consent fields to trial licence and contact forms Adds the three web-to-lead consent checkboxes to the trial licence and contact forms: - Data Processing Consent - required to submit - Email Marketing Consent - optional - Email Tracking Consent - optional Copy and layout live in a shared ConsentCheckbox component so the wording stays identical across both forms. The contact form posts natively to Salesforce web-to-lead, so its checkboxes are named by Salesforce field ID via CONSENT_FIELD_IDS; the trial form sends the three values in its JSON payload. Email Tracking Consent is shown to everyone for now - the France/Italy geolocation gating will follow separately. * AG-17996 - gate email tracking consent behind a France/Italy self-declaration Email tracking consent only applies to France and Italy. Until IP geolocation is in place, visitors declare their own location: an optional "Please check this box if you live in France or Italy" checkbox reveals the tracking consent checkbox beneath it. Unchecking the declaration also withdraws the tracking consent, so a consent the visitor can no longer see is never submitted, and re-checking always brings the box back unchecked - the legislation requires the opt-in to be unchecked and freely given. * AG-17996 - submit the France/Italy declaration to Salesforce Salesforce now has a field for the France/Italy self-declaration, so the flag is submitted rather than being UI-only. This closes the gap raised in the ticket comments: Salesforce can now tell a French/Italian visitor who declined email tracking from a visitor the checkbox was never shown to. The contact form names the checkbox by Salesforce field ID; the trial form sends it as franceOrItaly in its JSON payload alongside the other consents. * AG-14065 - add trial licence form loading state Applies the changes from PR #14649 onto this branch. - Spinner in the submit button while the request is in flight, with aria-busy for assistive tech and a slowed animation under prefers-reduced-motion - Defines .submit, which the button already referenced but which was never declared in the stylesheet - Drops the styles.isHidden toggle from the field error paragraphs. That class was never declared either, so the toggle was a no-op - visibility comes from .input-error .error. Verified behaviour is unchanged. The three per-variant copies in #14649 collapse into TrialLicenceForm here, since the emailOnly/original variants were removed earlier on this branch. The Studio variant is updated to match. * AG-17996 - Update living in France/Italy text * AG-17996 - Remove redundant privacy message on trial license forms --- documentation/ag-grid-docs/src/constants.ts | 2 - .../ConsentCheckbox.module.scss | 25 + .../consent-fields/ConsentCheckbox.tsx | 26 + .../consent-fields/consentMessages.tsx | 26 + .../contact-form/ContactForm.module.scss | 15 +- .../components/contact-form/ContactForm.tsx | 70 ++- .../TrialLicenceForm.module.scss | 36 +- .../trial-licence-form/TrialLicenceForm.tsx | 461 +++++++++++++++++- .../TrialLicenceFormAllFields.tsx | 323 ------------ .../TrialLicenceFormEmailOnly.tsx | 228 --------- .../TrialLicenceFormOriginal.tsx | 315 ------------ .../TrialLicenceFormStudio.tsx | 24 +- external/ag-website-shared/src/constants.ts | 19 + 13 files changed, 650 insertions(+), 920 deletions(-) create mode 100644 external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.module.scss create mode 100644 external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.tsx create mode 100644 external/ag-website-shared/src/components/consent-fields/consentMessages.tsx delete mode 100644 external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormAllFields.tsx delete mode 100644 external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormEmailOnly.tsx delete mode 100644 external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormOriginal.tsx diff --git a/documentation/ag-grid-docs/src/constants.ts b/documentation/ag-grid-docs/src/constants.ts index 1cdbc2f4314..e8ebe511fd0 100644 --- a/documentation/ag-grid-docs/src/constants.ts +++ b/documentation/ag-grid-docs/src/constants.ts @@ -196,8 +196,6 @@ export const DEBUG_SCRIPT_FILE_NAME = 'ag-grid-debug.js'; export const PRODUCTION_CHANGELOG_JSON_URL = 'https://www.ag-grid.com/changelog/changelog.json'; export const ZI_FORM_ID = 'aad0527d-5af6-4263-8dcd-60f3ac998d5d'; -export type TrialLicenceFormType = 'emailOnly' | 'allFields' | 'original'; -export const DEFAULT_TRIAL_LICENSE_FORM: TrialLicenceFormType = 'allFields'; // Google Tag Manager export const PUBLIC_GTM_ID = import.meta.env?.PUBLIC_GTM_ID; diff --git a/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.module.scss b/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.module.scss new file mode 100644 index 00000000000..e351c55edcc --- /dev/null +++ b/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.module.scss @@ -0,0 +1,25 @@ +@use 'design-system' as *; + +.consentLabel { + display: flex; + align-items: flex-start; + gap: $spacing-size-2; + cursor: pointer; + + input[type='checkbox'] { + // Keep the box aligned with the first line of the label text + flex-shrink: 0; + margin-top: 1px; + } +} + +.errorContainer { + display: flex; + align-items: flex-start; + margin-left: 1.75rem; +} + +// Indents a checkbox that is only revealed by the one above it +.nested { + margin-left: 1.75rem; +} diff --git a/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.tsx b/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.tsx new file mode 100644 index 00000000000..d37868a1144 --- /dev/null +++ b/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.tsx @@ -0,0 +1,26 @@ +import classnames from 'classnames'; +import type { ComponentPropsWithRef, FunctionComponent, ReactNode } from 'react'; + +import styles from './ConsentCheckbox.module.scss'; + +interface Props { + id: string; + label: ReactNode; + /** Validation message; presence of a message puts the field into its error state */ + error?: string; + /** Indents the field, for a checkbox only revealed by the one above it */ + nested?: boolean; + inputProps: ComponentPropsWithRef<'input'>; +} + +export const ConsentCheckbox: FunctionComponent = ({ id, label, error, nested, inputProps }: Props) => { + return ( +
+ +
{error &&

{error}

}
+
+ ); +}; diff --git a/external/ag-website-shared/src/components/consent-fields/consentMessages.tsx b/external/ag-website-shared/src/components/consent-fields/consentMessages.tsx new file mode 100644 index 00000000000..2058913dc69 --- /dev/null +++ b/external/ag-website-shared/src/components/consent-fields/consentMessages.tsx @@ -0,0 +1,26 @@ +import { PRIVACY_POLICY_URL } from '@ag-website-shared/constants'; +import type { ReactElement } from 'react'; + +/** + * Consent copy shared by every web-to-lead form, so the wording stays identical + * across the site. See AG-17996. + */ +export const CONSENT_LABELS: Record = { + dataProcessing: ( + <> + I agree to my data being processed in accordance with the{' '} +
+ Privacy Policy + + + ), + marketingEmail: "I'd like to receive product updates and marketing emails", + emailTracking: 'I consent to tracking of email opens and clicks', + /** + * Temporary stand-in for IP geolocation: email tracking consent only applies to + * France and Italy, so visitors declare it themselves to reveal that checkbox. + */ + franceOrItaly: 'I live in France or Italy', +}; + +export const DATA_PROCESSING_CONSENT_REQUIRED = 'You must agree to the Privacy Policy to continue'; diff --git a/external/ag-website-shared/src/components/contact-form/ContactForm.module.scss b/external/ag-website-shared/src/components/contact-form/ContactForm.module.scss index cb008677519..f105710248b 100644 --- a/external/ag-website-shared/src/components/contact-form/ContactForm.module.scss +++ b/external/ag-website-shared/src/components/contact-form/ContactForm.module.scss @@ -53,15 +53,10 @@ font-family: inherit; } -.privacyMessage { - font-size: var(--text-fs-xs); - line-height: var(--text-lh-xs); - color: color-mix(in srgb, var(--color-text-secondary), var(--color-bg-primary) 30%); - margin: 0; - - a { - font-weight: var(--text-regular); - } +.consents { + display: flex; + flex-direction: column; + gap: $spacing-size-2; } .tertiaryButton { @@ -73,6 +68,8 @@ } .errorContainer { + display: flex; + align-items: flex-start; margin-top: 0.25rem; &:empty { diff --git a/external/ag-website-shared/src/components/contact-form/ContactForm.tsx b/external/ag-website-shared/src/components/contact-form/ContactForm.tsx index f2684ede281..f23f810d5e2 100644 --- a/external/ag-website-shared/src/components/contact-form/ContactForm.tsx +++ b/external/ag-website-shared/src/components/contact-form/ContactForm.tsx @@ -1,10 +1,15 @@ +import { ConsentCheckbox } from '@ag-website-shared/components/consent-fields/ConsentCheckbox'; +import { + CONSENT_LABELS, + DATA_PROCESSING_CONSENT_REQUIRED, +} from '@ag-website-shared/components/consent-fields/consentMessages'; import { initCaptcha } from '@ag-website-shared/components/contact-form/initCaptcha'; import { Icon } from '@ag-website-shared/components/icon/Icon'; -import { CONTACT_FORM_DATA, PRIVACY_POLICY_URL, RECAPTCHA_URL, STUDIO_FORM_DATA } from '@ag-website-shared/constants'; +import { CONSENT_FIELD_IDS, CONTACT_FORM_DATA, RECAPTCHA_URL, STUDIO_FORM_DATA } from '@ag-website-shared/constants'; import { LIBRARY } from '@constants'; import { getIsDev, getIsProduction } from '@utils/env'; import classnames from 'classnames'; -import type { FunctionComponent } from 'react'; +import type { ChangeEvent, FunctionComponent } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -25,6 +30,10 @@ const { captchaSettingsKeyName, } = getIsProduction() ? contactFormData.production : contactFormData.default; +const { dataProcessingConsentId, marketingEmailConsentId, emailTrackingConsentId, franceOrItalyId } = getIsProduction() + ? CONSENT_FIELD_IDS.production + : CONSENT_FIELD_IDS.default; + const isDev = getIsDev(); const ENQUIRY_TYPE_OPTIONS = [ @@ -82,14 +91,30 @@ export const ContactForm: FunctionComponent = ({ const [isDisabled, setIsDisabled] = useState(false); const [captchaError, setCaptchaError] = useState(false); + const [isFranceOrItaly, setIsFranceOrItaly] = useState(false); + const { register, handleSubmit, + setValue, formState: { errors }, } = useForm({ mode: 'onBlur', }); + // Email tracking consent only applies to France and Italy, so hiding it must also + // withdraw it — never submit a consent the visitor can no longer see + const handleFranceOrItalyChange = useCallback( + (e: ChangeEvent) => { + const { checked } = e.target; + setIsFranceOrItaly(checked); + if (!checked) { + setValue(emailTrackingConsentId, ''); + } + }, + [setValue] + ); + useEffect(() => { const searchParams = new URLSearchParams(window.location.search); const hasDebugFlag = searchParams.has('debug'); @@ -246,6 +271,44 @@ export const ContactForm: FunctionComponent = ({ )} +
+ + + + + + + {isFranceOrItaly && ( + + )} +
+
@@ -258,9 +321,6 @@ export const ContactForm: FunctionComponent = ({ type="submit" value={submitLabel || 'Send us a message'} /> -

- By submitting this form you agree to our Privacy Policy. -

For technical support, visit our{' '} { - if (type === 'emailOnly') { - return ; - } else if (type === 'allFields') { - return ; - } else { - return ; +type TrialFormState = 'success' | 'error' | 'loading' | 'idle'; + +const getFormErrorMessage = (message: string) => { + let errorMessage = MESSAGES.formErrorDefault; + + if (message === 'invalid arguments provided') { + errorMessage = MESSAGES.formErrorInvalidArguments; + } else if (message.includes('INVALID_EMAIL_ADDRESS')) { + // eg, "Error: Unable to create a lead for a trial LK for email @bc.com. Error: Insert failed. First exception on row 0; first error: INVALID_EMAIL_ADDRESS, Email: invalid email address: @bc.com: [Email]" + errorMessage = MESSAGES.formErrorInvalidEmail; + } else if (message.includes('Duplicate email')) { + // eg, "Error: Unable to create a lead for a trial LK for email something@somewhere.com. Error: Duplicate email" + errorMessage = MESSAGES.formErrorDuplicateEmail; } + + return errorMessage; +}; + +const isEmailValid = (email: string) => { + const emailPattern = /^([a-zA-Z0-9._-]|\+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/; + return emailPattern.test(email); +}; + +const validateEmail = (email: string) => { + let validation = ''; + + if (!email) { + validation = MESSAGES.validationEmailRequired; + } else if (!isEmailValid(email)) { + validation = MESSAGES.validationEmailInvalid; + } + + return validation; +}; + +const validateRequired = (value: string) => { + let validation = ''; + if (value === '') { + validation = MESSAGES.validationRequiredField; + } + + return validation; +}; + +function useEmailValidation(initialValue: string = '') { + const [email, setEmail] = useState(initialValue); + const [emailError, setEmailError] = useState(validateEmail(initialValue)); + + const handleEmailChange: ChangeEventHandler = useCallback((e) => { + const value = e.target?.value; + setEmail(value); + setEmailError(validateEmail(value)); + }, []); + + return { + emailError, + email, + handleEmailChange, + }; +} + +function useRequiredValidation(initialValue: string = '') { + const [value, setValue] = useState(initialValue); + const [valueError, setValueError] = useState(validateRequired(initialValue)); + + const handleValueChange: ChangeEventHandler = useCallback((e) => { + const value = e.target?.value; + setValue(value); + setValueError(validateRequired(value)); + }, []); + + return { + valueError, + value, + handleValueChange, + }; +} + +function useCheckbox(initialValue: boolean = false) { + const [checked, setChecked] = useState(initialValue); + + const handleCheckedChange: ChangeEventHandler = useCallback((e) => { + setChecked(e.target.checked); + }, []); + + return { + checked, + setChecked, + handleCheckedChange, + }; +} + +async function submitTrialLicenceFormData({ + submitUrl = TRIAL_LICENCE_FORM_URL, + firstName, + lastName, + email, + company, + dataProcessingConsent, + marketingEmailConsent, + emailTrackingConsent, + franceOrItaly, +}: { + submitUrl?: string; + firstName: string; + lastName: string; + email: string; + company: string; + dataProcessingConsent: boolean; + marketingEmailConsent: boolean; + emailTrackingConsent: boolean; + franceOrItaly: boolean; +}) { + const response = await fetch(submitUrl, { + method: 'POST', + body: JSON.stringify({ + data: { + firstName, + lastName, + email, + company, + dataProcessingConsent, + marketingEmailConsent, + emailTrackingConsent, + franceOrItaly, + }, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + const json = await response.json(); + + return json; +} + +function useTrialForm({ submitUrl }: Props) { + const [formState, setFormState] = useState('idle'); + const [formError, setFormError] = useState(''); + const [wasValidated, setWasValidated] = useState(false); + const { emailError: validatedEmailError, email, handleEmailChange } = useEmailValidation(); + const emailError = wasValidated && validatedEmailError ? validatedEmailError : ''; + + const { + value: firstName, + valueError: validatedFirstNameError, + handleValueChange: handleFirstNameChange, + } = useRequiredValidation(); + const firstNameError = wasValidated && validatedFirstNameError ? validatedFirstNameError : ''; + + const { + value: lastName, + valueError: validatedLastNameError, + handleValueChange: handleLastNameChange, + } = useRequiredValidation(); + const lastNameError = wasValidated && validatedLastNameError ? validatedLastNameError : ''; + + const { checked: dataProcessingConsent, handleCheckedChange: handleDataProcessingConsentChange } = useCheckbox(); + const dataProcessingConsentError = wasValidated && !dataProcessingConsent ? DATA_PROCESSING_CONSENT_REQUIRED : ''; + + const { checked: marketingEmailConsent, handleCheckedChange: handleMarketingEmailConsentChange } = useCheckbox(); + + const { + checked: emailTrackingConsent, + setChecked: setEmailTrackingConsent, + handleCheckedChange: handleEmailTrackingConsentChange, + } = useCheckbox(); + + const { checked: isFranceOrItaly, setChecked: setIsFranceOrItaly } = useCheckbox(); + + // Email tracking consent only applies to France and Italy, so hiding it must also + // withdraw it — never submit a consent the visitor can no longer see + const handleFranceOrItalyChange: ChangeEventHandler = useCallback((e) => { + const { checked } = e.target; + setIsFranceOrItaly(checked); + if (!checked) { + setEmailTrackingConsent(false); + } + }, []); + + const handleFormSubmit: FormEventHandler = useCallback( + async (e) => { + e.preventDefault(); + setWasValidated(true); + + if (validatedEmailError || validatedFirstNameError || validatedLastNameError || !dataProcessingConsent) { + setFormState('error'); + return; + } + + setFormError(''); + setFormState('loading'); + + const currentPage = window.location.pathname; + + try { + const company = (document.getElementById('company') as HTMLInputElement)?.value || ''; + const response = await submitTrialLicenceFormData({ + submitUrl, + firstName, + lastName, + email, + company, + dataProcessingConsent, + marketingEmailConsent, + emailTrackingConsent, + franceOrItaly: isFranceOrItaly, + }); + + if (response.error) { + setFormState('error'); + const errorMessage = getFormErrorMessage(response.error.message); + setFormError(errorMessage); + trackTrialLicenseFormError({ + error: response.error.message, + errorType: 'api_error', + page: currentPage, + }); + } else { + setFormState('success'); + trackTrialLicenseFormSuccess({ + page: currentPage, + }); + } + } catch (e) { + console.error(e); + const errorMessage = MESSAGES.formErrorDefault; + setFormError(errorMessage); + trackTrialLicenseFormError({ + error: e instanceof Error ? e.message : 'Unknown error', + errorType: 'system_error', + page: currentPage, + }); + setFormState('error'); + } + }, + [ + validatedEmailError, + validatedFirstNameError, + validatedLastNameError, + firstName, + lastName, + email, + dataProcessingConsent, + marketingEmailConsent, + emailTrackingConsent, + isFranceOrItaly, + ] + ); + + return { + formState, + formError, + emailError, + email, + handleEmailChange, + firstName, + firstNameError, + handleFirstNameChange, + lastName, + lastNameError, + handleLastNameChange, + dataProcessingConsent, + dataProcessingConsentError, + handleDataProcessingConsentChange, + marketingEmailConsent, + handleMarketingEmailConsentChange, + emailTrackingConsent, + handleEmailTrackingConsentChange, + isFranceOrItaly, + handleFranceOrItalyChange, + handleFormSubmit, + }; +} + +export const TrialLicenceForm: FunctionComponent = ({ submitUrl }: Props) => { + const { + formState, + formError, + emailError, + email, + handleEmailChange, + firstName, + firstNameError, + handleFirstNameChange, + lastName, + lastNameError, + handleLastNameChange, + dataProcessingConsent, + dataProcessingConsentError, + handleDataProcessingConsentChange, + marketingEmailConsent, + handleMarketingEmailConsentChange, + emailTrackingConsent, + handleEmailTrackingConsentChange, + isFranceOrItaly, + handleFranceOrItalyChange, + handleFormSubmit, + } = useTrialForm({ submitUrl }); + const hasFormError = Boolean(emailError || firstNameError || lastNameError || dataProcessingConsentError); + + return ( +

+
+
+ + +
+ +
+ + + + + +

{emailError ? emailError : 'Email required'}

+
+ +
+ + + +

First name required

+
+ +
+ + + +

Last name required

+
+
+ +
+ + + + + + + {isFranceOrItaly && ( + + )} +
+ +
+ + + {formState === 'success' && ( +

+ + + Thank you. Please check your inbox to validate your email and receive your{' '} + Enterprise Bundle trial licence. + +

+ )} + + {formError && ( +

+ + {formError} +

+ )} +
+ + ); }; diff --git a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormAllFields.tsx b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormAllFields.tsx deleted file mode 100644 index 26c3a90a29f..00000000000 --- a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormAllFields.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { Icon } from '@ag-website-shared/components/icon/Icon'; -import { PRIVACY_POLICY_URL } from '@ag-website-shared/constants'; -import { TRIAL_LICENCE_FORM_URL, ZI_FORM_ID } from '@constants'; -import { trackTrialLicenseFormError, trackTrialLicenseFormSuccess } from '@utils/analytics'; -import classnames from 'classnames'; -import { useCallback, useState } from 'react'; -import type { ChangeEventHandler, FormEventHandler, FunctionComponent } from 'react'; - -import { MESSAGES } from './Messages'; -import styles from './TrialLicenceForm.module.scss'; - -interface Props { - submitUrl?: string; -} - -type TrialFormState = 'success' | 'error' | 'loading' | 'idle'; - -const getFormErrorMessage = (message: string) => { - let errorMessage = MESSAGES.formErrorDefault; - - if (message === 'invalid arguments provided') { - errorMessage = MESSAGES.formErrorInvalidArguments; - } else if (message.includes('INVALID_EMAIL_ADDRESS')) { - // eg, "Error: Unable to create a lead for a trial LK for email @bc.com. Error: Insert failed. First exception on row 0; first error: INVALID_EMAIL_ADDRESS, Email: invalid email address: @bc.com: [Email]" - errorMessage = MESSAGES.formErrorInvalidEmail; - } else if (message.includes('Duplicate email')) { - // eg, "Error: Unable to create a lead for a trial LK for email something@somewhere.com. Error: Duplicate email" - errorMessage = MESSAGES.formErrorDuplicateEmail; - } - - return errorMessage; -}; - -const isEmailValid = (email: string) => { - const emailPattern = /^([a-zA-Z0-9._-]|\+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/; - return emailPattern.test(email); -}; - -const validateEmail = (email: string) => { - let validation = ''; - - if (!email) { - validation = MESSAGES.validationEmailRequired; - } else if (!isEmailValid(email)) { - validation = MESSAGES.validationEmailInvalid; - } - - return validation; -}; - -const validateRequired = (value: string) => { - let validation = ''; - if (value === '') { - validation = MESSAGES.validationRequiredField; - } - - return validation; -}; - -function useEmailValidation(initialValue: string = '') { - const [email, setEmail] = useState(initialValue); - const [emailError, setEmailError] = useState(validateEmail(initialValue)); - - const handleEmailChange: ChangeEventHandler = useCallback((e) => { - const value = e.target?.value; - setEmail(value); - setEmailError(validateEmail(value)); - }, []); - - return { - emailError, - email, - handleEmailChange, - }; -} - -function useRequiredValidation(initialValue: string = '') { - const [value, setValue] = useState(initialValue); - const [valueError, setValueError] = useState(validateRequired(initialValue)); - - const handleValueChange: ChangeEventHandler = useCallback((e) => { - const value = e.target?.value; - setValue(value); - setValueError(validateRequired(value)); - }, []); - - return { - valueError, - value, - handleValueChange, - }; -} - -async function submitTrialLicenceFormData({ - submitUrl = TRIAL_LICENCE_FORM_URL, - firstName, - lastName, - email, - company, -}: { - submitUrl?: string; - firstName: string; - lastName: string; - email: string; - company: string; -}) { - const response = await fetch(submitUrl, { - method: 'POST', - body: JSON.stringify({ data: { firstName, lastName, email, company } }), - headers: { - 'Content-Type': 'application/json', - }, - }); - const json = await response.json(); - - return json; -} - -function useTrialForm({ submitUrl }: Props) { - const [formState, setFormState] = useState('idle'); - const [formError, setFormError] = useState(''); - const [wasValidated, setWasValidated] = useState(false); - const { emailError: validatedEmailError, email, handleEmailChange } = useEmailValidation(); - const emailError = wasValidated && validatedEmailError ? validatedEmailError : ''; - - const { - value: firstName, - valueError: validatedFirstNameError, - handleValueChange: handleFirstNameChange, - } = useRequiredValidation(); - const firstNameError = wasValidated && validatedFirstNameError ? validatedFirstNameError : ''; - - const { - value: lastName, - valueError: validatedLastNameError, - handleValueChange: handleLastNameChange, - } = useRequiredValidation(); - const lastNameError = wasValidated && validatedLastNameError ? validatedLastNameError : ''; - - const handleFormSubmit: FormEventHandler = useCallback( - async (e) => { - e.preventDefault(); - setWasValidated(true); - - if (validatedEmailError || validatedFirstNameError || validatedLastNameError) { - setFormState('error'); - return; - } - - setFormError(''); - setFormState('loading'); - - const currentPage = window.location.pathname; - - try { - const company = (document.getElementById('company') as HTMLInputElement)?.value || ''; - const response = await submitTrialLicenceFormData({ submitUrl, firstName, lastName, email, company }); - - if (response.error) { - setFormState('error'); - const errorMessage = getFormErrorMessage(response.error.message); - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: response.error.message, - errorType: 'api_error', - page: currentPage, - }); - } else { - setFormState('success'); - trackTrialLicenseFormSuccess({ - page: currentPage, - }); - } - } catch (e) { - console.error(e); - const errorMessage = MESSAGES.formErrorDefault; - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: e instanceof Error ? e.message : 'Unknown error', - errorType: 'system_error', - page: currentPage, - }); - setFormState('error'); - } - }, - [validatedEmailError, validatedFirstNameError, validatedLastNameError, firstName, lastName, email] - ); - - return { - formState, - formError, - emailError, - email, - handleEmailChange, - firstName, - firstNameError, - handleFirstNameChange, - lastName, - lastNameError, - handleLastNameChange, - handleFormSubmit, - }; -} - -export const TrialLicenceFormAllFields: FunctionComponent = ({ submitUrl }: Props) => { - const { - formState, - formError, - emailError, - email, - handleEmailChange, - firstName, - firstNameError, - handleFirstNameChange, - lastName, - lastNameError, - handleLastNameChange, - handleFormSubmit, - } = useTrialForm({ submitUrl }); - const hasFormError = Boolean(emailError || firstNameError || lastNameError); - - return ( -
-
-
- - -
- -
- - - - - -

- {emailError ? emailError : 'Email required'} -

-
- -
- - - -

- First name required -

-
- -
- - - -

Last name required

-
-
- -
- - -

- By clicking "Request trial licence" you agree to our Privacy Policy - , and to be contacted by a member of our team -

- - {formState === 'success' && ( -

- - - Thank you. Please check your inbox to validate your email and receive your{' '} - Enterprise Bundle trial licence. - -

- )} - - {formError && ( -

- - {formError} -

- )} -
-
- ); -}; diff --git a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormEmailOnly.tsx b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormEmailOnly.tsx deleted file mode 100644 index 10ecb8782b3..00000000000 --- a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormEmailOnly.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { Icon } from '@ag-website-shared/components/icon/Icon'; -import { PRIVACY_POLICY_URL } from '@ag-website-shared/constants'; -import { TRIAL_LICENCE_FORM_URL } from '@constants'; -import { ZI_FORM_ID } from '@constants'; -import { trackTrialLicenseFormError, trackTrialLicenseFormSuccess } from '@utils/analytics'; -import classnames from 'classnames'; -import { useCallback, useState } from 'react'; -import type { ChangeEventHandler, FormEventHandler, FunctionComponent, ReactElement } from 'react'; - -import { MESSAGES } from './Messages'; -import styles from './TrialLicenceForm.module.scss'; - -interface Props { - submitUrl?: string; -} - -type TrialFormState = 'success' | 'error' | 'loading' | 'idle'; - -const getFormErrorMessage = (message: string) => { - let errorMessage = MESSAGES.formErrorDefault; - - if (message === 'invalid arguments provided') { - errorMessage = MESSAGES.formErrorInvalidArguments; - } else if (message.includes('INVALID_EMAIL_ADDRESS')) { - // eg, "Error: Unable to create a lead for a trial LK for email @bc.com. Error: Insert failed. First exception on row 0; first error: INVALID_EMAIL_ADDRESS, Email: invalid email address: @bc.com: [Email]" - errorMessage = MESSAGES.formErrorInvalidEmail; - } else if (message.includes('Duplicate email')) { - // eg, "Error: Unable to create a lead for a trial LK for email something@somewhere.com. Error: Duplicate email" - errorMessage = MESSAGES.formErrorDuplicateEmail; - } - - return errorMessage; -}; - -const isEmailValid = (email: string) => { - const emailPattern = /^([a-zA-Z0-9._-]|\+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/; - return emailPattern.test(email); -}; - -const validateEmail = (email: string) => { - let validation = ''; - - if (!email) { - validation = MESSAGES.validationEmailRequired; - } else if (!isEmailValid(email)) { - validation = MESSAGES.validationEmailInvalid; - } - - return validation; -}; - -function useEmailValidation(initialValue: string = '') { - const [email, setEmail] = useState(initialValue); - const [emailError, setEmailError] = useState(validateEmail(initialValue)); - - const handleEmailChange: ChangeEventHandler = useCallback((e) => { - const value = e.target?.value; - setEmail(value); - setEmailError(validateEmail(value)); - }, []); - - return { - emailError, - email, - handleEmailChange, - }; -} - -async function submitTrialLicenceFormData({ - submitUrl = TRIAL_LICENCE_FORM_URL, - firstName, - lastName, - email, - company, -}: { - submitUrl?: string; - firstName: string; - lastName: string; - email: string; - company: string; -}) { - const response = await fetch(submitUrl, { - method: 'POST', - body: JSON.stringify({ data: { firstName, lastName, email, company } }), - headers: { - 'Content-Type': 'application/json', - }, - }); - const json = await response.json(); - - return json; -} - -function useTrialForm({ submitUrl }: Props) { - const [formState, setFormState] = useState('idle'); - const [formError, setFormError] = useState(''); - const [wasValidated, setWasValidated] = useState(false); - const { emailError: validatedEmailError, email, handleEmailChange } = useEmailValidation(); - const emailError = wasValidated && validatedEmailError ? validatedEmailError : ''; - - const handleFormSubmit: FormEventHandler = useCallback( - async (e) => { - e.preventDefault(); - setWasValidated(true); - - if (validatedEmailError) { - setFormState('error'); - return; - } - - setFormError(''); - setFormState('loading'); - - const currentPage = window.location.pathname; - - try { - const firstName = (document.getElementById('first-name') as HTMLInputElement)?.value || ''; - const lastName = (document.getElementById('last-name') as HTMLInputElement)?.value || ''; - const company = (document.getElementById('company') as HTMLInputElement)?.value || ''; - const response = await submitTrialLicenceFormData({ submitUrl, firstName, lastName, email, company }); - - if (response.error) { - setFormState('error'); - const errorMessage = getFormErrorMessage(response.error.message); - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: response.error.message, - errorType: 'api_error', - page: currentPage, - }); - } else { - setFormState('success'); - trackTrialLicenseFormSuccess({ - page: currentPage, - }); - } - } catch (e) { - console.error(e); - const errorMessage = MESSAGES.formErrorDefault; - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: e instanceof Error ? e.message : 'Unknown error', - errorType: 'system_error', - page: currentPage, - }); - setFormState('error'); - } - }, - [validatedEmailError, email] - ); - - return { - formState, - formError, - emailError, - email, - handleEmailChange, - handleFormSubmit, - }; -} - -export const TrialLicenceFormEmailOnly: FunctionComponent = ({ submitUrl }: Props) => { - const { formState, formError, emailError, email, handleEmailChange, handleFormSubmit } = useTrialForm({ - submitUrl, - }); - const hasFormError = Boolean(emailError); - - return ( -
-
- - - - -
- - - - - -

- {emailError ? emailError : 'Email required'} -

-
-
-
- - -

- By clicking "Request trial licence" you agree to our Privacy Policy - , and to be contacted by a member of our team -

- - {formState === 'success' && ( -

- - - Thank you. Please check your inbox to validate your email and receive your{' '} - Enterprise Bundle trial licence. - -

- )} - - {formError && ( -

- - {formError} -

- )} -
-
- ); -}; diff --git a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormOriginal.tsx b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormOriginal.tsx deleted file mode 100644 index e343f6bce99..00000000000 --- a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormOriginal.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { Icon } from '@ag-website-shared/components/icon/Icon'; -import { PRIVACY_POLICY_URL } from '@ag-website-shared/constants'; -import { TRIAL_LICENCE_FORM_URL } from '@constants'; -import { trackTrialLicenseFormError, trackTrialLicenseFormSuccess } from '@utils/analytics'; -import classnames from 'classnames'; -import { useCallback, useState } from 'react'; -import type { ChangeEventHandler, FormEventHandler, FunctionComponent } from 'react'; - -import { MESSAGES } from './Messages'; -import styles from './TrialLicenceForm.module.scss'; - -interface Props { - submitUrl?: string; -} - -type TrialFormState = 'success' | 'error' | 'loading' | 'idle'; - -const getFormErrorMessage = (message: string) => { - let errorMessage = MESSAGES.formErrorDefault; - - if (message === 'invalid arguments provided') { - errorMessage = MESSAGES.formErrorInvalidArguments; - } else if (message.includes('INVALID_EMAIL_ADDRESS')) { - // eg, "Error: Unable to create a lead for a trial LK for email @bc.com. Error: Insert failed. First exception on row 0; first error: INVALID_EMAIL_ADDRESS, Email: invalid email address: @bc.com: [Email]" - errorMessage = MESSAGES.formErrorInvalidEmail; - } else if (message.includes('Duplicate email')) { - // eg, "Error: Unable to create a lead for a trial LK for email something@somewhere.com. Error: Duplicate email" - errorMessage = MESSAGES.formErrorDuplicateEmail; - } - - return errorMessage; -}; - -const isEmailValid = (email: string) => { - const emailPattern = /^([a-zA-Z0-9._-]|\+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/; - return emailPattern.test(email); -}; - -const validateEmail = (email: string) => { - let validation = ''; - - if (!email) { - validation = MESSAGES.validationEmailRequired; - } else if (!isEmailValid(email)) { - validation = MESSAGES.validationEmailInvalid; - } - - return validation; -}; - -const validateRequired = (value: string) => { - let validation = ''; - if (value === '') { - validation = MESSAGES.validationRequiredField; - } - - return validation; -}; - -function useEmailValidation(initialValue: string = '') { - const [email, setEmail] = useState(initialValue); - const [emailError, setEmailError] = useState(validateEmail(initialValue)); - - const handleEmailChange: ChangeEventHandler = useCallback((e) => { - const value = e.target?.value; - setEmail(value); - setEmailError(validateEmail(value)); - }, []); - - return { - emailError, - email, - handleEmailChange, - }; -} - -function useRequiredValidation(initialValue: string = '') { - const [value, setValue] = useState(initialValue); - const [valueError, setValueError] = useState(validateRequired(initialValue)); - - const handleValueChange: ChangeEventHandler = useCallback((e) => { - const value = e.target?.value; - setValue(value); - setValueError(validateRequired(value)); - }, []); - - return { - valueError, - value, - handleValueChange, - }; -} - -async function submitTrialLicenceFormData({ - submitUrl = TRIAL_LICENCE_FORM_URL, - firstName, - lastName, - email, -}: { - submitUrl?: string; - firstName: string; - lastName: string; - email: string; -}) { - const response = await fetch(submitUrl, { - method: 'POST', - body: JSON.stringify({ data: { firstName, lastName, email } }), - headers: { - 'Content-Type': 'application/json', - }, - }); - const json = await response.json(); - - return json; -} - -function useTrialForm({ submitUrl }: Props) { - const [formState, setFormState] = useState('idle'); - const [formError, setFormError] = useState(''); - const [wasValidated, setWasValidated] = useState(false); - const { emailError: validatedEmailError, email, handleEmailChange } = useEmailValidation(); - const emailError = wasValidated && validatedEmailError ? validatedEmailError : ''; - - const { - value: firstName, - valueError: validatedFirstNameError, - handleValueChange: handleFirstNameChange, - } = useRequiredValidation(); - const firstNameError = wasValidated && validatedFirstNameError ? validatedFirstNameError : ''; - - const { - value: lastName, - valueError: validatedLastNameError, - handleValueChange: handleLastNameChange, - } = useRequiredValidation(); - const lastNameError = wasValidated && validatedLastNameError ? validatedLastNameError : ''; - - const handleFormSubmit: FormEventHandler = useCallback( - async (e) => { - e.preventDefault(); - setWasValidated(true); - - if (validatedEmailError || validatedFirstNameError || validatedLastNameError) { - setFormState('error'); - return; - } - - setFormError(''); - setFormState('loading'); - - const currentPage = window.location.pathname; - - try { - const response = await submitTrialLicenceFormData({ submitUrl, firstName, lastName, email }); - - if (response.error) { - setFormState('error'); - const errorMessage = getFormErrorMessage(response.error.message); - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: response.error.message, - errorType: 'api_error', - page: currentPage, - }); - } else { - setFormState('success'); - trackTrialLicenseFormSuccess({ - page: currentPage, - }); - } - } catch (e) { - console.error(e); - const errorMessage = MESSAGES.formErrorDefault; - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: e instanceof Error ? e.message : 'Unknown error', - errorType: 'system_error', - page: currentPage, - }); - setFormState('error'); - } - }, - [validatedEmailError, validatedFirstNameError, validatedLastNameError, firstName, lastName, email] - ); - - return { - formState, - formError, - emailError, - email, - handleEmailChange, - firstName, - firstNameError, - handleFirstNameChange, - lastName, - lastNameError, - handleLastNameChange, - handleFormSubmit, - }; -} - -export const TrialLicenceFormOriginal: FunctionComponent = ({ submitUrl }: Props) => { - const { - formState, - formError, - emailError, - email, - handleEmailChange, - firstName, - firstNameError, - handleFirstNameChange, - lastName, - lastNameError, - handleLastNameChange, - handleFormSubmit, - } = useTrialForm({ submitUrl }); - const hasFormError = Boolean(emailError || firstNameError || lastNameError); - - return ( -
-
-
- - - -

- First name required -

-
- -
- - - -

Last name required

-
- -
- - - - - -

- {emailError ? emailError : 'Email required'} -

-
-
- -
- - -

- By clicking "Request trial licence" you agree to our Privacy Policy - , and to be contacted by a member of our team -

- - {formState === 'success' && ( -

- - - Thank you. Please check your inbox to validate your email and receive your{' '} - Enterprise Bundle trial licence. - -

- )} - - {formError && ( -

- - {formError} -

- )} -
-
- ); -}; diff --git a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormStudio.tsx b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormStudio.tsx index fb506d56f35..d76f77d2e1e 100644 --- a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormStudio.tsx +++ b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormStudio.tsx @@ -1,5 +1,4 @@ import { Icon } from '@ag-website-shared/components/icon/Icon'; -import { PRIVACY_POLICY_URL } from '@ag-website-shared/constants'; import { TRIAL_LICENCE_FORM_URL, ZI_FORM_ID } from '@constants'; import { trackTrialLicenseFormError, trackTrialLicenseFormSuccess } from '@utils/analytics'; import classnames from 'classnames'; @@ -236,16 +235,7 @@ export const TrialLicenceFormStudio: FunctionComponent = ({ submitUrl }: Props) required /> -

- First name required -

+

First name required

@@ -260,7 +250,7 @@ export const TrialLicenceFormStudio: FunctionComponent = ({ submitUrl }: Props) required /> -

Last name required

+

Last name required

@@ -278,9 +268,7 @@ export const TrialLicenceFormStudio: FunctionComponent = ({ submitUrl }: Props) onChange={handleEmailChange} /> -

- {emailError ? emailError : 'Email required'} -

+

{emailError ? emailError : 'Email required'}

@@ -289,14 +277,12 @@ export const TrialLicenceFormStudio: FunctionComponent = ({ submitUrl }: Props) className={styles.submit} type="submit" disabled={hasFormError || formState === 'loading' || formState === 'success'} + aria-busy={formState === 'loading'} > + {formState === 'loading' &&