diff --git a/packages/ag-grid-community/src/edit/editService.ts b/packages/ag-grid-community/src/edit/editService.ts index 4e38d80dbc9..ebff3b45bf5 100644 --- a/packages/ag-grid-community/src/edit/editService.ts +++ b/packages/ag-grid-community/src/edit/editService.ts @@ -898,20 +898,34 @@ export class EditService extends BeanStub implements NamedBean, IEditService { public setDataValue(position: Required, newValue: any, eventSource?: string): boolean | undefined { try { - if ((!this.isEditing() || this.committing) && !SET_DATA_SOURCE_AS_API.has(eventSource)) { - return; + const batch = this.batch; + const editing = this.isEditing(batch ? undefined : position); + + if ((!editing || this.committing) && !SET_DATA_SOURCE_AS_API.has(eventSource)) { + return; // Ignore non-edit edits that are not treated as API sources. + } + + if (!editing && !batch && eventSource === 'paste') { + return; // Paste on non editable cells and not batching } - const { beans } = this; + const beans = this.beans; this.strategy ??= this.createStrategy(); - const source = this.batch ? 'ui' : this.committing ? eventSource ?? 'api' : 'api'; + let source: string; + if (batch) { + source = 'ui'; + } else if (this.committing) { + source = eventSource ?? 'api'; + } else { + source = 'api'; + } if (!eventSource || KEEP_EDITOR_SOURCES.has(eventSource)) { // editApi or undoRedoApi apply change without involving the editor _syncFromEditor(beans, position, newValue, eventSource, undefined, { persist: true }); - if (this.batch) { + if (batch) { this.cleanupEditors(); _purgeUnchangedEdits(beans); diff --git a/packages/ag-grid-community/src/gridBodyComp/gridBodyCtrl.ts b/packages/ag-grid-community/src/gridBodyComp/gridBodyCtrl.ts index 4a76a5ffc42..bb491dde2e1 100644 --- a/packages/ag-grid-community/src/gridBodyComp/gridBodyCtrl.ts +++ b/packages/ag-grid-community/src/gridBodyComp/gridBodyCtrl.ts @@ -289,12 +289,22 @@ export class GridBodyCtrl extends BeanStub { } public isVerticalScrollShowing(): boolean { - const show = this.gos.get('alwaysShowVerticalScroll'); + const { gos, comp, ctrlsSvc } = this; + const show = gos.get('alwaysShowVerticalScroll'); + const cssClass = show ? CSS_CLASS_FORCE_VERTICAL_SCROLL : null; - const allowVerticalScroll = _isDomLayout(this.gos, 'normal'); - this.comp.setAlwaysVerticalScrollClass(cssClass, show); - const horizontalScrollElement = this.ctrlsSvc.get('center')?.eViewport; - return show || (allowVerticalScroll && _shouldShowVerticalScroll(this.eBodyViewport, horizontalScrollElement)); + const allowVerticalScroll = _isDomLayout(gos, 'normal'); + + comp.setAlwaysVerticalScrollClass(cssClass, show); + const horizontalScrollElement = ctrlsSvc.get('center')?.eViewport; + const hScrollEl = ctrlsSvc.get('fakeHScrollComp')?.getGui(); + const vScrollEl = ctrlsSvc.get('fakeVScrollComp')?.getGui(); + + return ( + show || + (allowVerticalScroll && + _shouldShowVerticalScroll(this.eBodyViewport, horizontalScrollElement, undefined, vScrollEl, hScrollEl)) + ); } private setupRowAnimationCssClass(): void { diff --git a/packages/ag-grid-community/src/gridBodyComp/rowContainer/rowContainerCtrl.ts b/packages/ag-grid-community/src/gridBodyComp/rowContainer/rowContainerCtrl.ts index 46f42d70bce..cf65cf5c629 100644 --- a/packages/ag-grid-community/src/gridBodyComp/rowContainer/rowContainerCtrl.ts +++ b/packages/ag-grid-community/src/gridBodyComp/rowContainer/rowContainerCtrl.ts @@ -461,9 +461,17 @@ export class RowContainerCtrl extends BeanStub implements ScrollPartner { } public isHorizontalScrollShowing(): boolean { - const isAlwaysShowHorizontalScroll = this.gos.get('alwaysShowHorizontalScroll'); - const verticalScrollElement = this.beans.ctrlsSvc.getGridBodyCtrl()?.eBodyViewport; - return isAlwaysShowHorizontalScroll || _shouldShowHorizontalScroll(this.eViewport, verticalScrollElement); + const { beans, gos, eViewport } = this; + const isAlwaysShowHorizontalScroll = gos.get('alwaysShowHorizontalScroll'); + const { ctrlsSvc } = beans; + const verticalScrollElement = ctrlsSvc.getGridBodyCtrl()?.eBodyViewport; + const hScrollEl = ctrlsSvc.get('fakeHScrollComp')?.getGui(); + const vScrollEl = ctrlsSvc.get('fakeVScrollComp')?.getGui(); + + return ( + isAlwaysShowHorizontalScroll || + _shouldShowHorizontalScroll(eViewport, verticalScrollElement, undefined, hScrollEl, vScrollEl) + ); } public setHorizontalScroll(offset: number): void { diff --git a/packages/ag-grid-community/src/gridBodyComp/scrollbarVisibilityHelper.test.ts b/packages/ag-grid-community/src/gridBodyComp/scrollbarVisibilityHelper.test.ts index a884787035c..83472769a17 100644 --- a/packages/ag-grid-community/src/gridBodyComp/scrollbarVisibilityHelper.test.ts +++ b/packages/ag-grid-community/src/gridBodyComp/scrollbarVisibilityHelper.test.ts @@ -59,6 +59,27 @@ describe('scrollbarVisibilityHelper', () => { setSizes({ scrollHeight: 600, scrollWidth: 515 }); expect(_shouldShowHorizontalScroll(element, element, scrollbarWidth)).toBe(true); }); + + test('scrollbar suppression respects scrollbar element visibility', () => { + const { element: horizontal } = createElementWithSizes({ + clientWidth: 500, + scrollWidth: 508, + }); + const { element: vertical } = createElementWithSizes({ + clientHeight: 500, + scrollHeight: 508, + }); + const { element: hScrollbar, setVisible: setHVisible } = createVisibilityElement(false); + const { element: vScrollbar, setVisible: setVVisible } = createVisibilityElement(true); + + expect(_shouldShowHorizontalScroll(horizontal, vertical, scrollbarWidth, hScrollbar, vScrollbar)).toBe(true); + + setHVisible(true); + expect(_shouldShowHorizontalScroll(horizontal, vertical, scrollbarWidth, hScrollbar, vScrollbar)).toBe(false); + + setVVisible(false); + expect(_shouldShowHorizontalScroll(horizontal, vertical, scrollbarWidth, hScrollbar, vScrollbar)).toBe(true); + }); }); type ElementDimensions = { @@ -93,3 +114,18 @@ function createElementWithSizes(initial: ElementDimensions) { }, }; } + +function createVisibilityElement(initiallyVisible: boolean) { + let visible = initiallyVisible; + const element = document.createElement('div') as HTMLElement & { + checkVisibility?: (options?: unknown) => boolean; + }; + element.checkVisibility = () => visible; + + return { + element, + setVisible(nextVisible: boolean) { + visible = nextVisible; + }, + }; +} diff --git a/packages/ag-grid-community/src/gridBodyComp/scrollbarVisibilityHelper.ts b/packages/ag-grid-community/src/gridBodyComp/scrollbarVisibilityHelper.ts index e0bb4e4eb5f..020fb4eba4c 100644 --- a/packages/ag-grid-community/src/gridBodyComp/scrollbarVisibilityHelper.ts +++ b/packages/ag-grid-community/src/gridBodyComp/scrollbarVisibilityHelper.ts @@ -1,4 +1,5 @@ import { _getScrollbarWidth } from '../agStack/utils/browser'; +import { _isVisible } from '../agStack/utils/dom'; const AXES = { horizontal: { @@ -18,27 +19,49 @@ const AXES = { export function _shouldShowHorizontalScroll( horizontalElement: HTMLElement, verticalScrollElement?: HTMLElement, - scrollbarWidth: number = _getScrollbarWidth() || 0 + scrollbarWidth: number = _getScrollbarWidth() || 0, + primaryScrollbarElement?: HTMLElement, + oppositeScrollbarElement?: HTMLElement ): boolean { - return shouldShowScroll(horizontalElement, verticalScrollElement, 'horizontal', scrollbarWidth); + return shouldShowScroll( + horizontalElement, + verticalScrollElement, + 'horizontal', + scrollbarWidth, + primaryScrollbarElement, + oppositeScrollbarElement + ); } export function _shouldShowVerticalScroll( verticalElement: HTMLElement, horizontalScrollElement?: HTMLElement, - scrollbarWidth: number = _getScrollbarWidth() || 0 + scrollbarWidth: number = _getScrollbarWidth() || 0, + primaryScrollbarElement?: HTMLElement, + oppositeScrollbarElement?: HTMLElement ): boolean { - return shouldShowScroll(verticalElement, horizontalScrollElement, 'vertical', scrollbarWidth); + return shouldShowScroll( + verticalElement, + horizontalScrollElement, + 'vertical', + scrollbarWidth, + primaryScrollbarElement, + oppositeScrollbarElement + ); } function shouldShowScroll( primaryElement: HTMLElement, oppositeElement: HTMLElement | undefined, axis: 'horizontal' | 'vertical', - scrollbarWidth: number + scrollbarWidth: number, + primaryScrollbarElement: HTMLElement | undefined, + oppositeScrollbarElement: HTMLElement | undefined ): boolean { const primary = AXES[axis]; const opposite = AXES[primary.opposite]; + const primaryScrollbarShowing = primaryScrollbarElement ? _isVisible(primaryScrollbarElement) : true; + const oppositeScrollbarShowing = oppositeScrollbarElement ? _isVisible(oppositeScrollbarElement) : true; const primaryOverflow = primary.overflow(primaryElement); if (primaryOverflow <= 0) { @@ -55,14 +78,16 @@ function shouldShowScroll( } if (primaryOverflow <= scrollbarWidth) { - const oppositeCausedByPrimary = isScrollbarCausedByOppositeAxis({ - candidateOverflow: oppositeOverflow, - candidateScrollSize: opposite.scrollSize(oppositeElement), - candidateClientSize: opposite.clientSize(oppositeElement), - scrollbarWidth, - }); - - if (oppositeCausedByPrimary) { + if ( + primaryScrollbarShowing && + oppositeScrollbarShowing && + isScrollbarCausedByOppositeAxis({ + candidateOverflow: oppositeOverflow, + candidateScrollSize: opposite.scrollSize(oppositeElement), + candidateClientSize: opposite.clientSize(oppositeElement), + scrollbarWidth, + }) + ) { // The opposite scrollbar only exists because of this one, so suppress this scrollbar. return false; } diff --git a/packages/ag-grid-enterprise/src/widgets/formulaInputAutocompleteFeature.ts b/packages/ag-grid-enterprise/src/widgets/formulaInputAutocompleteFeature.ts index 428d50160c9..3e365aec755 100644 --- a/packages/ag-grid-enterprise/src/widgets/formulaInputAutocompleteFeature.ts +++ b/packages/ag-grid-enterprise/src/widgets/formulaInputAutocompleteFeature.ts @@ -90,7 +90,9 @@ export class FormulaInputAutocompleteFeature extends BeanStub { return; } - const value = this.field.getCurrentValue(); + const { field, beans } = this; + + const value = field.getCurrentValue(); const hasFormulaPrefix = value.trimStart().startsWith('='); if (!hasFormulaPrefix) { @@ -98,31 +100,38 @@ export class FormulaInputAutocompleteFeature extends BeanStub { return; } - const caretOffsets = this.field.getCaretOffsetsForAutocomplete(value); + const caretOffsets = field.getCaretOffsetsForAutocomplete(value); if (!caretOffsets) { this.closeFunctionAutocomplete(); return; } - if (isCaretInsideRefToken(this.beans, value, caretOffsets.valueOffset)) { + if (isCaretInsideRefToken(beans, value, caretOffsets.valueOffset)) { this.closeFunctionAutocomplete(); return; } - const token = getFunctionTokenAtOffset(value, caretOffsets.valueOffset, this.beans.formula ?? null); + const token = getFunctionTokenAtOffset(value, caretOffsets.valueOffset, beans.formula ?? null); if (!token) { this.closeFunctionAutocomplete(); return; } + const { prefix } = token; + + if (!prefix.length) { + this.closeFunctionAutocomplete(); + return; + } + const entries = this.getFunctionAutocompleteEntries(); if (!entries.length) { this.closeFunctionAutocomplete(); return; } - const searchLower = token.prefix.toLocaleLowerCase(); - const hasMatch = entries.some((entry) => entry.key.toLocaleLowerCase().startsWith(searchLower)); + const searchLower = prefix.toLocaleLowerCase(); + const hasMatch = entries.some(({ key }) => key.toLocaleLowerCase().startsWith(searchLower)); if (!hasMatch) { this.closeFunctionAutocomplete(); @@ -132,9 +141,9 @@ export class FormulaInputAutocompleteFeature extends BeanStub { this.functionAutocompleteToken = token; this.openFunctionAutocomplete(entries); - if (this.functionAutocompleteList && this.functionAutocompleteSearch !== token.prefix) { - this.functionAutocompleteList.setSearch(token.prefix); - this.functionAutocompleteSearch = token.prefix; + if (this.functionAutocompleteList && this.functionAutocompleteSearch !== prefix) { + this.functionAutocompleteList.setSearch(prefix); + this.functionAutocompleteSearch = prefix; } } @@ -222,15 +231,16 @@ export class FormulaInputAutocompleteFeature extends BeanStub { return; } - const value = this.field.getCurrentValue(); + const { field } = this; + const value = field.getCurrentValue(); const functionName = selected.key; const baseValue = value.slice(0, token.start) + functionName + value.slice(token.end); const insertPos = token.start + functionName.length; const nextValue = baseValue[insertPos] === '(' ? baseValue : baseValue.slice(0, insertPos) + '(' + baseValue.slice(insertPos); - this.field.getContentElement().focus({ preventScroll: true }); - this.field.applyFormulaValueChange({ + field.getContentElement().focus({ preventScroll: true }); + field.applyFormulaValueChange({ currentValue: value, nextValue, caret: insertPos + 1, 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 f66c5495d74..2254790ee8c 100644 --- a/testing/behavioural/src/cell-editing/cell-editing-batch.test.ts +++ b/testing/behavioural/src/cell-editing/cell-editing-batch.test.ts @@ -5,7 +5,7 @@ import { userEvent } from '@testing-library/user-event'; import { agTestIdFor, getGridElement, setupAgTestIds } from 'ag-grid-community'; import { BatchEditModule } from 'ag-grid-enterprise'; -import { TestGridsManager, asyncSetTimeout, waitForInput } from '../test-utils'; +import { GridRows, TestGridsManager, asyncSetTimeout, waitForInput } from '../test-utils'; import { expect } from '../test-utils/matchers'; describe('Cell Editing Batch', () => { @@ -216,10 +216,14 @@ describe('Cell Editing Batch', () => { const cellB = getByTestId(gridDiv, agTestIdFor.cell('0', 'b')); expect(cellB).toHaveTextContent('initial'); - await userEvent.dblClick(cellA); - const editor = await waitForInput(gridDiv, cellA, { popup: false }); + api.startEditingCell({ rowIndex: 0, colKey: 'a' }); + await asyncSetTimeout(1); + const editor = gridDiv.querySelector('input'); + if (!editor) { + throw new Error('Editor input not found'); + } await userEvent.clear(editor); - await userEvent.type(editor, 'xx{Enter}'); + await userEvent.keyboard('xx{Enter}'); await asyncSetTimeout(1); api.refreshCells({ columns: ['b'], force: true }); @@ -250,4 +254,60 @@ describe('Cell Editing Batch', () => { expect(cellB).toHaveTextContent('xx'); }); + + test('setDataValue during batch edit is staged for new cells', async () => { + const api = await gridMgr.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'number', editable: true, cellEditor: 'agNumberCellEditor' }, + { field: 'string1', editable: true, cellEditor: 'agTextCellEditor' }, + ], + rowData: [{ number: 10, string1: 'test' }], + }); + + api.startBatchEdit(); + + const beforeRows = new GridRows(api, 'before batch setDataValue'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 number:10 string1:"test" + `); + + const gridDiv = getGridElement(api)! as HTMLElement; + await asyncSetTimeout(1); + const numberCell = getByTestId(gridDiv, agTestIdFor.cell('0', 'number')); + const stringCell = getByTestId(gridDiv, agTestIdFor.cell('0', 'string1')); + + await userEvent.dblClick(numberCell); + await asyncSetTimeout(1); + await userEvent.keyboard('100{Enter}'); + await asyncSetTimeout(1); + + expect(numberCell).toHaveTextContent('100'); + expect(numberCell).toHaveClass(/ag-cell-batch-edit/); + + const rowNode = api.getDisplayedRowAtIndex(0); + rowNode?.setDataValue('string1', 'pending', 'ui'); + await asyncSetTimeout(1); + + await new GridRows(api, 'after batch setDataValue ui').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 number:100 string1:"pending" + `); + + expect(stringCell).toHaveTextContent('pending'); + + api.cancelBatchEdit(); + await asyncSetTimeout(1); + + await new GridRows(api, 'after cancel batch setDataValue').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 number:10 string1:"test" + `); + + expect(numberCell).toHaveTextContent('10'); + expect(stringCell).toHaveTextContent('test'); + expect(numberCell).not.toHaveClass(/ag-cell-batch-edit/); + expect(stringCell).not.toHaveClass(/ag-cell-batch-edit/); + expect(api.getDisplayedRowAtIndex(0)?.data?.string1).toBe('test'); + }); }); diff --git a/testing/behavioural/src/cell-editing/clipboard/clipboard-fill-handle.test.ts b/testing/behavioural/src/cell-editing/clipboard/clipboard-fill-handle.test.ts new file mode 100644 index 00000000000..9771eaffa52 --- /dev/null +++ b/testing/behavioural/src/cell-editing/clipboard/clipboard-fill-handle.test.ts @@ -0,0 +1,187 @@ +import { getByTestId } from '@testing-library/dom'; +import '@testing-library/jest-dom'; +import { userEvent } from '@testing-library/user-event'; + +import { TextEditorModule, UndoRedoEditModule, agTestIdFor, getGridElement, setupAgTestIds } from 'ag-grid-community'; +import { BatchEditModule, CellSelectionModule, ClipboardModule } from 'ag-grid-enterprise'; + +import { + EditEventTracker, + GridRows, + TestGridsManager, + asyncSetTimeout, + clipboardUtils, + waitForEvent, +} from '../../test-utils'; + +describe('Clipboard Paste Behaviour: fill handle', () => { + const gridMgr = new TestGridsManager({ + modules: [ClipboardModule, CellSelectionModule, BatchEditModule, UndoRedoEditModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + clipboardUtils.init(); + }); + + beforeEach(() => { + clipboardUtils.init(); + }); + + afterEach(() => { + gridMgr.reset(); + clipboardUtils.reset(); + }); + + test('fill handle after paste should only update each target once', async () => { + let valueSetterCalls = 0; + let lastSetValue: string | undefined; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + lastSetValue = newValue; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait('clipboardGridFillHandle', { + cellSelection: { + handle: { + mode: 'fill', + }, + }, + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [ + { id: 'ROW_0', field: 'Top Value' }, + { id: 'ROW_1', field: 'Bottom Value' }, + { id: 'ROW_2', field: 'Bottom Value 2' }, + ], + getRowId: (params) => params.data.id, + }); + + const eventTracker = new EditEventTracker(api); + + const gridDiv = getGridElement(api)! as HTMLElement; + + const beforeRows = new GridRows(api, 'before fill handle paste'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 field:"Top Value" + ├── LEAF id:ROW_1 field:"Bottom Value" + └── LEAF id:ROW_2 field:"Bottom Value 2" + `); + + clipboardUtils.setText('Top Value'); + api.setFocusedCell(1, 'field'); + const pasteEnd = waitForEvent('pasteEnd', api); + api.pasteFromClipboard(); + await pasteEnd; + + const afterPasteRows = new GridRows(api, 'after paste before fill'); + await afterPasteRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 field:"Top Value" + ├── LEAF id:ROW_1 field:"Top Value" + └── LEAF id:ROW_2 field:"Bottom Value 2" + `); + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 0, + cellEditingStopped: 0, + cellValueChanged: 1, + rowValueChanged: 0, + cellEditRequest: 0, + }); + + await asyncSetTimeout(1); + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_1', 'field')); + const cellSelectionChanged = waitForEvent('cellSelectionChanged', api); + cell.dispatchEvent(new MouseEvent('touchstart', { bubbles: true })); + await cellSelectionChanged; + await asyncSetTimeout(1); + + const fillHandle = getByTestId(gridDiv, agTestIdFor.fillHandle()); + const fillEnd = waitForEvent('fillEnd', api); + await userEvent.dblClick(fillHandle); + await fillEnd; + + const afterFillRows = new GridRows(api, 'after fill handle paste'); + await afterFillRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 field:"Top Value" + ├── LEAF id:ROW_1 field:"Top Value" + └── LEAF id:ROW_2 field:"Top Value" + `); + + expect(lastSetValue).toBe('Top Value'); + expect(valueSetterTargets).toEqual(['ROW_1', 'ROW_2']); + expect(valueSetterCalls).toBe(2); + }); + + test('readOnlyEdit fill handle fires cellEditRequest once per target', async () => { + const editRequests: string[] = []; + + const api = await gridMgr.createGridAndWait('clipboardGridReadOnlyFill', { + readOnlyEdit: true, + cellSelection: { + handle: { + mode: 'fill', + }, + }, + columnDefs: [ + { + field: 'field', + editable: true, + }, + ], + rowData: [ + { id: 'ROW_0', field: 'Top Value' }, + { id: 'ROW_1', field: 'Bottom Value' }, + { id: 'ROW_2', field: 'Bottom Value 2' }, + ], + getRowId: (params) => params.data.id, + onCellEditRequest: (event) => { + editRequests.push(`${event.node?.id ?? 'unknown'}:${event.colDef.field}:${event.newValue}`); + }, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + + const beforeRows = new GridRows(api, 'before readOnly fill handle'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 field:"Top Value" + ├── LEAF id:ROW_1 field:"Bottom Value" + └── LEAF id:ROW_2 field:"Bottom Value 2" + `); + + await asyncSetTimeout(1); + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'field')); + const cellSelectionChanged = waitForEvent('cellSelectionChanged', api); + cell.dispatchEvent(new MouseEvent('touchstart', { bubbles: true })); + await cellSelectionChanged; + await asyncSetTimeout(1); + + const fillHandle = getByTestId(gridDiv, agTestIdFor.fillHandle()); + const fillEnd = waitForEvent('fillEnd', api); + await userEvent.dblClick(fillHandle); + await fillEnd; + + const afterRows = new GridRows(api, 'after readOnly fill handle'); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 field:"Top Value" + ├── LEAF id:ROW_1 field:"Bottom Value" + └── LEAF id:ROW_2 field:"Bottom Value 2" + `); + + expect(editRequests).toEqual(['ROW_1:field:Top Value', 'ROW_2:field:Top Value']); + }); +}); diff --git a/testing/behavioural/src/cell-editing/clipboard/clipboard-paste.test.ts b/testing/behavioural/src/cell-editing/clipboard/clipboard-paste.test.ts new file mode 100644 index 00000000000..504705cdc54 --- /dev/null +++ b/testing/behavioural/src/cell-editing/clipboard/clipboard-paste.test.ts @@ -0,0 +1,497 @@ +import { getByTestId, waitFor } from '@testing-library/dom'; +import '@testing-library/jest-dom'; +import { userEvent } from '@testing-library/user-event'; + +import { TextEditorModule, UndoRedoEditModule, agTestIdFor, getGridElement, setupAgTestIds } from 'ag-grid-community'; +import { BatchEditModule, CellSelectionModule, ClipboardModule } from 'ag-grid-enterprise'; + +import { + EditEventTracker, + GridRows, + TestGridsManager, + asyncSetTimeout, + clipboardUtils, + waitForEvent, +} from '../../test-utils'; + +describe('Clipboard Paste Behaviour: paste flows', () => { + const gridMgr = new TestGridsManager({ + modules: [ClipboardModule, CellSelectionModule, BatchEditModule, UndoRedoEditModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + clipboardUtils.init(); + }); + + beforeEach(() => { + clipboardUtils.init(); + }); + + afterEach(() => { + gridMgr.reset(); + clipboardUtils.reset(); + }); + + test('copy/paste should only update the destination cell once', async () => { + let valueSetterCalls = 0; + let lastSetValue: string | undefined; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + lastSetValue = newValue; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait('clipboardGrid', { + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [ + { id: 'ROW_0', field: 'Top Value' }, + { id: 'ROW_1', field: 'Bottom Value' }, + ], + }); + + const eventTracker = new EditEventTracker(api); + + const gridDiv = getGridElement(api)! as HTMLElement; + + const beforeRows = new GridRows(api, 'before paste'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 field:"Top Value" + └── LEAF id:1 field:"Bottom Value" + `); + + await asyncSetTimeout(0); + + const user = userEvent.setup({ skipHover: true }); + const sourceCell = getByTestId(gridDiv, agTestIdFor.cell('0', 'field')); + + await user.click(sourceCell); + api.setFocusedCell(0, 'field'); + await user.keyboard('{Control>}c{/Control}'); + + api.setFocusedCell(1, 'field'); + await user.keyboard('{Control>}v{/Control}'); + await asyncSetTimeout(5); + + const afterRows = new GridRows(api, 'after paste'); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 field:"Top Value" + └── LEAF id:1 field:"Top Value" + `); + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 0, + cellEditingStopped: 0, + cellValueChanged: 1, + rowValueChanged: 0, + cellEditRequest: 0, + }); + expect(lastSetValue).toBe('Top Value'); + expect(valueSetterTargets).toEqual(['ROW_1']); + expect(valueSetterCalls).toBe(1); + }); + + test('copy/paste APIs should only update the destination cell once', async () => { + let valueSetterCalls = 0; + let lastSetValue: string | undefined; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + lastSetValue = newValue; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait('clipboardGridApi', { + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [ + { id: 'ROW_0', field: 'Top Value' }, + { id: 'ROW_1', field: 'Bottom Value' }, + ], + }); + + const eventTracker = new EditEventTracker(api); + + const beforeRows = new GridRows(api, 'before api paste'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 field:"Top Value" + └── LEAF id:1 field:"Bottom Value" + `); + + clipboardUtils.setText('Top Value'); + api.setFocusedCell(1, 'field'); + const apiPasteEnd = waitForEvent('pasteEnd', api); + api.pasteFromClipboard(); + await apiPasteEnd; + + const afterRows = new GridRows(api, 'after api paste'); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 field:"Top Value" + └── LEAF id:1 field:"Top Value" + `); + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 0, + cellEditingStopped: 0, + cellValueChanged: 1, + rowValueChanged: 0, + cellEditRequest: 0, + }); + expect(lastSetValue).toBe('Top Value'); + expect(valueSetterTargets).toEqual(['ROW_1']); + expect(valueSetterCalls).toBe(1); + }); + + test('paste during edit session should only update the destination cell once', async () => { + let valueSetterCalls = 0; + let lastSetValue: string | undefined; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + lastSetValue = newValue; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait('clipboardGridEditSession', { + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [ + { id: 'ROW_0', field: 'Top Value' }, + { id: 'ROW_1', field: 'Bottom Value' }, + ], + }); + + const beforeRows = new GridRows(api, 'before edit session paste'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 field:"Top Value" + └── LEAF id:1 field:"Bottom Value" + `); + + api.setFocusedCell(0, 'field'); + api.addCellRange({ rowStartIndex: 0, rowEndIndex: 0, columns: ['field'] }); + api.copyToClipboard(); + + api.startEditingCell({ rowIndex: 1, colKey: 'field' }); + await asyncSetTimeout(1); + + api.setFocusedCell(1, 'field'); + const pasteEnd = waitForEvent('pasteEnd', api); + api.pasteFromClipboard(); + await pasteEnd; + + const afterRows = new GridRows(api, 'after edit session paste'); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 field:"Top Value" + └── LEAF id:1 field:"Top Value" + `); + + expect(lastSetValue).toBe('Top Value'); + expect(valueSetterTargets).toEqual(['ROW_1']); + expect(valueSetterCalls).toBe(1); + }); + + test('paste during batch edit should only update the destination cell once', async () => { + let valueSetterCalls = 0; + let lastSetValue: string | undefined; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + lastSetValue = newValue; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait('clipboardGridBatchEdit', { + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [ + { id: 'ROW_0', field: 'Top Value' }, + { id: 'ROW_1', field: 'Bottom Value' }, + ], + }); + + const beforeRows = new GridRows(api, 'before batch paste'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 field:"Top Value" + └── LEAF id:1 field:"Bottom Value" + `); + + api.startBatchEdit(); + + api.setFocusedCell(0, 'field'); + api.addCellRange({ rowStartIndex: 0, rowEndIndex: 0, columns: ['field'] }); + api.copyToClipboard(); + + api.setFocusedCell(1, 'field'); + api.pasteFromClipboard(); + await asyncSetTimeout(5); + + api.commitBatchEdit(); + await asyncSetTimeout(5); + + const afterRows = new GridRows(api, 'after batch paste'); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 field:"Top Value" + └── LEAF id:1 field:"Top Value" + `); + + expect(lastSetValue).toBe('Top Value'); + expect(valueSetterTargets).toEqual(['ROW_1']); + expect(valueSetterCalls).toBe(1); + }); + + test('batch edit paste should stage data until commit', async () => { + let valueSetterCalls = 0; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait('clipboardGridBatchStage', { + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [ + { id: 'ROW_0', field: 'Top Value' }, + { id: 'ROW_1', field: 'Bottom Value' }, + ], + }); + + const beforeRows = new GridRows(api, 'before batch staging paste'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 field:"Top Value" + └── LEAF id:1 field:"Bottom Value" + `); + + api.startBatchEdit(); + + api.setFocusedCell(0, 'field'); + api.addCellRange({ rowStartIndex: 0, rowEndIndex: 0, columns: ['field'] }); + api.copyToClipboard(); + + api.setFocusedCell(1, 'field'); + api.pasteFromClipboard(); + await asyncSetTimeout(5); + + const stagedRows = new GridRows(api, 'staged batch paste'); + await stagedRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 field:"Top Value" + └── LEAF id:1 field:"Top Value" + `); + + const stagedRowNode = api.getDisplayedRowAtIndex(1); + expect(stagedRowNode?.data?.field).toBe('Bottom Value'); + + api.commitBatchEdit(); + await asyncSetTimeout(5); + + const afterRows = new GridRows(api, 'after batch staging commit'); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 field:"Top Value" + └── LEAF id:1 field:"Top Value" + `); + + const committedRowNode = api.getDisplayedRowAtIndex(1); + expect(committedRowNode?.data?.field).toBe('Top Value'); + expect(valueSetterTargets).toEqual(['ROW_1']); + expect(valueSetterCalls).toBe(1); + }); + + test.each([false, true])( + 'full-row editing paste fires rowValueChanged once per row (batch=%s)', + async (batchEnabled) => { + const rowValueChangedNodes: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait(`clipboardGridFullRowPaste-${batchEnabled}`, { + editType: 'fullRow', + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [ + { id: 'ROW_0', field: 'Top Value' }, + { id: 'ROW_1', field: 'Bottom Value' }, + ], + getRowId: (params) => params.data.id, + onRowValueChanged: (event) => { + if (event.node?.id) { + rowValueChangedNodes.push(String(event.node.id)); + } + }, + }); + + const beforeRows = new GridRows(api, `before full-row paste (batch=${batchEnabled})`); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 field:"Top Value" + └── LEAF id:ROW_1 field:"Bottom Value" + `); + + if (batchEnabled) { + api.startBatchEdit(); + } + + clipboardUtils.setText('Top Value'); + api.setFocusedCell(1, 'field'); + api.startEditingCell({ rowIndex: 1, colKey: 'field' }); + await asyncSetTimeout(1); + + const pasteEnd = waitForEvent('pasteEnd', api); + api.pasteFromClipboard(); + await pasteEnd; + + if (batchEnabled) { + api.commitBatchEdit(); + await asyncSetTimeout(0); + } + + const afterRows = new GridRows(api, `after full-row paste (batch=${batchEnabled})`); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 field:"Top Value" + └── LEAF id:ROW_1 field:"Top Value" + `); + + await waitFor(() => expect(new Set(rowValueChangedNodes)).toEqual(new Set(['ROW_1']))); + } + ); + + test.each([false, true])('open editor + paste keeps editor open (batch=%s)', async (batchEnabled) => { + const api = await gridMgr.createGridAndWait(`clipboardGridOpenPaste-${batchEnabled}`, { + cellSelection: true, + defaultColDef: { + editable: true, + }, + columnDefs: [{ field: 'field', editable: true }], + rowData: [ + { id: 'ROW_0', field: 'Top Value' }, + { id: 'ROW_1', field: 'Bottom Value' }, + ], + getRowId: (params) => params.data.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + await asyncSetTimeout(0); + + if (batchEnabled) { + api.startBatchEdit(); + } + + const user = userEvent.setup({ skipHover: true }); + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'field')); + await user.click(cell); + api.setFocusedCell(0, 'field'); + api.startEditingCell({ rowIndex: 0, colKey: 'field' }); + await waitFor(() => expect(api.getEditingCells().length).toBe(1)); + + clipboardUtils.setText('Top Value'); + api.setFocusedCell(0, 'field'); + const pasteEnd = waitForEvent('pasteEnd', api); + api.pasteFromClipboard(); + await pasteEnd; + + await waitFor(() => expect(api.getEditingCells().length).toBe(batchEnabled ? 1 : 0)); + + if (batchEnabled) { + api.commitBatchEdit(); + await asyncSetTimeout(0); + } + }); + + test('readOnlyEdit paste fires cellEditRequest once', async () => { + const editRequests: string[] = []; + + const api = await gridMgr.createGridAndWait('clipboardGridReadOnlyPaste', { + readOnlyEdit: true, + columnDefs: [ + { + field: 'field', + editable: true, + }, + ], + rowData: [ + { id: 'ROW_0', field: 'Top Value' }, + { id: 'ROW_1', field: 'Bottom Value' }, + ], + getRowId: (params) => params.data.id, + onCellEditRequest: (event) => { + editRequests.push(`${event.node?.id ?? 'unknown'}:${event.colDef.field}:${event.newValue}`); + }, + }); + + const beforeRows = new GridRows(api, 'before readOnly paste'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 field:"Top Value" + └── LEAF id:ROW_1 field:"Bottom Value" + `); + + clipboardUtils.setText('Top Value'); + api.setFocusedCell(1, 'field'); + const pasteEnd = waitForEvent('pasteEnd', api); + api.pasteFromClipboard(); + await pasteEnd; + + const afterRows = new GridRows(api, 'after readOnly paste'); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 field:"Top Value" + └── LEAF id:ROW_1 field:"Bottom Value" + `); + + expect(editRequests).toEqual(['ROW_1:field:Top Value']); + }); +}); diff --git a/testing/behavioural/src/cell-editing/group-edit/group-edit-clipboard-paste.test.ts b/testing/behavioural/src/cell-editing/group-edit/group-edit-clipboard-paste.test.ts new file mode 100644 index 00000000000..c8d8878b3fe --- /dev/null +++ b/testing/behavioural/src/cell-editing/group-edit/group-edit-clipboard-paste.test.ts @@ -0,0 +1,120 @@ +import '@testing-library/jest-dom'; + +import type { GridOptions } from 'ag-grid-community'; +import { AllCommunityModule, ClientSideRowModelModule, setupAgTestIds } from 'ag-grid-community'; +import { BatchEditModule, ClipboardModule, RowGroupingModule } from 'ag-grid-enterprise'; + +import { + EditEventTracker, + GridRows, + TestGridsManager, + asyncSetTimeout, + clipboardUtils, + waitForEvent, +} from '../../test-utils'; +import { expect } from '../../test-utils/matchers'; + +describe('Group Edit: clipboard paste', () => { + const gridMgr = new TestGridsManager({ + modules: [AllCommunityModule, ClientSideRowModelModule, RowGroupingModule, ClipboardModule, BatchEditModule], + }); + + beforeAll(() => { + setupAgTestIds(); + clipboardUtils.init(); + }); + + beforeEach(() => { + clipboardUtils.init(); + }); + + afterEach(() => { + gridMgr.reset(); + clipboardUtils.reset(); + }); + + test('paste updates grouped leaf once', async () => { + const valueSetterTargets: string[] = []; + const valueSetter = (params: any) => { + if (params.node?.id) { + valueSetterTargets.push(String(params.node.id)); + } + if (params.data && params.colDef.field) { + (params.data as Record)[params.colDef.field] = params.newValue; + } else if (params.node?.groupData) { + params.node.groupData.group = params.newValue; + } + return true; + }; + + const gridOptions: GridOptions = { + groupDisplayType: 'custom', + defaultColDef: { + cellEditor: 'agTextCellEditor', + }, + columnDefs: [ + { + colId: 'group', + headerName: 'Group', + field: 'label', + cellRenderer: 'agGroupCellRenderer', + cellRendererParams: { + suppressCount: true, + }, + editable: true, + groupRowEditable: true, + valueSetter, + }, + { field: 'category', rowGroup: true, hide: true }, + ], + rowData: [ + { id: 'a-1', category: 'A', label: 'A1' }, + { id: 'a-2', category: 'A', label: 'A2' }, + ], + groupDefaultExpanded: -1, + getRowId: (params) => params.data.id, + }; + + const api = await gridMgr.createGridAndWait('groupEditClipboardPaste', gridOptions); + const eventTracker = new EditEventTracker(api); + + const beforeRows = new GridRows(api, 'before group paste'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + └─┬ LEAF_GROUP id:row-group-category-A + · ├── LEAF id:a-1 group:"A1" category:"A" + · └── LEAF id:a-2 group:"A2" category:"A" + `); + + const groupRowNode = api.getDisplayedRowAtIndex(0); + expect(groupRowNode).toBeDefined(); + expect(groupRowNode!.group).toBe(true); + + const groupCol = api.getDisplayedCenterColumns()[0]!; + const groupColId = groupCol.getColId(); + + clipboardUtils.setText('Edited Group'); + api.setFocusedCell(groupRowNode!.rowIndex!, groupColId); + api.startEditingCell({ rowIndex: groupRowNode!.rowIndex!, colKey: groupColId }); + await asyncSetTimeout(0); + const pasteEnd = waitForEvent('pasteEnd', api); + api.pasteFromClipboard(); + await pasteEnd; + + const afterRows = new GridRows(api, 'after group paste'); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + └─┬ LEAF_GROUP id:row-group-category-A + · ├── LEAF id:a-1 group:"Edited Group" category:"A" + · └── LEAF id:a-2 group:"A2" category:"A" + `); + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 1, + cellEditingStopped: 1, + cellValueChanged: 1, + rowValueChanged: 0, + cellEditRequest: 0, + }); + expect(new Set(valueSetterTargets)).toEqual(new Set(['a-1'])); + }); +}); 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 new file mode 100644 index 00000000000..8fff04acd8e --- /dev/null +++ b/testing/behavioural/src/cell-editing/set-value/cell-editing-bulk-edit.test.ts @@ -0,0 +1,117 @@ +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, CellSelectionModule } from 'ag-grid-enterprise'; + +import { EditEventTracker, GridRows, TestGridsManager, asyncSetTimeout, waitForInput } from '../../test-utils'; + +describe('Cell Editing: bulk edit', () => { + const gridMgr = new TestGridsManager({ + modules: [CellSelectionModule, BatchEditModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test.each([false, true])('bulk edit (Ctrl+Enter) updates once per cell (batch=%s)', async (batchEnabled) => { + let valueSetterCalls = 0; + const valueSetterTargets: string[] = []; + const valueSetter = ({ + data, + newValue, + colDef, + }: { + data: { id: string; a: string; b: string }; + newValue: any; + colDef: { field?: string }; + }) => { + valueSetterCalls += 1; + if (colDef.field) { + valueSetterTargets.push(`${data.id}:${colDef.field}`); + data[colDef.field as 'a' | 'b'] = newValue; + } + return true; + }; + + const api = await gridMgr.createGridAndWait(`cellEditingBulk-${batchEnabled}`, { + cellSelection: true, + defaultColDef: { + editable: true, + }, + columnDefs: [ + { field: 'a', editable: true, valueSetter }, + { field: 'b', editable: true, valueSetter }, + ], + 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 beforeRows = new GridRows(api, `before bulk edit (batch=${batchEnabled})`); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 a:"A0" b:"B0" + └── LEAF id:ROW_1 a:"A1" b:"B1" + `); + + await asyncSetTimeout(0); + + if (batchEnabled) { + api.startBatchEdit(); + } + + const user = userEvent.setup({ skipHover: true }); + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'a')); + await user.click(cell); + api.addCellRange({ rowStartIndex: 0, rowEndIndex: 1, columns: ['a', 'b'] }); + api.startEditingCell({ rowIndex: 0, colKey: 'a' }); + const input = await waitForInput(gridDiv, cell); + await user.clear(input); + await user.type(input, 'Bulk Value'); + await user.keyboard('{Control>}{Enter}{/Control}'); + await asyncSetTimeout(0); + + const afterRows = new GridRows(api, `after bulk edit (batch=${batchEnabled})`); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 a:"Bulk Value" b:"Bulk Value" + └── LEAF id:ROW_1 a:"Bulk Value" b:"Bulk Value" + `); + + if (batchEnabled) { + expect(api.getDisplayedRowAtIndex(0)?.data?.a).toBe('A0'); + expect(api.getDisplayedRowAtIndex(0)?.data?.b).toBe('B0'); + expect(api.getDisplayedRowAtIndex(1)?.data?.a).toBe('A1'); + expect(api.getDisplayedRowAtIndex(1)?.data?.b).toBe('B1'); + api.commitBatchEdit(); + await asyncSetTimeout(0); + } + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 1, + cellEditingStopped: batchEnabled ? 9 : 5, + cellValueChanged: valueSetterCalls, + rowValueChanged: 0, + cellEditRequest: 0, + }); + + expect(api.getDisplayedRowAtIndex(0)?.data?.a).toBe('Bulk Value'); + expect(api.getDisplayedRowAtIndex(0)?.data?.b).toBe('Bulk Value'); + expect(api.getDisplayedRowAtIndex(1)?.data?.a).toBe('Bulk Value'); + expect(api.getDisplayedRowAtIndex(1)?.data?.b).toBe('Bulk Value'); + expect(valueSetterTargets).toEqual(['ROW_0:a', 'ROW_0:b', 'ROW_1:a', 'ROW_1:b']); + expect(valueSetterCalls).toBe(4); + }); +}); diff --git a/testing/behavioural/src/cell-editing/set-value/cell-editing-delete-range.test.ts b/testing/behavioural/src/cell-editing/set-value/cell-editing-delete-range.test.ts new file mode 100644 index 00000000000..a1d0631458e --- /dev/null +++ b/testing/behavioural/src/cell-editing/set-value/cell-editing-delete-range.test.ts @@ -0,0 +1,376 @@ +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, CellSelectionModule } from 'ag-grid-enterprise'; + +import { EditEventTracker, GridRows, TestGridsManager, asyncSetTimeout, waitForInput } from '../../test-utils'; + +describe('Cell Editing: delete and range clearing', () => { + const gridMgr = new TestGridsManager({ + modules: [CellSelectionModule, BatchEditModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test.each([false, true])('delete key uses cellClear once (batch=%s)', async (batchEnabled) => { + let valueSetterCalls = 0; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait(`cellEditingCellClear-${batchEnabled}`, { + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [{ id: 'ROW_0', field: 'Initial Value' }], + getRowId: (params) => params.data.id, + }); + const eventTracker = new EditEventTracker(api); + + const gridDiv = getGridElement(api)! as HTMLElement; + + const beforeRows = new GridRows(api, `before cellClear delete (batch=${batchEnabled})`); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"Initial Value" + `); + + await asyncSetTimeout(0); + + if (batchEnabled) { + api.startBatchEdit(); + } + + const user = userEvent.setup({ skipHover: true }); + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'field')); + await user.click(cell); + await user.keyboard('{Delete}'); + await asyncSetTimeout(0); + + const afterRows = new GridRows(api, `after cellClear delete (batch=${batchEnabled})`); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:null + `); + + const rowNode = api.getDisplayedRowAtIndex(0); + if (batchEnabled) { + expect(rowNode?.data?.field).toBe('Initial Value'); + api.commitBatchEdit(); + await asyncSetTimeout(0); + } + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 0, + cellEditingStopped: 1, + cellValueChanged: valueSetterCalls, + rowValueChanged: 0, + cellEditRequest: 0, + }); + + expect(api.getDisplayedRowAtIndex(0)?.data?.field ?? null).toBeNull(); + expect(valueSetterTargets).toEqual(['ROW_0']); + expect(valueSetterCalls).toBe(1); + }); + + test.each([ + [false, 'Delete', '{Delete}'], + [true, 'Delete', '{Delete}'], + [false, 'Backspace', '{Backspace}'], + [true, 'Backspace', '{Backspace}'], + ])('suppressKeyboardEvent prevents cellClear for %s (batch=%s)', async (batchEnabled, key, keyEvent) => { + let valueSetterCalls = 0; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait(`cellEditingSuppress-${key}-${batchEnabled}`, { + defaultColDef: { + editable: true, + suppressKeyboardEvent: (params) => params.event?.key === key, + }, + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [{ id: 'ROW_0', field: 'Initial Value' }], + getRowId: (params) => params.data.id, + }); + const eventTracker = new EditEventTracker(api); + + const gridDiv = getGridElement(api)! as HTMLElement; + + const beforeRows = new GridRows(api, `before suppress ${key} (batch=${batchEnabled})`); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"Initial Value" + `); + + await asyncSetTimeout(0); + + if (batchEnabled) { + api.startBatchEdit(); + } + + const user = userEvent.setup({ skipHover: true }); + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'field')); + await user.click(cell); + await user.keyboard(keyEvent); + await asyncSetTimeout(0); + + const afterRows = new GridRows(api, `after suppress ${key} (batch=${batchEnabled})`); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"Initial Value" + `); + + if (batchEnabled) { + api.commitBatchEdit(); + await asyncSetTimeout(0); + } + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 0, + cellEditingStopped: 0, + cellValueChanged: 0, + rowValueChanged: 0, + cellEditRequest: 0, + }); + + expect(api.getDisplayedRowAtIndex(0)?.data?.field).toBe('Initial Value'); + expect(valueSetterTargets).toEqual([]); + expect(valueSetterCalls).toBe(0); + }); + + test.each([false, true])('delete key uses rangeSvc once per cell (batch=%s)', async (batchEnabled) => { + let valueSetterCalls = 0; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait(`cellEditingRangeClear-${batchEnabled}`, { + cellSelection: true, + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [ + { id: 'ROW_0', field: 'Top Value' }, + { id: 'ROW_1', field: 'Bottom Value' }, + ], + getRowId: (params) => params.data.id, + }); + const eventTracker = new EditEventTracker(api); + + const gridDiv = getGridElement(api)! as HTMLElement; + + const beforeRows = new GridRows(api, `before range delete (batch=${batchEnabled})`); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 field:"Top Value" + └── LEAF id:ROW_1 field:"Bottom Value" + `); + + await asyncSetTimeout(0); + + if (batchEnabled) { + api.startBatchEdit(); + } + + const user = userEvent.setup({ skipHover: true }); + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'field')); + await user.click(cell); + api.addCellRange({ rowStartIndex: 0, rowEndIndex: 1, columns: ['field'] }); + await user.keyboard('{Delete}'); + await asyncSetTimeout(0); + + const afterRows = new GridRows(api, `after range delete (batch=${batchEnabled})`); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 field:null + └── LEAF id:ROW_1 field:null + `); + + if (batchEnabled) { + expect(api.getDisplayedRowAtIndex(0)?.data?.field).toBe('Top Value'); + expect(api.getDisplayedRowAtIndex(1)?.data?.field).toBe('Bottom Value'); + api.commitBatchEdit(); + await asyncSetTimeout(0); + } + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 0, + cellEditingStopped: batchEnabled ? 5 : 0, + cellValueChanged: valueSetterCalls, + rowValueChanged: 0, + cellEditRequest: 0, + }); + + expect(api.getDisplayedRowAtIndex(0)?.data?.field ?? null).toBeNull(); + expect(api.getDisplayedRowAtIndex(1)?.data?.field ?? null).toBeNull(); + expect(valueSetterTargets).toEqual(['ROW_0', 'ROW_1']); + expect(valueSetterCalls).toBe(2); + }); + + test.each([false, true])('rangeSvc multi-column delete updates once per cell (batch=%s)', async (batchEnabled) => { + let valueSetterCalls = 0; + const valueSetterTargets: string[] = []; + const valueSetter = ({ + data, + newValue, + colDef, + }: { + data: { id: string; a: string; b: string }; + newValue: any; + colDef: { field?: string }; + }) => { + valueSetterCalls += 1; + if (colDef.field) { + valueSetterTargets.push(`${data.id}:${colDef.field}`); + data[colDef.field as 'a' | 'b'] = newValue; + } + return true; + }; + + const api = await gridMgr.createGridAndWait(`cellEditingRangeMulti-${batchEnabled}`, { + cellSelection: true, + columnDefs: [ + { field: 'a', editable: true, valueSetter }, + { field: 'b', editable: true, valueSetter }, + ], + 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 beforeRows = new GridRows(api, `before range multi delete (batch=${batchEnabled})`); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 a:"A0" b:"B0" + └── LEAF id:ROW_1 a:"A1" b:"B1" + `); + + await asyncSetTimeout(0); + + if (batchEnabled) { + api.startBatchEdit(); + } + + const user = userEvent.setup({ skipHover: true }); + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'a')); + await user.click(cell); + api.addCellRange({ rowStartIndex: 0, rowEndIndex: 1, columns: ['a', 'b'] }); + await user.keyboard('{Delete}'); + await asyncSetTimeout(0); + + const afterRows = new GridRows(api, `after range multi delete (batch=${batchEnabled})`); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:ROW_0 a:null b:null + └── LEAF id:ROW_1 a:null b:null + `); + + if (batchEnabled) { + expect(api.getDisplayedRowAtIndex(0)?.data?.a).toBe('A0'); + expect(api.getDisplayedRowAtIndex(0)?.data?.b).toBe('B0'); + expect(api.getDisplayedRowAtIndex(1)?.data?.a).toBe('A1'); + expect(api.getDisplayedRowAtIndex(1)?.data?.b).toBe('B1'); + api.commitBatchEdit(); + await asyncSetTimeout(0); + } + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 0, + cellEditingStopped: batchEnabled ? 14 : 0, + cellValueChanged: valueSetterCalls, + rowValueChanged: 0, + cellEditRequest: 0, + }); + + expect(api.getDisplayedRowAtIndex(0)?.data?.a ?? null).toBeNull(); + expect(api.getDisplayedRowAtIndex(0)?.data?.b ?? null).toBeNull(); + expect(api.getDisplayedRowAtIndex(1)?.data?.a ?? null).toBeNull(); + expect(api.getDisplayedRowAtIndex(1)?.data?.b ?? null).toBeNull(); + expect(valueSetterTargets).toEqual(['ROW_0:a', 'ROW_0:b', 'ROW_1:a', 'ROW_1:b']); + expect(valueSetterCalls).toBe(4); + }); + + test.each([false, true])('open editor + rangeSvc delete keeps editor open (batch=%s)', async (batchEnabled) => { + const api = await gridMgr.createGridAndWait(`cellEditingOpenRange-${batchEnabled}`, { + cellSelection: true, + defaultColDef: { + editable: true, + }, + columnDefs: [ + { field: 'a', editable: true }, + { field: 'b', editable: true }, + ], + rowData: [ + { id: 'ROW_0', a: 'A0', b: 'B0' }, + { id: 'ROW_1', a: 'A1', b: 'B1' }, + ], + getRowId: (params) => params.data.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + await asyncSetTimeout(0); + + if (batchEnabled) { + api.startBatchEdit(); + } + + const user = userEvent.setup({ skipHover: true }); + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'a')); + await user.click(cell); + api.startEditingCell({ rowIndex: 0, colKey: 'a' }); + const input = await waitForInput(gridDiv, cell); + expect(input).toBeTruthy(); + + api.addCellRange({ rowStartIndex: 0, rowEndIndex: 1, columns: ['a', 'b'] }); + await user.keyboard('{Delete}'); + await asyncSetTimeout(0); + + const inputAfterDelete = await waitForInput(gridDiv, cell); + expect(inputAfterDelete).toBeTruthy(); + + if (batchEnabled) { + api.commitBatchEdit(); + await asyncSetTimeout(0); + } + }); +}); diff --git a/testing/behavioural/src/cell-editing/set-value/cell-editing-full-row-batch.test.ts b/testing/behavioural/src/cell-editing/set-value/cell-editing-full-row-batch.test.ts new file mode 100644 index 00000000000..184d9dc5ec1 --- /dev/null +++ b/testing/behavioural/src/cell-editing/set-value/cell-editing-full-row-batch.test.ts @@ -0,0 +1,99 @@ +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 { EditEventTracker, TestGridsManager, asyncSetTimeout, waitForInput } from '../../test-utils'; + +describe('Cell Editing: full-row batch', () => { + const gridMgr = new TestGridsManager({ + modules: [BatchEditModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test.each(['commit', 'cancel'] as const)('full-row batch %s does not duplicate updates', async (action) => { + let valueSetterCalls = 0; + const valueSetterTargets: string[] = []; + const valueSetter = ({ + data, + newValue, + colDef, + }: { + data: { id: string; a: string; b: string }; + newValue: string; + colDef: any; + }) => { + valueSetterCalls += 1; + valueSetterTargets.push(`${data.id}:${colDef.field}`); + (data as any)[colDef.field] = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait(`cellEditingFullRowBatch-${action}`, { + editType: 'fullRow', + defaultColDef: { + editable: true, + valueSetter, + }, + columnDefs: [ + { field: 'a', editable: true }, + { field: 'b', editable: true }, + ], + rowData: [{ id: 'ROW_0', a: 'A0', b: 'B0' }], + getRowId: (params) => params.data.id, + }); + const eventTracker = new EditEventTracker(api); + + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + + api.startBatchEdit(); + + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'a')); + await user.click(cell); + api.startEditingCell({ rowIndex: 0, colKey: 'a' }); + const input = await waitForInput(gridDiv, cell); + await user.clear(input); + await user.type(input, 'A1'); + await user.keyboard('{Enter}'); + await asyncSetTimeout(0); + + if (action === 'commit') { + api.commitBatchEdit(); + } else { + api.cancelBatchEdit(); + } + await asyncSetTimeout(0); + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 2, + cellEditingStopped: 3, + cellValueChanged: action === 'commit' ? 1 : 0, + rowValueChanged: action === 'commit' ? 1 : 0, + cellEditRequest: 0, + }); + + const row = api.getDisplayedRowAtIndex(0)?.data as { a: string; b: string } | undefined; + if (action === 'commit') { + expect(row?.a).toBe('A1'); + expect(row?.b).toBe('B0'); + expect(valueSetterTargets).toEqual(['ROW_0:a']); + expect(valueSetterCalls).toBe(1); + } else { + expect(row?.a).toBe('A0'); + expect(row?.b).toBe('B0'); + expect(valueSetterTargets).toEqual([]); + expect(valueSetterCalls).toBe(0); + } + }); +}); diff --git a/testing/behavioural/src/cell-editing/set-value/cell-editing-set-data-value.test.ts b/testing/behavioural/src/cell-editing/set-value/cell-editing-set-data-value.test.ts new file mode 100644 index 00000000000..4ad6d3ac5e8 --- /dev/null +++ b/testing/behavioural/src/cell-editing/set-value/cell-editing-set-data-value.test.ts @@ -0,0 +1,288 @@ +import { getByTestId } from '@testing-library/dom'; +import '@testing-library/jest-dom'; +import { userEvent } from '@testing-library/user-event'; + +import { agTestIdFor, getGridElement, setupAgTestIds } from 'ag-grid-community'; + +import { EditEventTracker, GridRows, TestGridsManager, asyncSetTimeout, waitForInput } from '../../test-utils'; + +describe('Cell Editing: setDataValue sources', () => { + const gridMgr = new TestGridsManager({ + includeDefaultModules: true, + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test.each(['rangeSvc', 'cellClear', 'redo', 'undo'] as const)( + 'setDataValue source %s only updates once', + async (source) => { + let valueSetterCalls = 0; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait(`cellEditingSetDataValue-${source}`, { + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [{ id: 'ROW_0', field: 'Initial Value' }], + getRowId: (params) => params.data.id, + }); + const eventTracker = new EditEventTracker(api); + + const beforeRows = new GridRows(api, `before ${source} setDataValue`); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"Initial Value" + `); + + const rowNode = api.getDisplayedRowAtIndex(0); + rowNode?.setDataValue('field', `${source}-value`, source); + await asyncSetTimeout(0); + + const afterRows = new GridRows(api, `after ${source} setDataValue`); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"${source}-value" + `); + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 0, + cellEditingStopped: source === 'cellClear' ? 1 : 0, + cellValueChanged: 1, + rowValueChanged: 0, + cellEditRequest: 0, + }); + + expect(valueSetterTargets).toEqual(['ROW_0']); + expect(valueSetterCalls).toBe(1); + } + ); + + test('setDataValue without source updates once', async () => { + let valueSetterCalls = 0; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait('cellEditingSetDataValue-default', { + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [{ id: 'ROW_0', field: 'Initial Value' }], + getRowId: (params) => params.data.id, + }); + const eventTracker = new EditEventTracker(api); + + const beforeRows = new GridRows(api, 'before default setDataValue'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"Initial Value" + `); + + const rowNode = api.getDisplayedRowAtIndex(0); + rowNode?.setDataValue('field', 'default-value'); + await asyncSetTimeout(0); + + const afterRows = new GridRows(api, 'after default setDataValue'); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"default-value" + `); + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 0, + cellEditingStopped: 0, + cellValueChanged: 1, + rowValueChanged: 0, + cellEditRequest: 0, + }); + + expect(api.getDisplayedRowAtIndex(0)?.data?.field).toBe('default-value'); + expect(valueSetterTargets).toEqual(['ROW_0']); + expect(valueSetterCalls).toBe(1); + }); + + test('setDataValue paste source updates once when not editing', async () => { + let valueSetterCalls = 0; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait('cellEditingSetDataValue-paste', { + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [{ id: 'ROW_0', field: 'Initial Value' }], + getRowId: (params) => params.data.id, + }); + const eventTracker = new EditEventTracker(api); + + const beforeRows = new GridRows(api, 'before paste setDataValue'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"Initial Value" + `); + + const rowNode = api.getDisplayedRowAtIndex(0); + rowNode?.setDataValue('field', 'paste-value', 'paste'); + await asyncSetTimeout(0); + + const afterRows = new GridRows(api, 'after paste setDataValue'); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"paste-value" + `); + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 0, + cellEditingStopped: 0, + cellValueChanged: 1, + rowValueChanged: 0, + cellEditRequest: 0, + }); + + expect(api.getDisplayedRowAtIndex(0)?.data?.field).toBe('paste-value'); + expect(valueSetterTargets).toEqual(['ROW_0']); + expect(valueSetterCalls).toBe(1); + }); + + test('readOnlyEdit setDataValue fires cellEditRequest and does not update', async () => { + let valueSetterCalls = 0; + const editRequests: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait('cellEditingSetDataValue-readOnly', { + readOnlyEdit: true, + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [{ id: 'ROW_0', field: 'Initial Value' }], + getRowId: (params) => params.data.id, + onCellEditRequest: (event) => { + editRequests.push(`${event.node?.id ?? 'unknown'}:${event.colDef.field}:${event.newValue}`); + }, + }); + const eventTracker = new EditEventTracker(api); + + const beforeRows = new GridRows(api, 'before readOnly setDataValue'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"Initial Value" + `); + + const rowNode = api.getDisplayedRowAtIndex(0); + rowNode?.setDataValue('field', 'readOnly-value', 'ui'); + await asyncSetTimeout(0); + + const afterRows = new GridRows(api, 'after readOnly setDataValue'); + await afterRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"Initial Value" + `); + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 0, + cellEditingStopped: 0, + cellValueChanged: 0, + rowValueChanged: 0, + cellEditRequest: 1, + }); + + expect(api.getDisplayedRowAtIndex(0)?.data?.field).toBe('Initial Value'); + expect(valueSetterCalls).toBe(0); + expect(editRequests).toEqual(['ROW_0:field:readOnly-value']); + }); + + test('setDataValue during edit commits and stops editing', async () => { + let valueSetterCalls = 0; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait('cellEditingSetDataValue-editing', { + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [{ id: 'ROW_0', field: 'Initial Value' }], + getRowId: (params) => params.data.id, + }); + + const beforeRows = new GridRows(api, 'before editing setDataValue'); + await beforeRows.check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"Initial Value" + `); + + const gridDiv = getGridElement(api)! as HTMLElement; + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'field')); + await userEvent.click(cell); + api.startEditingCell({ rowIndex: 0, colKey: 'field' }); + const input = await waitForInput(gridDiv, cell); + await userEvent.clear(input); + await userEvent.type(input, 'Editor Value'); + + const rowNode = api.getDisplayedRowAtIndex(0); + rowNode?.setDataValue('field', 'Committed Value', 'ui'); + await asyncSetTimeout(0); + + await new GridRows(api, 'after editing setDataValue ui').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"Editor Value" + `); + + rowNode?.setDataValue('field', 'Committed Value', 'api'); + await asyncSetTimeout(0); + + await new GridRows(api, 'after editing setDataValue api').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:ROW_0 field:"Committed Value" + `); + + expect(valueSetterCalls).toBe(2); + }); +}); diff --git a/testing/behavioural/src/cell-editing/set-value/cell-editing-undo-redo.test.ts b/testing/behavioural/src/cell-editing/set-value/cell-editing-undo-redo.test.ts new file mode 100644 index 00000000000..87fe807d99a --- /dev/null +++ b/testing/behavioural/src/cell-editing/set-value/cell-editing-undo-redo.test.ts @@ -0,0 +1,149 @@ +import { getByTestId, waitFor } from '@testing-library/dom'; +import '@testing-library/jest-dom'; +import { userEvent } from '@testing-library/user-event'; + +import { TextEditorModule, UndoRedoEditModule, agTestIdFor, getGridElement, setupAgTestIds } from 'ag-grid-community'; +import { BatchEditModule } from 'ag-grid-enterprise'; + +import { EditEventTracker, TestGridsManager, asyncSetTimeout, waitForInput } from '../../test-utils'; + +describe('Cell Editing: undo/redo', () => { + const gridMgr = new TestGridsManager({ + modules: [BatchEditModule, UndoRedoEditModule, TextEditorModule], + }); + + beforeAll(() => { + setupAgTestIds(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test('undo/redo uses single setValue per action', async () => { + let valueSetterCalls = 0; + const valueSetterTargets: string[] = []; + const valueSetter = ({ data, newValue }: { data: { id: string; field: string }; newValue: string }) => { + valueSetterCalls += 1; + valueSetterTargets.push(data.id); + data.field = newValue; + return true; + }; + + const api = await gridMgr.createGridAndWait('cellEditingUndoRedo', { + undoRedoCellEditing: true, + defaultColDef: { + editable: true, + }, + columnDefs: [ + { + field: 'field', + editable: true, + valueSetter, + }, + ], + rowData: [{ id: 'ROW_0', field: 'Initial Value' }], + getRowId: (params) => params.data.id, + }); + const eventTracker = new EditEventTracker(api); + + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'field')); + + api.startEditingCell({ rowIndex: 0, colKey: 'field' }); + const input = await waitForInput(gridDiv, cell); + await user.clear(input); + await user.type(input, 'Updated Value'); + await user.keyboard('{Enter}'); + await asyncSetTimeout(0); + + expect(api.getDisplayedRowAtIndex(0)?.data?.field).toBe('Updated Value'); + expect(valueSetterCalls).toBe(1); + + api.undoCellEditing(); + await asyncSetTimeout(0); + + expect(api.getDisplayedRowAtIndex(0)?.data?.field).toBe('Initial Value'); + expect(valueSetterCalls).toBe(2); + + api.redoCellEditing(); + await asyncSetTimeout(0); + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 1, + cellEditingStopped: 1, + cellValueChanged: valueSetterCalls, + rowValueChanged: 0, + cellEditRequest: 0, + }); + + expect(api.getDisplayedRowAtIndex(0)?.data?.field).toBe('Updated Value'); + expect(valueSetterCalls).toBe(3); + expect(valueSetterTargets).toEqual(['ROW_0', 'ROW_0', 'ROW_0']); + }); + + test.each([false, true])( + 'full-row editing undo/redo fires rowValueChanged once per row (batch=%s)', + async (batchEnabled) => { + const rowValueChangedNodes: string[] = []; + const api = await gridMgr.createGridAndWait(`cellEditingFullRowUndoRedo-${batchEnabled}`, { + editType: 'fullRow', + undoRedoCellEditing: true, + defaultColDef: { + editable: true, + }, + columnDefs: [ + { field: 'a', editable: true }, + { field: 'b', editable: true }, + ], + rowData: [{ id: 'ROW_0', a: 'A0', b: 'B0' }], + getRowId: (params) => params.data.id, + onRowValueChanged: (event) => { + if (event.node?.id) { + rowValueChangedNodes.push(String(event.node.id)); + } + }, + }); + const eventTracker = new EditEventTracker(api); + + const gridDiv = getGridElement(api)! as HTMLElement; + const user = userEvent.setup({ skipHover: true }); + await asyncSetTimeout(0); + + if (batchEnabled) { + api.startBatchEdit(); + } + + const cell = getByTestId(gridDiv, agTestIdFor.cell('ROW_0', 'a')); + await user.click(cell); + api.startEditingCell({ rowIndex: 0, colKey: 'a' }); + const input = await waitForInput(gridDiv, cell); + await user.clear(input); + await user.type(input, 'A1'); + await user.keyboard('{Enter}'); + + if (batchEnabled) { + api.commitBatchEdit(); + await asyncSetTimeout(0); + } + + await waitFor(() => expect(new Set(rowValueChangedNodes)).toEqual(new Set(['ROW_0']))); + + api.undoCellEditing(); + await waitFor(() => expect(new Set(rowValueChangedNodes)).toEqual(new Set(['ROW_0']))); + + api.redoCellEditing(); + await waitFor(() => expect(new Set(rowValueChangedNodes)).toEqual(new Set(['ROW_0']))); + + expect(eventTracker.counts).toEqual({ + cellEditingStarted: 2, + cellEditingStopped: batchEnabled ? 3 : 2, + cellValueChanged: 1, + rowValueChanged: 1, + cellEditRequest: 0, + }); + } + ); +}); diff --git a/testing/behavioural/src/test-utils/index.ts b/testing/behavioural/src/test-utils/index.ts index 288dd3c62ac..ac4b9644064 100644 --- a/testing/behavioural/src/test-utils/index.ts +++ b/testing/behavioural/src/test-utils/index.ts @@ -1,6 +1,7 @@ export * from './polyfills/objectUrls'; export * from './polyfills/mockGridLayout'; export * from './polyfills/pointerEvent'; +export * from './polyfills/clipboard'; export * from './gridRows/gridHtmlRows'; export * from './gridRows/gridRows'; export * from './gridRows/gridRowsErrors'; diff --git a/testing/behavioural/src/test-utils/polyfills/clipboard.ts b/testing/behavioural/src/test-utils/polyfills/clipboard.ts new file mode 100644 index 00000000000..527fb6f1d96 --- /dev/null +++ b/testing/behavioural/src/test-utils/polyfills/clipboard.ts @@ -0,0 +1,99 @@ +type ClipboardItemStub = { + types: string[]; + getType: (type: string) => Promise; +}; + +type ClipboardState = { + text: string; + items: ClipboardItemStub[]; +}; + +const createClipboardItem = (text: string, type = 'text/plain'): ClipboardItemStub => ({ + types: [type], + getType: async (requestedType: string) => new Blob([text], { type: requestedType }), +}); + +const eventTarget = new EventTarget(); +let clipboardState: ClipboardState = { text: '', items: [] }; + +const emitClipboardChange = (): void => { + eventTarget.dispatchEvent(new Event('clipboardchange')); +}; + +const setClipboardState = (text: string, items?: ClipboardItemStub[]): void => { + const value = String(text ?? ''); + clipboardState = { + text: value, + items: items ?? (value ? [createClipboardItem(value)] : []), + }; +}; + +const getClipboardStub = (target: EventTarget): Clipboard => ({ + readText: async () => clipboardState.text, + writeText: async (data: string) => { + setClipboardState(data); + emitClipboardChange(); + }, + read: async () => clipboardState.items.map((item) => item as unknown as ClipboardItem), + write: async (items: ClipboardItem[]) => { + if (!items?.length) { + setClipboardState(''); + emitClipboardChange(); + return; + } + + const stubs = items.map((item) => item as unknown as ClipboardItemStub); + const firstItem = items[0]; + if (firstItem.types?.includes('text/plain')) { + const blob = await firstItem.getType('text/plain'); + const text = await blob.text(); + setClipboardState(text, stubs); + } else { + setClipboardState('', stubs); + } + + emitClipboardChange(); + }, + addEventListener: target.addEventListener.bind(target), + removeEventListener: target.removeEventListener.bind(target), + dispatchEvent: target.dispatchEvent.bind(target), +}); + +const clipboardStub = getClipboardStub(eventTarget); + +let initialised = false; + +export const clipboardUtils = { + isInitialised(): boolean { + return initialised; + }, + init(): boolean { + if (typeof navigator === 'undefined') { + return false; + } + + if (navigator.clipboard !== clipboardStub) { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: clipboardStub, + }); + } + + setClipboardState(''); + + initialised = true; + return true; + }, + reset(): void { + setClipboardState('', []); + }, + setText(value: string): void { + setClipboardState(value); + }, + getText() { + return clipboardState.text; + }, + getItems() { + return [...clipboardState.items]; + }, +}; diff --git a/testing/behavioural/src/test-utils/test-utils-events.ts b/testing/behavioural/src/test-utils/test-utils-events.ts index f703fb6870b..58f7602b428 100644 --- a/testing/behavioural/src/test-utils/test-utils-events.ts +++ b/testing/behavioural/src/test-utils/test-utils-events.ts @@ -77,3 +77,53 @@ export async function firePointerLikeClick(element: string | HTMLElement | null return clickNotCancelled; } + +export type EditEventCounts = { + cellEditingStarted: number; + cellEditingStopped: number; + cellValueChanged: number; + rowValueChanged: number; + cellEditRequest: number; +}; + +const DEFAULT_EDIT_EVENT_COUNTS = { + cellEditingStarted: 0, + cellEditingStopped: 0, + cellValueChanged: 0, + rowValueChanged: 0, + cellEditRequest: 0, +}; + +export class EditEventTracker { + public readonly counts: EditEventCounts = { ...DEFAULT_EDIT_EVENT_COUNTS }; + + private readonly listeners: Array<{ event: AgPublicEventType; listener: () => void }> = []; + + constructor(private readonly api: GridApi) { + this.track('cellEditingStarted'); + this.track('cellEditingStopped'); + this.track('cellValueChanged'); + this.track('rowValueChanged'); + this.track('cellEditRequest'); + } + + private track(event: AgPublicEventType): void { + const listener = () => { + this.counts[event] += 1; + }; + + this.listeners.push({ event, listener }); + this.api.addEventListener(event, listener); + } + + public destroy(): void { + for (const { event, listener } of this.listeners) { + this.api.removeEventListener(event, listener); + } + this.listeners.length = 0; + } + + public reset(): void { + Object.assign(this.counts, DEFAULT_EDIT_EVENT_COUNTS); + } +}