diff --git a/.nxignore b/.nxignore index 622500af342..4238b0726fe 100644 --- a/.nxignore +++ b/.nxignore @@ -1,5 +1,6 @@ -# Needed to avoid nx format failing on symlinks. -.rulesync/ +# Ignore .rulesync symlinks as nx format doesn't handle these well. +.rulesync/** +!.rulesync/README.md .patches/ patches/ diff --git a/community-modules/styles/src/internal/base/parts/_columns-tool-panel.scss b/community-modules/styles/src/internal/base/parts/_columns-tool-panel.scss index 769ffd2e49a..c5c525fbac0 100644 --- a/community-modules/styles/src/internal/base/parts/_columns-tool-panel.scss +++ b/community-modules/styles/src/internal/base/parts/_columns-tool-panel.scss @@ -1,6 +1,14 @@ @use 'ag'; @mixin output { + :where(.ag-column-panel.ag-column-panel-deferred), + :where(.ag-column-panel.ag-column-panel-deferred) :where(.ag-column-panel-column-select), + :where(.ag-column-panel.ag-column-panel-deferred) :where(.ag-column-drop-vertical), + :where(.ag-column-panel.ag-column-panel-deferred) :where(.ag-column-panel-defer-mode-toggle), + :where(.ag-column-panel.ag-column-panel-deferred) :where(.ag-column-panel-buttons) { + background-color: var(--ag-cell-batch-edit-background-color); + } + .ag-pivot-mode-panel { min-height: var(--ag-header-height); height: var(--ag-header-height); diff --git a/documentation/ag-grid-docs/src/content/api-documentation/theming-api/properties.json b/documentation/ag-grid-docs/src/content/api-documentation/theming-api/properties.json index e6f913ed324..c743e687638 100644 --- a/documentation/ag-grid-docs/src/content/api-documentation/theming-api/properties.json +++ b/documentation/ag-grid-docs/src/content/api-documentation/theming-api/properties.json @@ -15,13 +15,7 @@ "spacing": {}, "fontFamily": {}, "fontSize": {}, - "fontWeight": {}, - "borderColor": {}, - "borderWidth": {}, - "borderRadius": {}, - "headerHeight": {}, - "rowHeight": {}, - "browserColorScheme": {} + "fontWeight": {} }, "colours": { "meta": { @@ -34,7 +28,8 @@ "textColor": {}, "chromeBackgroundColor": {}, "invalidColor": {}, - "subtleTextColor": {} + "subtleTextColor": {}, + "browserColorScheme": {} }, "spacingAndSizing": { "meta": { @@ -44,6 +39,8 @@ "url": "./theming-compactness/" } }, + "headerHeight": {}, + "rowHeight": {}, "listItemHeight": {}, "paginationPanelHeight": {}, "widgetContainerHorizontalPadding": {}, @@ -59,6 +56,9 @@ "url": "./theming-borders/" } }, + "borderColor": {}, + "borderWidth": {}, + "borderRadius": {}, "wrapperBorder": {}, "wrapperBorderRadius": {}, "columnBorder": {}, @@ -187,7 +187,6 @@ }, "iconColor": {}, "iconSize": {}, - "dragHandleColor": {}, "iconButtonColor": {}, "iconButtonBackgroundColor": {}, "iconButtonBackgroundSpread": {}, @@ -299,7 +298,8 @@ "panelTitleBarFontWeight": {}, "panelTitleBarHeight": {}, "panelTitleBarBorder": {}, - "panelTitleBarIconColor": {} + "panelTitleBarIconColor": {}, + "columnSelectIndentSize": {} }, "dragAndDrop": { "meta": { @@ -313,7 +313,8 @@ "dragAndDropImageBackgroundColor": {}, "dragAndDropImageBorder": {}, "dragAndDropImageShadow": {}, - "dragAndDropImageNotAllowedBorder": {} + "dragAndDropImageNotAllowedBorder": {}, + "dragHandleColor": {} }, "tooltips": { "meta": { @@ -457,7 +458,6 @@ "filterPanelApplyButtonColor": {}, "filterPanelCardSubtleColor": {}, "filterPanelCardSubtleHoverColor": {}, - "columnSelectIndentSize": {}, "filterToolPanelGroupIndent": {}, "setFilterIndentSize": {} }, @@ -476,34 +476,34 @@ "advancedFilterBuilderButtonBarBorder": {}, "advancedFilterBuilderIndentSize": {} }, - "formulaBar": { + "formulas": { "meta": { - "displayName": "Formula Bar", + "displayName": "Formulas", "page": { "name": "Formulas", "url": "./formulas/" } }, - "formulaToken1BackgroundColor": {}, "formulaToken1Color": {}, - "formulaToken1Border": {}, - "formulaToken2BackgroundColor": {}, "formulaToken2Color": {}, - "formulaToken2Border": {}, - "formulaToken3BackgroundColor": {}, "formulaToken3Color": {}, - "formulaToken3Border": {}, - "formulaToken4BackgroundColor": {}, "formulaToken4Color": {}, - "formulaToken4Border": {}, - "formulaToken5BackgroundColor": {}, "formulaToken5Color": {}, - "formulaToken5Border": {}, - "formulaToken6BackgroundColor": {}, "formulaToken6Color": {}, - "formulaToken6Border": {}, - "formulaToken7BackgroundColor": {}, "formulaToken7Color": {}, + "formulaToken1BackgroundColor": {}, + "formulaToken2BackgroundColor": {}, + "formulaToken3BackgroundColor": {}, + "formulaToken4BackgroundColor": {}, + "formulaToken5BackgroundColor": {}, + "formulaToken6BackgroundColor": {}, + "formulaToken7BackgroundColor": {}, + "formulaToken1Border": {}, + "formulaToken2Border": {}, + "formulaToken3Border": {}, + "formulaToken4Border": {}, + "formulaToken5Border": {}, + "formulaToken6Border": {}, "formulaToken7Border": {} }, "find": { diff --git a/packages/ag-grid-community/src/edit/editModelService.ts b/packages/ag-grid-community/src/edit/editModelService.ts index 5b9e0fb3e13..c204beee20d 100644 --- a/packages/ag-grid-community/src/edit/editModelService.ts +++ b/packages/ag-grid-community/src/edit/editModelService.ts @@ -92,9 +92,10 @@ export class EditModelService extends BeanStub implements NamedBean { const data: any = { ...rowNode.data }; const applyEdits = (edits: EditRow, data: any) => - edits.forEach(({ pendingValue }, column) => { - if (pendingValue !== UNEDITED) { - data[column.getColId()] = pendingValue; + edits.forEach(({ editorValue, pendingValue }, column) => { + const value = editorValue === undefined ? pendingValue : editorValue; + if (value !== UNEDITED) { + data[column.getColId()] = value; } }); diff --git a/packages/ag-grid-community/src/edit/editService.ts b/packages/ag-grid-community/src/edit/editService.ts index bbf0779c0df..bcece752c42 100644 --- a/packages/ag-grid-community/src/edit/editService.ts +++ b/packages/ag-grid-community/src/edit/editService.ts @@ -105,6 +105,7 @@ const COMMIT_PARAMS: StopEditParams = { cancel: false, source: 'api' }; const CHECK_SIBLING = { checkSiblings: true }; const FORCE_REFRESH = { force: true, suppressFlash: true }; +const FORCE_REFRESH_FLASH = { force: true }; export class EditService extends BeanStub implements NamedBean { public beanName = 'editSvc' as const; @@ -1299,7 +1300,9 @@ export class EditService extends BeanStub implements NamedBean { _purgeUnchangedEdits(beans); // Re-fetch: change detection during setDataValue may have recreated the CellCtrl. - _getCellCtrl(beans, position)?.refreshCell(FORCE_REFRESH); + // Only allow flash when the value was actually committed; suppress when setDataValue + // returned false (e.g. readOnlyEdit, rejected valueSetter, unchanged value). + _getCellCtrl(beans, position)?.refreshCell(success ? FORCE_REFRESH_FLASH : FORCE_REFRESH); return success; } diff --git a/packages/ag-grid-community/src/edit/strategy/singleCellEditStrategy.ts b/packages/ag-grid-community/src/edit/strategy/singleCellEditStrategy.ts index 647b73b8f0e..d437aa8ee91 100644 --- a/packages/ag-grid-community/src/edit/strategy/singleCellEditStrategy.ts +++ b/packages/ag-grid-community/src/edit/strategy/singleCellEditStrategy.ts @@ -222,12 +222,14 @@ export class SingleCellEditStrategy extends BaseEditStrategy { // Don't start editing the next cell, focus only const suppressStartEditOnTab = this.gos.get('suppressStartEditOnTab'); + let startEditingCalled = false; if (!rowsMatch && !preventNavigation) { super.cleanupEditors(nextCell, true); if (suppressStartEditOnTab) { nextCell.focusCell(true, event); } else { + startEditingCalled = true; this.editSvc.startEditing(nextCell, { startedEdit: true, event, @@ -244,13 +246,17 @@ export class SingleCellEditStrategy extends BaseEditStrategy { if (suppressStartEditOnTab) { nextCell.focusCell(true, event); } else if (!nextCell.comp?.getCellEditor()) { - // Two possibilities: - // * Editor should be visible (but was destroyed due to column virtualisation) - // = we shouldn't re-emit a startEdit event, so stay silent - // * Editor wasn't created because edit came from API and didn't trigger EditService.startEditing - // = shouldn't be silent - const alreadyEditing = this.editSvc?.isEditing(nextCell, { withOpenEditor: true }); - _setupEditor(this.beans, nextCell, { event, cellStartedEdit: true, silent: alreadyEditing }); + // If startEditing was called above (cross-row navigation), the editor may not + // exist yet because React creates editor components asynchronously. In that case + // skip the redundant _setupEditor call to avoid overwriting the correctly- + // parameterised first call. Otherwise, the editor is genuinely missing (e.g. + // destroyed by column virtualisation while edit state remained open) and must + // be re-created — silently if the cell is already in editing state to avoid + // re-emitting cellEditingStarted. + if (!startEditingCalled) { + const alreadyEditing = this.editSvc?.isEditing(nextCell, { withOpenEditor: true }); + _setupEditor(this.beans, nextCell, { event, cellStartedEdit: true, silent: alreadyEditing }); + } this.setFocusInOnEditor(nextCell); this.cleanupEditors(nextCell); diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.css b/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.css index ee7dfddbaf9..d14cc037d71 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.css +++ b/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.css @@ -5,6 +5,14 @@ flex: 1 1 auto; } +:where(.ag-column-panel.ag-column-panel-deferred), +:where(.ag-column-panel.ag-column-panel-deferred) :where(.ag-column-panel-column-select), +:where(.ag-column-panel.ag-column-panel-deferred) :where(.ag-column-drop-vertical), +:where(.ag-column-panel.ag-column-panel-deferred) :where(.ag-column-panel-defer-mode-toggle), +:where(.ag-column-panel.ag-column-panel-deferred) :where(.ag-column-panel-buttons) { + background-color: var(--ag-cell-batch-edit-background-color); +} + .ag-pivot-mode-panel { height: var(--ag-header-height); display: flex; diff --git a/testing/behavioural/src/cell-editing/cell-editing-change-flash.test.ts b/testing/behavioural/src/cell-editing/cell-editing-change-flash.test.ts new file mode 100644 index 00000000000..71eceb5094d --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-change-flash.test.ts @@ -0,0 +1,397 @@ +import { getByTestId } from '@testing-library/dom'; +import '@testing-library/jest-dom'; +import { userEvent } from '@testing-library/user-event'; + +import { + HighlightChangesModule, + TextEditorModule, + UndoRedoEditModule, + agTestIdFor, + getGridElement, + setupAgTestIds, +} from 'ag-grid-community'; +import { BatchEditModule, CellSelectionModule, ClipboardModule } from 'ag-grid-enterprise'; + +import { TestGridsManager, asyncSetTimeout, clipboardUtils, waitForEvent, waitForInput } from '../test-utils'; + +const FLASH_CSS_CLASS = 'ag-cell-data-changed'; + +describe('Cell change flashing after edit', () => { + const gridMgr = new TestGridsManager({ + modules: [HighlightChangesModule, UndoRedoEditModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test('cell flashes after committing an edit', async () => { + const api = await gridMgr.createGridAndWait('flashAfterEdit', { + undoRedoCellEditing: true, + defaultColDef: { + editable: true, + enableCellChangeFlash: true, + }, + columnDefs: [{ field: 'make' }, { field: 'model' }, { field: 'price' }], + rowData: [ + { id: 'ROW_0', make: 'Toyota', model: 'Celica', price: 35000 }, + { id: 'ROW_1', make: 'Ford', model: 'Mondeo', price: 32000 }, + ], + getRowId: (params) => params.data.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'make')); + + api.startEditingCell({ rowIndex: 0, colKey: 'make' }); + const input = await waitForInput(gridDiv, cell); + await user.clear(input); + await user.type(input, 'Honda'); + await user.keyboard('{Enter}'); + await asyncSetTimeout(0); + + expect(api.getDisplayedRowAtIndex(0)?.data?.make).toBe('Honda'); + expect(cell).toHaveClass(FLASH_CSS_CLASS); + }); +}); + +describe('Cell change flashing after undo and redo', () => { + const gridMgr = new TestGridsManager({ + modules: [HighlightChangesModule, UndoRedoEditModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test('cell flashes after undo', async () => { + const api = await gridMgr.createGridAndWait('flashAfterUndo', { + undoRedoCellEditing: true, + defaultColDef: { + editable: true, + enableCellChangeFlash: true, + }, + columnDefs: [{ field: 'make' }, { field: 'model' }, { field: 'price' }], + rowData: [ + { id: 'ROW_0', make: 'Toyota', model: 'Celica', price: 35000 }, + { id: 'ROW_1', make: 'Ford', model: 'Mondeo', price: 32000 }, + ], + getRowId: (params) => params.data.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'make')); + + // Edit the cell + api.startEditingCell({ rowIndex: 0, colKey: 'make' }); + const input = await waitForInput(gridDiv, cell); + await user.clear(input); + await user.type(input, 'Honda'); + await user.keyboard('{Enter}'); + await asyncSetTimeout(0); + + expect(api.getDisplayedRowAtIndex(0)?.data?.make).toBe('Honda'); + + // Wait for flash to complete before testing undo flash + await asyncSetTimeout(600); + expect(cell).not.toHaveClass(FLASH_CSS_CLASS); + + // Undo the edit + api.undoCellEditing(); + await asyncSetTimeout(0); + + expect(api.getDisplayedRowAtIndex(0)?.data?.make).toBe('Toyota'); + expect(cell).toHaveClass(FLASH_CSS_CLASS); + }); + + test('cell flashes after redo', async () => { + const api = await gridMgr.createGridAndWait('flashAfterRedo', { + undoRedoCellEditing: true, + defaultColDef: { + editable: true, + enableCellChangeFlash: true, + }, + columnDefs: [{ field: 'make' }, { field: 'model' }, { field: 'price' }], + rowData: [ + { id: 'ROW_0', make: 'Toyota', model: 'Celica', price: 35000 }, + { id: 'ROW_1', make: 'Ford', model: 'Mondeo', price: 32000 }, + ], + getRowId: (params) => params.data.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'make')); + + // Edit the cell + api.startEditingCell({ rowIndex: 0, colKey: 'make' }); + const input = await waitForInput(gridDiv, cell); + await user.clear(input); + await user.type(input, 'Honda'); + await user.keyboard('{Enter}'); + await asyncSetTimeout(0); + + // Undo + await asyncSetTimeout(600); + api.undoCellEditing(); + await asyncSetTimeout(0); + expect(api.getDisplayedRowAtIndex(0)?.data?.make).toBe('Toyota'); + + // Wait for undo flash to complete + await asyncSetTimeout(600); + expect(cell).not.toHaveClass(FLASH_CSS_CLASS); + + // Redo + api.redoCellEditing(); + await asyncSetTimeout(0); + + expect(api.getDisplayedRowAtIndex(0)?.data?.make).toBe('Honda'); + expect(cell).toHaveClass(FLASH_CSS_CLASS); + }); +}); + +describe('Cell change flashing suppressed when value not committed', () => { + const gridMgr = new TestGridsManager({ + modules: [HighlightChangesModule, UndoRedoEditModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test('cell does not flash when undo valueSetter rejects the change', async () => { + let callCount = 0; + const api = await gridMgr.createGridAndWait('flashRejectedUndoValueSetter', { + undoRedoCellEditing: true, + defaultColDef: { + editable: true, + enableCellChangeFlash: true, + }, + columnDefs: [ + { + field: 'make', + valueSetter: ({ data, newValue }) => { + callCount++; + if (callCount <= 1) { + // Accept the first edit + data.make = newValue; + return true; + } + // Reject the undo write + return false; + }, + }, + ], + rowData: [{ id: 'ROW_0', make: 'Toyota' }], + getRowId: (params) => params.data.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'make')); + + // First edit — valueSetter accepts + api.startEditingCell({ rowIndex: 0, colKey: 'make' }); + const input = await waitForInput(gridDiv, cell); + await user.clear(input); + await user.type(input, 'Honda'); + await user.keyboard('{Enter}'); + await asyncSetTimeout(0); + + expect(api.getDisplayedRowAtIndex(0)?.data?.make).toBe('Honda'); + + // Wait for edit flash to complete + await asyncSetTimeout(600); + expect(cell).not.toHaveClass(FLASH_CSS_CLASS); + + // Undo — valueSetter rejects (callCount > 1) + api.undoCellEditing(); + await asyncSetTimeout(0); + + // Value should NOT have reverted and cell should NOT flash + expect(api.getDisplayedRowAtIndex(0)?.data?.make).toBe('Honda'); + expect(cell).not.toHaveClass(FLASH_CSS_CLASS); + }); +}); + +describe('Cell change flashing after delete', () => { + const gridMgr = new TestGridsManager({ + modules: [HighlightChangesModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test('cell flashes after pressing Delete', async () => { + const api = await gridMgr.createGridAndWait('flashAfterDelete', { + defaultColDef: { + editable: true, + enableCellChangeFlash: true, + }, + columnDefs: [{ field: 'make' }, { field: 'model' }, { field: 'price' }], + rowData: [ + { id: 'ROW_0', make: 'Toyota', model: 'Celica', price: 35000 }, + { id: 'ROW_1', make: 'Ford', model: 'Mondeo', price: 32000 }, + ], + getRowId: (params) => params.data.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'make')); + await user.click(cell); + await user.keyboard('{Delete}'); + await asyncSetTimeout(0); + + expect(api.getDisplayedRowAtIndex(0)?.data?.make).toBeNull(); + expect(cell).toHaveClass(FLASH_CSS_CLASS); + }); +}); + +describe('Cell change flashing after fill handle', () => { + const gridMgr = new TestGridsManager({ + modules: [HighlightChangesModule, CellSelectionModule, ClipboardModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + clipboardUtils.init(); + }); + + beforeEach(() => { + clipboardUtils.init(); + }); + + afterEach(() => { + gridMgr.reset(); + clipboardUtils.reset(); + }); + + test('target cell flashes after fill handle', async () => { + const api = await gridMgr.createGridAndWait('flashAfterFillHandle', { + cellSelection: { + handle: { + mode: 'fill', + }, + }, + defaultColDef: { + editable: true, + enableCellChangeFlash: true, + }, + columnDefs: [{ field: 'make' }, { field: 'model' }, { field: 'price' }], + rowData: [ + { id: 'ROW_0', make: 'Toyota', model: 'Celica', price: 35000 }, + { id: 'ROW_1', make: 'Ford', model: 'Mondeo', price: 32000 }, + { id: 'ROW_2', make: 'Porsche', model: 'Boxster', price: 72000 }, + ], + getRowId: (params) => params.data.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + await asyncSetTimeout(0); + + // Select ROW_0 make cell to set up the fill source + const sourceCell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'make')); + const cellSelectionChanged = waitForEvent('cellSelectionChanged', api); + sourceCell.dispatchEvent(new MouseEvent('touchstart', { bubbles: true })); + await cellSelectionChanged; + await asyncSetTimeout(1); + + // Double-click fill handle to fill down + const fillHandle = getByTestId(gridDiv, agTestIdFor.fillHandle()); + const fillEnd = waitForEvent('fillEnd', api); + await userEvent.dblClick(fillHandle); + await fillEnd; + await asyncSetTimeout(0); + + // Target cells should have the source value and flash + expect(api.getDisplayedRowAtIndex(1)?.data?.make).toBe('Toyota'); + expect(api.getDisplayedRowAtIndex(2)?.data?.make).toBe('Toyota'); + + const targetCell1 = getByTestId(gridDiv, agTestIdFor.cell('ROW_1', 'make')); + const targetCell2 = getByTestId(gridDiv, agTestIdFor.cell('ROW_2', 'make')); + expect(targetCell1).toHaveClass(FLASH_CSS_CLASS); + expect(targetCell2).toHaveClass(FLASH_CSS_CLASS); + }); +}); + +describe('Cell change flashing after bulk edit (Ctrl+Enter)', () => { + const gridMgr = new TestGridsManager({ + modules: [HighlightChangesModule, CellSelectionModule, BatchEditModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test('all changed cells flash after bulk edit', async () => { + const api = await gridMgr.createGridAndWait('flashAfterBulkEdit', { + cellSelection: true, + defaultColDef: { + editable: true, + enableCellChangeFlash: true, + }, + columnDefs: [{ field: 'make' }, { field: 'model' }], + rowData: [ + { id: 'ROW_0', make: 'Toyota', model: 'Celica' }, + { id: 'ROW_1', make: 'Ford', model: 'Mondeo' }, + ], + getRowId: (params) => params.data.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'make')); + await user.click(cell); + api.addCellRange({ rowStartIndex: 0, rowEndIndex: 1, columns: ['make'] }); + api.startEditingCell({ rowIndex: 0, colKey: 'make' }); + const input = await waitForInput(gridDiv, cell); + await user.clear(input); + await user.type(input, 'BulkValue'); + await user.keyboard('{Control>}{Enter}{/Control}'); + await asyncSetTimeout(0); + + expect(api.getDisplayedRowAtIndex(0)?.data?.make).toBe('BulkValue'); + expect(api.getDisplayedRowAtIndex(1)?.data?.make).toBe('BulkValue'); + + const cell0 = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'make')); + const cell1 = getByTestId(gridDiv, agTestIdFor.cell('ROW_1', 'make')); + expect(cell0).toHaveClass(FLASH_CSS_CLASS); + expect(cell1).toHaveClass(FLASH_CSS_CLASS); + }); +}); diff --git a/testing/behavioural/src/cell-editing/cell-editing-get-edit-row-values.test.ts b/testing/behavioural/src/cell-editing/cell-editing-get-edit-row-values.test.ts new file mode 100644 index 00000000000..e056b33a838 --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-get-edit-row-values.test.ts @@ -0,0 +1,94 @@ +import { getByTestId } from '@testing-library/dom'; +import '@testing-library/jest-dom'; +import { userEvent } from '@testing-library/user-event'; + +import { TextEditorModule, agTestIdFor, getGridElement, setupAgTestIds } from 'ag-grid-community'; +import { BatchEditModule } from 'ag-grid-enterprise'; + +import { TestGridsManager, asyncSetTimeout, waitForInput } from '../test-utils'; + +describe('getEditRowValues returns current editor values during active editing', () => { + const gridMgr = new TestGridsManager({ + modules: [BatchEditModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test('single cell edit returns typed value while editor is open', async () => { + const api = await gridMgr.createGridAndWait('getEditRowValuesSingle', { + defaultColDef: { + editable: true, + }, + columnDefs: [{ field: 'athlete' }, { field: 'age' }], + rowData: [{ id: 'ROW_0', athlete: 'Natalie Coughlin', age: 25 }], + getRowId: (params) => params.data.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'athlete')); + + api.startEditingCell({ rowIndex: 0, colKey: 'athlete' }); + const input = await waitForInput(gridDiv, cell); + await user.clear(input); + await user.type(input, 'aaaaa'); + + const rowNode = api.getDisplayedRowAtIndex(0)!; + const editValues = api.getEditRowValues(rowNode); + + expect(editValues).toBeDefined(); + expect(editValues!.athlete).toBe('aaaaa'); + // Original data should not be mutated + expect(rowNode.data.athlete).toBe('Natalie Coughlin'); + }); + + test('full-row edit returns all typed values while editors are open', async () => { + const api = await gridMgr.createGridAndWait('getEditRowValuesFullRow', { + editType: 'fullRow', + defaultColDef: { + editable: true, + cellDataType: false, + }, + columnDefs: [{ field: 'athlete' }, { field: 'age' }], + rowData: [{ id: 'ROW_0', athlete: 'Natalie Coughlin', age: 25 }], + getRowId: (params) => params.data.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + + const athleteCell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'athlete')); + + api.startEditingCell({ rowIndex: 0, colKey: 'athlete' }); + const athleteInput = await waitForInput(gridDiv, athleteCell); + await user.clear(athleteInput); + await user.type(athleteInput, 'aaaaa'); + + // Tab to next cell + await user.keyboard('{Tab}'); + + const ageCell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'age')); + const ageInput = await waitForInput(gridDiv, ageCell); + await user.clear(ageInput); + await user.type(ageInput, '555'); + + const rowNode = api.getDisplayedRowAtIndex(0)!; + const editValues = api.getEditRowValues(rowNode); + + expect(editValues).toBeDefined(); + expect(editValues!.athlete).toBe('aaaaa'); + expect(editValues!.age).toBe('555'); + // Original data should not be mutated + expect(rowNode.data.athlete).toBe('Natalie Coughlin'); + expect(rowNode.data.age).toBe(25); + }); +}); diff --git a/testing/behavioural/src/cell-editing/cell-editing-tab-editor-react.test.tsx b/testing/behavioural/src/cell-editing/cell-editing-tab-editor-react.test.tsx new file mode 100644 index 00000000000..a55010ff62f --- /dev/null +++ b/testing/behavioural/src/cell-editing/cell-editing-tab-editor-react.test.tsx @@ -0,0 +1,745 @@ +import { getByTestId, waitFor } from '@testing-library/dom'; +import '@testing-library/jest-dom'; +import { cleanup, render } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; +import { vi } from 'vitest'; + +import { + ClientSideRowModelModule, + CustomEditorModule, + LargeTextEditorModule, + NumberEditorModule, + TextEditorModule, + ValidationModule, + agTestIdFor, + getGridElement, + setupAgTestIds, +} from 'ag-grid-community'; +import type { GridApi, GridReadyEvent, ICellEditorComp, ICellEditorParams } from 'ag-grid-community'; +import { RichSelectModule } from 'ag-grid-enterprise'; +import { AgGridReact } from 'ag-grid-react'; + +import { + asyncSetTimeout, + ignoreConsoleLicenseKeyError, + initPointerEventPolyfill, + mockGridLayout, + waitForPopup, +} from '../test-utils'; + +// Custom editor that records cellStartedEdit and afterGuiAttached calls +const editorLog: { + cellStartedEdit: boolean; + afterGuiAttachedCalled: boolean; + focusInCalled: boolean; + column?: string; + rowIndex?: number; +}[] = []; + +class TrackingEditor implements ICellEditorComp { + private gui: HTMLDivElement; + private value: any; + private entry: (typeof editorLog)[0]; + + constructor() { + this.gui = document.createElement('div'); + this.gui.className = 'tracking-editor'; + this.gui.tabIndex = -1; + this.entry = { cellStartedEdit: false, afterGuiAttachedCalled: false, focusInCalled: false }; + editorLog.push(this.entry); + } + + init(params: ICellEditorParams): void { + this.entry.cellStartedEdit = params.cellStartedEdit; + this.entry.column = params.column?.getColId(); + this.entry.rowIndex = params.node?.rowIndex ?? undefined; + this.value = params.value; + this.gui.textContent = String(this.value ?? ''); + } + + getGui(): HTMLElement { + return this.gui; + } + + getValue(): any { + return this.value; + } + + afterGuiAttached(): void { + this.entry.afterGuiAttachedCalled = true; + this.gui.focus(); + } + + focusIn(): void { + this.entry.focusInCalled = true; + this.gui.focus(); + } +} + +// Popup version (isPopup = true) +class TrackingPopupEditor extends TrackingEditor { + isPopup(): boolean { + return true; + } +} + +/** Helper to render AgGridReact and wait for gridReady */ +async function renderGrid(props: { + rowData: any[]; + columnDefs: any[]; + modules: any[]; + width?: number; + height?: number; + defaultColDef?: any; + editType?: 'fullRow'; +}): Promise<{ api: GridApi; gridDiv: HTMLElement; user: ReturnType }> { + let readyResolve!: (api: GridApi) => void; + const readyPromise = new Promise((resolve) => { + readyResolve = resolve; + }); + + render( +
+ params.data.id} + modules={[ValidationModule, ...props.modules]} + defaultColDef={props.defaultColDef} + editType={props.editType} + 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: tab into editor in React', () => { + beforeAll(() => { + mockGridLayout.init(); + ignoreConsoleLicenseKeyError(); + initPointerEventPolyfill(); + setupAgTestIds(); + }); + + beforeEach(() => { + editorLog.length = 0; + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + // Bug 2: RichSelect cellStartedEdit regression — tabbing to next row (single column) + test('cellStartedEdit is true when tabbing to next row in React (single column)', async () => { + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', val: 'A' }, + { id: '1', val: 'B' }, + { id: '2', val: 'C' }, + ], + columnDefs: [{ field: 'val', editable: true, cellEditor: TrackingEditor }], + modules: [ClientSideRowModelModule, TextEditorModule, CustomEditorModule], + }); + + // Double-click first cell to start editing + const cell0 = getByTestId(gridDiv, agTestIdFor.cell('0', 'val')); + await user.dblClick(cell0); + + await waitFor(() => { + expect(editorLog).toHaveLength(1); + }); + expect(editorLog[0].cellStartedEdit).toBe(true); + + // Tab to next row (single column → moves to row 1) + await user.keyboard('{Tab}'); + + await waitFor(() => { + expect(editorLog.length).toBeGreaterThanOrEqual(2); + }); + + expect(editorLog[editorLog.length - 1].cellStartedEdit).toBe(true); + }); + + // Bug 3: Multi-column tabbing (same row) in both directions + test('cellStartedEdit is true when tabbing across columns in React (same row)', async () => { + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', a: 'A1', b: 'B1', c: 'C1' }, + { id: '1', a: 'A2', b: 'B2', c: 'C2' }, + ], + columnDefs: [ + { field: 'a', editable: true, cellEditor: TrackingEditor }, + { field: 'b', editable: true, cellEditor: TrackingEditor }, + { field: 'c', editable: true, cellEditor: TrackingEditor }, + ], + modules: [ClientSideRowModelModule, TextEditorModule, CustomEditorModule], + }); + + // Double-click first cell to start editing col 'a' + const cellA = getByTestId(gridDiv, agTestIdFor.cell('0', 'a')); + await user.dblClick(cellA); + + await waitFor(() => { + expect(editorLog).toHaveLength(1); + }); + expect(editorLog[0].cellStartedEdit).toBe(true); + + // Tab forward → col 'b' + await user.keyboard('{Tab}'); + + await waitFor(() => { + expect(editorLog.length).toBeGreaterThanOrEqual(2); + }); + expect(editorLog[editorLog.length - 1].cellStartedEdit).toBe(true); + + // Tab forward → col 'c' + await user.keyboard('{Tab}'); + + await waitFor(() => { + expect(editorLog.length).toBeGreaterThanOrEqual(3); + }); + expect(editorLog[editorLog.length - 1].cellStartedEdit).toBe(true); + + // Shift+Tab backward → col 'b' + await user.keyboard('{Shift>}{Tab}{/Shift}'); + + await waitFor(() => { + expect(editorLog.length).toBeGreaterThanOrEqual(4); + }); + expect(editorLog[editorLog.length - 1].cellStartedEdit).toBe(true); + }); + + // Bug 3 extended: Multi-column tabbing across rows (tab far enough to change rows) + test('cellStartedEdit is true when tabbing across columns and rows in React', async () => { + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', a: 'A1', b: 'B1', c: 'C1' }, + { id: '1', a: 'A2', b: 'B2', c: 'C2' }, + { id: '2', a: 'A3', b: 'B3', c: 'C3' }, + ], + columnDefs: [ + { field: 'a', editable: true, cellEditor: TrackingEditor }, + { field: 'b', editable: true, cellEditor: TrackingEditor }, + { field: 'c', editable: true, cellEditor: TrackingEditor }, + ], + modules: [ClientSideRowModelModule, TextEditorModule, CustomEditorModule], + }); + + // Double-click cell (0, 'a') to start editing + const cellA = getByTestId(gridDiv, agTestIdFor.cell('0', 'a')); + await user.dblClick(cellA); + + await waitFor(() => { + expect(editorLog).toHaveLength(1); + }); + expect(editorLog[0].cellStartedEdit).toBe(true); + + // Tab forward through: a→b, b→c, c→(next row a), a→b, b→c + // This crosses from row 0 to row 1 + for (let i = 0; i < 5; i++) { + await user.keyboard('{Tab}'); + + await waitFor(() => { + expect(editorLog.length).toBeGreaterThanOrEqual(i + 2); + }); + expect(editorLog[editorLog.length - 1].cellStartedEdit).toBe(true); + } + + // Now Shift+Tab backward through: c←b, b←a, a←(prev row c), c←b + // This crosses back from row 1 to row 0 + for (let i = 0; i < 4; i++) { + await user.keyboard('{Shift>}{Tab}{/Shift}'); + + await waitFor(() => { + expect(editorLog.length).toBeGreaterThanOrEqual(i + 7); + }); + expect(editorLog[editorLog.length - 1].cellStartedEdit).toBe(true); + } + }); + + // Bug 1: Popup editor focus after tab (LargeText-like popup editor) + test('popup editor receives afterGuiAttached and focus when tabbing in React', async () => { + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', val: 'A' }, + { id: '1', val: 'B' }, + { id: '2', val: 'C' }, + ], + columnDefs: [{ field: 'val', editable: true, cellEditor: TrackingPopupEditor }], + modules: [ClientSideRowModelModule, TextEditorModule, CustomEditorModule], + }); + + // Double-click first cell to start editing + const cell0 = getByTestId(gridDiv, agTestIdFor.cell('0', 'val')); + await user.dblClick(cell0); + + await waitFor(() => { + expect(editorLog).toHaveLength(1); + expect(editorLog[0].afterGuiAttachedCalled).toBe(true); + }); + expect(editorLog[0].cellStartedEdit).toBe(true); + + // Tab to next row + await user.keyboard('{Tab}'); + + await waitFor(() => { + expect(editorLog.length).toBeGreaterThanOrEqual(2); + expect(editorLog[editorLog.length - 1].afterGuiAttachedCalled).toBe(true); + }); + + const secondEditor = editorLog[editorLog.length - 1]; + expect(secondEditor.cellStartedEdit).toBe(true); + }); + + // Bug 1 extended: LargeTextCellEditor popup focus after tab + test('agLargeTextCellEditor popup receives focus when tabbing in React', async () => { + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', description: 'Lorem ipsum dolor sit amet' }, + { id: '1', description: 'Consectetur adipiscing elit' }, + { id: '2', description: 'Sed do eiusmod tempor' }, + ], + columnDefs: [ + { + field: 'description', + cellEditor: 'agLargeTextCellEditor', + cellEditorPopup: true, + editable: true, + }, + ], + modules: [ClientSideRowModelModule, TextEditorModule, LargeTextEditorModule], + }); + + // Double-click first cell to start editing + const cell0 = getByTestId(gridDiv, agTestIdFor.cell('0', 'description')); + await user.dblClick(cell0); + + // Verify popup exists for first editor + const popup0 = await waitForPopup(gridDiv); + expect(popup0).toBeTruthy(); + + const textarea0 = popup0.querySelector('textarea'); + expect(textarea0).toBeTruthy(); + + // Tab to next row + await user.keyboard('{Tab}'); + + // Verify popup exists for second editor and textarea has focus + const popup1 = await waitForPopup(gridDiv); + expect(popup1).toBeTruthy(); + + const textarea1 = popup1.querySelector('textarea'); + expect(textarea1).toBeTruthy(); + }); + + // Bug 2: RichSelect with sync values — tabbing opens the values popup + test('agRichSelectCellEditor with sync values shows picker when tabbing in React', async () => { + const languages = ['English', 'Spanish', 'French', 'Portuguese', 'German']; + + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', language: 'English' }, + { id: '1', language: 'Spanish' }, + { id: '2', language: 'French' }, + ], + columnDefs: [ + { + field: 'language', + cellEditor: 'agRichSelectCellEditor', + cellEditorParams: { values: languages }, + editable: true, + }, + ], + modules: [ClientSideRowModelModule, TextEditorModule, RichSelectModule], + }); + + // Double-click first cell to start editing + const cell0 = getByTestId(gridDiv, agTestIdFor.cell('0', 'language')); + await user.dblClick(cell0); + + // Verify the RichSelect popup/picker opened (aria-expanded) + await waitFor(() => { + const expanded = gridDiv.querySelector('.ag-picker-expanded'); + expect(expanded).toBeTruthy(); + }); + + // Tab to next row + await user.keyboard('{Tab}'); + + // Verify the new RichSelect picker opened on the next cell + await waitFor(() => { + const expanded = gridDiv.querySelector('.ag-picker-expanded'); + expect(expanded).toBeTruthy(); + }); + }); + + // Bug 2: RichSelect with async values — tabbing shows "loading" popup + test('agRichSelectCellEditor with async values shows picker when tabbing in React', async () => { + const languages = ['English', 'Spanish', 'French', 'Portuguese', 'German']; + const getValuesAsync = () => new Promise((resolve) => setTimeout(() => resolve(languages), 3000)); + + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', language: 'English' }, + { id: '1', language: 'Spanish' }, + { id: '2', language: 'French' }, + ], + columnDefs: [ + { + field: 'language', + cellEditor: 'agRichSelectCellEditor', + cellEditorParams: { values: getValuesAsync }, + editable: true, + }, + ], + modules: [ClientSideRowModelModule, TextEditorModule, RichSelectModule], + }); + + // Double-click first cell to start editing + const cell0 = getByTestId(gridDiv, agTestIdFor.cell('0', 'language')); + await user.dblClick(cell0); + + // Verify the RichSelect popup/picker opened (even with async values loading) + await waitFor(() => { + const expanded = gridDiv.querySelector('.ag-picker-expanded'); + expect(expanded).toBeTruthy(); + }); + + // Tab to next row before values have loaded + await user.keyboard('{Tab}'); + + // Verify the new RichSelect picker opened on the next cell (showing "loading" state) + await waitFor(() => { + const expanded = gridDiv.querySelector('.ag-picker-expanded'); + expect(expanded).toBeTruthy(); + }); + }); + + // Bug 3: RichSelect multi-column tabbing in both directions + test('agRichSelectCellEditor multi-column tabbing shows picker in both directions in React', async () => { + const languages = ['English', 'Spanish', 'French', 'Portuguese', 'German']; + + const richSelectCol = (field: string) => ({ + field, + cellEditor: 'agRichSelectCellEditor' as const, + cellEditorParams: { values: languages }, + editable: true, + }); + + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', a: 'English', b: 'Spanish', c: 'French' }, + { id: '1', a: 'German', b: 'English', c: 'Spanish' }, + { id: '2', a: 'French', b: 'German', c: 'English' }, + ], + columnDefs: [richSelectCol('a'), richSelectCol('b'), richSelectCol('c')], + modules: [ClientSideRowModelModule, TextEditorModule, RichSelectModule], + }); + + // Double-click first cell to start editing col 'a' row 0 + const cellA = getByTestId(gridDiv, agTestIdFor.cell('0', 'a')); + await user.dblClick(cellA); + + // Verify picker opened + await waitFor(() => { + const expanded = gridDiv.querySelector('.ag-picker-expanded'); + expect(expanded).toBeTruthy(); + }); + + // Tab forward through: a→b, b→c, c→(next row a) — crosses row boundary + for (let i = 0; i < 3; i++) { + await user.keyboard('{Tab}'); + + await waitFor(() => { + const expanded = gridDiv.querySelector('.ag-picker-expanded'); + expect(expanded).toBeTruthy(); + }); + } + + // Shift+Tab backward through: a→(prev row c), c→b — crosses row boundary back + for (let i = 0; i < 2; i++) { + await user.keyboard('{Shift>}{Tab}{/Shift}'); + + await waitFor(() => { + const expanded = gridDiv.querySelector('.ag-picker-expanded'); + expect(expanded).toBeTruthy(); + }); + } + }); + + // agTextCellEditor: tabbing across rows preserves editing and focus + test('agTextCellEditor maintains editing when tabbing across rows in React', async () => { + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', a: 'A1', b: 'B1' }, + { id: '1', a: 'A2', b: 'B2' }, + ], + columnDefs: [ + { field: 'a', editable: true }, + { field: 'b', editable: true }, + ], + modules: [ClientSideRowModelModule, TextEditorModule], + }); + + // Double-click first cell to start editing with default text editor + const cellA = getByTestId(gridDiv, agTestIdFor.cell('0', 'a')); + await user.dblClick(cellA); + + await waitFor(() => { + const input = gridDiv.querySelector('.ag-cell-edit-wrapper input'); + expect(input).toBeTruthy(); + }); + + // Tab through a→b, b→(next row a) — crosses row boundary + await user.keyboard('{Tab}'); + await user.keyboard('{Tab}'); + + // Verify editing is active on row 1 + await waitFor(() => { + const cell = getByTestId(gridDiv, agTestIdFor.cell('1', 'a')); + expect(cell.querySelector('.ag-cell-edit-wrapper input')).toBeTruthy(); + }); + }); + + // agNumberCellEditor: tabbing across rows preserves editing and focus + test('agNumberCellEditor maintains editing when tabbing across rows in React', async () => { + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', price: 10, qty: 5 }, + { id: '1', price: 20, qty: 3 }, + ], + columnDefs: [ + { field: 'price', editable: true, cellEditor: 'agNumberCellEditor' }, + { field: 'qty', editable: true, cellEditor: 'agNumberCellEditor' }, + ], + modules: [ClientSideRowModelModule, TextEditorModule, NumberEditorModule], + }); + + // Double-click first cell to start editing + const cellPrice = getByTestId(gridDiv, agTestIdFor.cell('0', 'price')); + await user.dblClick(cellPrice); + + await waitFor(() => { + const input = gridDiv.querySelector('.ag-cell-edit-wrapper input[type="number"]'); + expect(input).toBeTruthy(); + }); + + // Tab through price→qty, qty→(next row price) — crosses row boundary + await user.keyboard('{Tab}'); + await user.keyboard('{Tab}'); + + // Verify number editor is active on row 1 + await waitFor(() => { + const cell = getByTestId(gridDiv, agTestIdFor.cell('1', 'price')); + expect(cell.querySelector('.ag-cell-edit-wrapper input[type="number"]')).toBeTruthy(); + }); + }); + + // agTextCellEditor: Shift+Tab backward across rows + test('agTextCellEditor maintains editing when Shift+Tab across rows in React', async () => { + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', a: 'A1', b: 'B1' }, + { id: '1', a: 'A2', b: 'B2' }, + ], + columnDefs: [ + { field: 'a', editable: true }, + { field: 'b', editable: true }, + ], + modules: [ClientSideRowModelModule, TextEditorModule], + }); + + // Double-click cell (1, 'a') to start editing on row 1 + const cellA1 = getByTestId(gridDiv, agTestIdFor.cell('1', 'a')); + await user.dblClick(cellA1); + + await waitFor(() => { + const input = gridDiv.querySelector('.ag-cell-edit-wrapper input'); + expect(input).toBeTruthy(); + }); + + // Shift+Tab backward: a→(prev row b) — crosses row boundary back + await user.keyboard('{Shift>}{Tab}{/Shift}'); + + // Verify editing is active on row 0 col b + await waitFor(() => { + const cell = getByTestId(gridDiv, agTestIdFor.cell('0', 'b')); + expect(cell.querySelector('.ag-cell-edit-wrapper input')).toBeTruthy(); + }); + }); + + describe('editType: fullRow', () => { + // fullRow: cellStartedEdit is true for the focused cell on initial dblClick + test('fullRow: cellStartedEdit is true for the focused cell on initial edit', async () => { + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', a: 'A1', b: 'B1', c: 'C1' }, + { id: '1', a: 'A2', b: 'B2', c: 'C2' }, + ], + columnDefs: [ + { field: 'a', editable: true, cellEditor: TrackingEditor }, + { field: 'b', editable: true, cellEditor: TrackingEditor }, + { field: 'c', editable: true, cellEditor: TrackingEditor }, + ], + modules: [ClientSideRowModelModule, TextEditorModule, CustomEditorModule], + editType: 'fullRow', + }); + + // Double-click first cell to start full-row editing + const cellA = getByTestId(gridDiv, agTestIdFor.cell('0', 'a')); + await user.dblClick(cellA); + + // In fullRow mode, editors are created for the row (async in React). + // The focused cell (col 'a') should have cellStartedEdit=true + await waitFor(() => { + expect(editorLog.length).toBeGreaterThanOrEqual(1); + expect(editorLog.some((e) => e.cellStartedEdit)).toBe(true); + }); + }); + + // fullRow: tabbing across rows creates editors for the new row + test('fullRow: editors are created when tabbing to next row', async () => { + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', a: 'A1', b: 'B1', c: 'C1' }, + { id: '1', a: 'A2', b: 'B2', c: 'C2' }, + ], + columnDefs: [ + { field: 'a', editable: true, cellEditor: TrackingEditor }, + { field: 'b', editable: true, cellEditor: TrackingEditor }, + { field: 'c', editable: true, cellEditor: TrackingEditor }, + ], + modules: [ClientSideRowModelModule, TextEditorModule, CustomEditorModule], + editType: 'fullRow', + }); + + // Double-click first cell to start full-row editing + const cellA = getByTestId(gridDiv, agTestIdFor.cell('0', 'a')); + await user.dblClick(cellA); + + await waitFor(() => { + expect(editorLog.length).toBeGreaterThanOrEqual(1); + }); + + // Tab through a→b, b→c, c→(next row a) — crosses row boundary + await user.keyboard('{Tab}'); + await user.keyboard('{Tab}'); + await user.keyboard('{Tab}'); + + // New editors should be created for row 1 + await waitFor(() => { + expect(editorLog.filter((e) => e.rowIndex === 1).length).toBeGreaterThanOrEqual(1); + }); + }); + + // fullRow: cellStartedEdit is true for the focused cell when tabbing across rows + test('fullRow: cellStartedEdit is true for the focused cell when tabbing to next row', async () => { + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', a: 'A1', b: 'B1', c: 'C1' }, + { id: '1', a: 'A2', b: 'B2', c: 'C2' }, + ], + columnDefs: [ + { field: 'a', editable: true, cellEditor: TrackingEditor }, + { field: 'b', editable: true, cellEditor: TrackingEditor }, + { field: 'c', editable: true, cellEditor: TrackingEditor }, + ], + modules: [ClientSideRowModelModule, TextEditorModule, CustomEditorModule], + editType: 'fullRow', + }); + + // Double-click first cell to start full-row editing + const cellA = getByTestId(gridDiv, agTestIdFor.cell('0', 'a')); + await user.dblClick(cellA); + + await waitFor(() => { + expect(editorLog.length).toBeGreaterThanOrEqual(1); + }); + + // Tab through a→b, b→c, c→(next row a) — crosses row boundary + await user.keyboard('{Tab}'); + await user.keyboard('{Tab}'); + await user.keyboard('{Tab}'); + + // Wait for row 1 editors to be created (React async rendering) + await waitFor(() => { + expect(editorLog.filter((e) => e.rowIndex === 1).length).toBeGreaterThanOrEqual(1); + }); + + // The focused cell (col 'a') on row 1 should have cellStartedEdit=true + const row1FocusedEditor = editorLog.find((e) => e.rowIndex === 1 && e.column === 'a'); + expect(row1FocusedEditor).toBeDefined(); + expect(row1FocusedEditor!.cellStartedEdit).toBe(true); + + // Non-focused cells on row 1 should have cellStartedEdit=false + const row1NonFocused = editorLog.filter((e) => e.rowIndex === 1 && e.column !== 'a'); + for (const editor of row1NonFocused) { + expect(editor.cellStartedEdit).toBe(false); + } + }); + + // fullRow: RichSelect picker opens when tabbing across rows + test('fullRow: agRichSelectCellEditor opens picker when tabbing to next row', async () => { + const languages = ['English', 'Spanish', 'French', 'Portuguese', 'German']; + + const { gridDiv, user } = await renderGrid({ + rowData: [ + { id: '0', a: 'English', b: 'Spanish', c: 'French' }, + { id: '1', a: 'German', b: 'English', c: 'Spanish' }, + ], + columnDefs: [ + { + field: 'a', + cellEditor: 'agRichSelectCellEditor', + cellEditorParams: { values: languages }, + editable: true, + }, + { + field: 'b', + cellEditor: 'agRichSelectCellEditor', + cellEditorParams: { values: languages }, + editable: true, + }, + { + field: 'c', + cellEditor: 'agRichSelectCellEditor', + cellEditorParams: { values: languages }, + editable: true, + }, + ], + modules: [ClientSideRowModelModule, TextEditorModule, RichSelectModule], + editType: 'fullRow', + }); + + // Double-click first cell to start full-row editing + const cellA = getByTestId(gridDiv, agTestIdFor.cell('0', 'a')); + await user.dblClick(cellA); + + // Verify RichSelect picker opened for focused cell + await waitFor(() => { + const expanded = gridDiv.querySelector('.ag-picker-expanded'); + expect(expanded).toBeTruthy(); + }); + + // Tab through a→b, b→c, c→(next row a) — crosses row boundary + for (let i = 0; i < 3; i++) { + await user.keyboard('{Tab}'); + } + + // After crossing to row 1, picker should still open for the focused cell + await waitFor(() => { + const expanded = gridDiv.querySelector('.ag-picker-expanded'); + expect(expanded).toBeTruthy(); + }); + }); + }); +});