diff --git a/packages/ag-grid-community/eslint.config.mjs b/packages/ag-grid-community/eslint.config.mjs index b84f1cde5e9..b01f111050e 100644 --- a/packages/ag-grid-community/eslint.config.mjs +++ b/packages/ag-grid-community/eslint.config.mjs @@ -44,6 +44,10 @@ export default [ message: "Prefer unicode characters as they don't have to be parsed into HTML to display correctly.", }, + { + selector: 'PropertyDefinition[static=true]', + message: 'Static class properties prevent tree-shaking. Use an alternative if possible.', + }, ], 'no-restricted-properties': [ 'warn', diff --git a/packages/ag-grid-community/src/agStack/utils/dom.ts b/packages/ag-grid-community/src/agStack/utils/dom.ts index 0f3b463bea9..d0e9364140d 100644 --- a/packages/ag-grid-community/src/agStack/utils/dom.ts +++ b/packages/ag-grid-community/src/agStack/utils/dom.ts @@ -1,6 +1,6 @@ import type { UtilBeanCollection } from '../interfaces/agCoreBeanCollection'; import { _setAriaHidden } from './aria'; -import { _getWindow } from './document'; +import { _getDocument, _getWindow } from './document'; /** * This method adds a class to an element and remove that class from all siblings. @@ -393,6 +393,23 @@ export function _addOrRemoveAttribute(element: HTMLElement, name: string, value: } } +export function _placeCaretAtEnd(beans: UtilBeanCollection, contentElement: HTMLElement): void { + if (!contentElement.isContentEditable) { + return; + } + const selection = _getWindow(beans).getSelection(); + + if (!selection) { + return; + } + + const range = _getDocument(beans).createRange(); + range.selectNodeContents(contentElement); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); +} + export function _observeResize( beans: UtilBeanCollection, element: HTMLElement, diff --git a/packages/ag-grid-community/src/columns/dataTypeService.ts b/packages/ag-grid-community/src/columns/dataTypeService.ts index cfa0c30d84e..8f1157c601a 100644 --- a/packages/ag-grid-community/src/columns/dataTypeService.ts +++ b/packages/ag-grid-community/src/columns/dataTypeService.ts @@ -5,7 +5,7 @@ import { _toStringOrNull } from '../agStack/utils/generic'; import { _getValueUsingField } from '../agStack/utils/value'; import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; -import type { BeanCollection, UserComponentName } from '../context/context'; +import type { BeanCollection } from '../context/context'; import type { AgColumn } from '../entities/agColumn'; import type { ColDef, SuppressKeyboardEventParams, ValueFormatterFunc, ValueFormatterParams } from '../entities/colDef'; import type { @@ -215,11 +215,14 @@ export class DataTypeService extends BeanStub implements NamedBean { cellDataType = colDef.cellDataType; } - const { field, allowFormula } = userColDef; + const { field } = userColDef; if (cellDataType == null || cellDataType === true) { cellDataType = this.canInferCellDataType(colDef, userColDef) ? this.inferCellDataType(field, colId) : false; } + + this.addFormulaCellEditorToColDef(colDef, userColDef); + if (!cellDataType) { colDef.cellDataType = false; return undefined; @@ -231,7 +234,6 @@ export class DataTypeService extends BeanStub implements NamedBean { } colDef.cellDataType = cellDataType; - colDef.allowFormula ??= allowFormula; if (dataTypeDefinition.groupSafeValueFormatter) { colDef.valueFormatter = dataTypeDefinition.groupSafeValueFormatter; @@ -245,6 +247,16 @@ export class DataTypeService extends BeanStub implements NamedBean { return dataTypeDefinition.columnTypes; } + private addFormulaCellEditorToColDef(colDef: ColDef, userColDef: ColDef): void { + const allowFormula = userColDef.allowFormula ?? colDef.allowFormula; + + if (!allowFormula || userColDef.cellEditor) { + return; + } + + colDef.cellEditor = 'agFormulaCellEditor'; + } + public addColumnListeners(column: AgColumn): void { if (!this.isPendingInference) { return; @@ -587,21 +599,14 @@ export class DataTypeService extends BeanStub implements NamedBean { colId, formatValue, }); - Object.assign(colDef, partialColDef); - - const { cellEditor, allowFormula } = colDef; - - if (allowFormula) { - const supportedEditors: UserComponentName[] = [ - 'agFormulaCellEditor', - 'agTextCellEditor', - 'agLargeTextCellEditor', - ]; - if (!supportedEditors.includes(cellEditor)) { - colDef.cellEditor = 'agFormulaCellEditor'; - } + // if the user enabled formula and did not manually provide an editor + // we should keep `agFormulaCellEditor` as the default editor. + if (colDef.cellEditor === 'agFormulaCellEditor' && partialColDef.cellEditor !== colDef.cellEditor) { + partialColDef.cellEditor = colDef.cellEditor; } + + Object.assign(colDef, partialColDef); } private getDateObjectTypeDef(baseDataType: T) { diff --git a/packages/ag-grid-community/src/globalGridOptions.ts b/packages/ag-grid-community/src/globalGridOptions.ts index 1089b8d4408..1c35d6ad363 100644 --- a/packages/ag-grid-community/src/globalGridOptions.ts +++ b/packages/ag-grid-community/src/globalGridOptions.ts @@ -2,7 +2,9 @@ import type { GridOptions } from './entities/gridOptions'; import { _mergeDeep } from './utils/mergeDeep'; export class GlobalGridOptions { + // eslint-disable-next-line no-restricted-syntax static gridOptions: GridOptions | undefined = undefined; + // eslint-disable-next-line no-restricted-syntax static mergeStrategy: GlobalGridOptionsMergeStrategy = 'shallow'; /** diff --git a/packages/ag-grid-community/src/interfaces/formulas.ts b/packages/ag-grid-community/src/interfaces/formulas.ts index 66446fb7ac8..6c906b33ab7 100644 --- a/packages/ag-grid-community/src/interfaces/formulas.ts +++ b/packages/ag-grid-community/src/interfaces/formulas.ts @@ -73,6 +73,7 @@ export interface IFormulaDataService extends Bean { export interface IFormulaService extends Bean { active: boolean; + activeEditor: number | null; isFormula(value: unknown): value is `=${string}`; setFormulasActive(cols: ColumnCollections): void; resolveValue(col: AgColumn, row: RowNode): unknown; diff --git a/packages/ag-grid-community/src/main-internal.ts b/packages/ag-grid-community/src/main-internal.ts index c2e432434b4..93fad24eeff 100644 --- a/packages/ag-grid-community/src/main-internal.ts +++ b/packages/ag-grid-community/src/main-internal.ts @@ -130,6 +130,7 @@ export { MONTHS as _MONTHS, _getDateParts, _parseDateTimeFromString, _serialiseD export { _getActiveDomElement, _getDocument, + _getWindow, _getPageBody, _getRootNode, _isNothingFocused, @@ -155,6 +156,7 @@ export { _setFixedWidth, _setVisible, _isFocusableFormField, + _placeCaretAtEnd, } from './agStack/utils/dom'; export { _anchorElementToMouseMoveEvent, _isElementInEventPath } from './agStack/utils/event'; export { diff --git a/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts b/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts index 6ee7248d152..9ea5f5aff63 100644 --- a/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts +++ b/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts @@ -1,6 +1,6 @@ import { _setAriaColIndex } from '../../agStack/utils/aria'; import { _getActiveDomElement } from '../../agStack/utils/document'; -import { _addOrRemoveAttribute, _requestAnimationFrame } from '../../agStack/utils/dom'; +import { _addOrRemoveAttribute, _placeCaretAtEnd, _requestAnimationFrame } from '../../agStack/utils/dom'; import { _findFocusableElements } from '../../agStack/utils/focus'; import { _makeNull } from '../../agStack/utils/generic'; import { AgPromise } from '../../agStack/utils/promise'; @@ -909,6 +909,7 @@ export class CellCtrl extends BeanStub { } focusEl.focus({ preventScroll: !!event.preventScrollOnBrowserFocus }); + _placeCaretAtEnd(beans, focusEl); } // require event to announce so we only announce diff --git a/packages/ag-grid-community/src/selection/selectionService.ts b/packages/ag-grid-community/src/selection/selectionService.ts index 11a7cb6188a..fd0e5ae8dba 100644 --- a/packages/ag-grid-community/src/selection/selectionService.ts +++ b/packages/ag-grid-community/src/selection/selectionService.ts @@ -758,11 +758,7 @@ export class SelectionService extends BaseSelectionService implements NamedBean, } if (!isSelectAll) { - const detailSelected = this.detailSelection.get(node.id!) ?? new Set(); - for (const n of detailApi.getSelectedNodes()) { - detailSelected.add(n.id!); - } - this.detailSelection.set(node.id!, detailSelected); + this.detailSelection.set(node.id!, new Set(detailApi.getSelectedNodes().map((n) => n.id!))); } } diff --git a/packages/ag-grid-enterprise/eslint.config.mjs b/packages/ag-grid-enterprise/eslint.config.mjs index 8cb17779790..1ff29deaa1a 100644 --- a/packages/ag-grid-enterprise/eslint.config.mjs +++ b/packages/ag-grid-enterprise/eslint.config.mjs @@ -58,6 +58,10 @@ export default [ message: 'Empty imports are not allowed. i.e import "ag-grid-community"; as it will cause warnings about being sideEffect free', }, + { + selector: 'PropertyDefinition[static=true]', + message: 'Static class properties prevent tree-shaking. Use an alternative if possible.', + }, ], 'no-restricted-imports': [ 'error', diff --git a/packages/ag-grid-enterprise/src/formula/editor/formulaCellEditor.ts b/packages/ag-grid-enterprise/src/formula/editor/formulaCellEditor.ts index 0a3e4bec715..b37b576a30b 100644 --- a/packages/ag-grid-enterprise/src/formula/editor/formulaCellEditor.ts +++ b/packages/ag-grid-enterprise/src/formula/editor/formulaCellEditor.ts @@ -1,5 +1,5 @@ import type { ICellEditorParams } from 'ag-grid-community'; -import { AgAbstractCellEditor, KeyCode, RefPlaceholder, _isBrowserSafari } from 'ag-grid-community'; +import { AgAbstractCellEditor, KeyCode, RefPlaceholder, _isBrowserSafari, _placeCaretAtEnd } from 'ag-grid-community'; import { AgFormulaInputField } from '../../widgets/agFormulaInputField'; @@ -55,10 +55,11 @@ export class FormulaCellEditor extends AgAbstractCellEditor { return; } + const { beans, eEditor } = this; if (!_isBrowserSafari()) { this.focusIn(); } - this.eEditor.placeCaretAtEnd(); + _placeCaretAtEnd(beans, eEditor.getContentElement()); } public focusIn(): void { diff --git a/packages/ag-grid-enterprise/src/formula/formulaService.ts b/packages/ag-grid-enterprise/src/formula/formulaService.ts index cca7ecbe24c..07b6ec6353a 100644 --- a/packages/ag-grid-enterprise/src/formula/formulaService.ts +++ b/packages/ag-grid-enterprise/src/formula/formulaService.ts @@ -107,6 +107,9 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe /** Built-in operations (extendable via gridOptions.formulaFuncs). */ private supportedOperations: Map unknown>; + // Track the active editor instance per grid/cell to avoid overlapping syncs on editor restarts. + public activeEditor: number | null = null; + public active = false; public setFormulasActive(cols: _ColumnCollections): void { diff --git a/packages/ag-grid-enterprise/src/license/shared/licenseManager.ts b/packages/ag-grid-enterprise/src/license/shared/licenseManager.ts index 2dedcdbd390..03869e6973b 100644 --- a/packages/ag-grid-enterprise/src/license/shared/licenseManager.ts +++ b/packages/ag-grid-enterprise/src/license/shared/licenseManager.ts @@ -14,8 +14,11 @@ export interface ILicenseManager { } export class LicenseManager { + // eslint-disable-next-line no-restricted-syntax private static readonly RELEASE_INFORMATION: string = 'MTc2NTM1OTQ2ODIzOA=='; + // eslint-disable-next-line no-restricted-syntax private static licenseKey: string; + // eslint-disable-next-line no-restricted-syntax private static chartsLicenseManager?: ILicenseManager; private watermarkMessage: string | undefined = undefined; diff --git a/packages/ag-grid-enterprise/src/rangeSelection/rangeService.ts b/packages/ag-grid-enterprise/src/rangeSelection/rangeService.ts index 90c824db42e..d5764c5aa3f 100644 --- a/packages/ag-grid-enterprise/src/rangeSelection/rangeService.ts +++ b/packages/ag-grid-enterprise/src/rangeSelection/rangeService.ts @@ -27,6 +27,7 @@ import type { import { AutoScrollService, BeanStub, + KeyCode, _areCellsEqual, _areEqual, _exists, @@ -462,15 +463,29 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, private isMultiRange(event: MouseEvent): boolean { const { ctrlKey, metaKey } = event; - const { editSvc, gos } = this.beans; - const editingWithRanges = !!editSvc?.isEditing() && !!editSvc?.isRangeSelectionEnabledWhileEditing(); + const { editingWithRanges, allowMulti } = this.getMultiRangeContext(); // ctrlKey for windows, metaKey for Apple const isMultiKey = ctrlKey || metaKey; - const allowMulti = !_getSuppressMultiRanges(gos); return editingWithRanges || (allowMulti ? isMultiKey : false); } + private getMultiRangeContext(): { + editingWithRanges: boolean; + suppressMultiRanges: boolean; + allowMulti: boolean; + } { + const { gos, editSvc } = this.beans; + const editingWithRanges = !!editSvc?.isEditing() && !!editSvc?.isRangeSelectionEnabledWhileEditing(); + const suppressMultiRanges = _getSuppressMultiRanges(gos) && !editingWithRanges; + + return { + editingWithRanges, + suppressMultiRanges, + allowMulti: !suppressMultiRanges, + }; + } + private removeRowFromAllColumnsRange(cell: CellPosition, containingRange: CellRange): void { const { beans, cellRanges } = this; const firstRow = _getFirstRow(beans); @@ -525,10 +540,10 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, return; } - const suppressMultiRangeSelections = _getSuppressMultiRanges(gos); + const { suppressMultiRanges } = this.getMultiRangeContext(); // if not appending, then clear previous range selections - if (suppressMultiRangeSelections || !appendRange || _missing(this.cellRanges)) { + if (suppressMultiRanges || !appendRange || _missing(this.cellRanges)) { this.removeAllCellRanges(true); } @@ -1097,18 +1112,11 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, // when ranges are created due to a mouse click without drag (happens in cellMouseListener) // this method will be called with `fromMouseClick=true`. // Range selection while editing relies on overlapping ranges to preserve editor overlays. - if (this.beans.editSvc?.isRangeSelectionEnabledWhileEditing?.()) { - return; - } - if (fromMouseClick && this.dragging) { - return; - } - if (_getSuppressMultiRanges(this.gos)) { - return; - } - if (this.isEmpty()) { + const { editingWithRanges, suppressMultiRanges } = this.getMultiRangeContext(); + if (editingWithRanges || suppressMultiRanges || (fromMouseClick && this.dragging) || this.isEmpty()) { return; } + const lastRange = _last(this.cellRanges); const intersectionStartRow = this.getRangeStartRow(lastRange); const intersectionEndRow = this.getRangeEndRow(lastRange); @@ -1250,7 +1258,8 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, } private verifyCellRanges(gos: GridOptionsService): boolean { - const invalid = _isUsingNewCellSelectionAPI(gos) && _getSuppressMultiRanges(gos) && this.cellRanges.length > 1; + const { suppressMultiRanges } = this.getMultiRangeContext(); + const invalid = _isUsingNewCellSelectionAPI(gos) && suppressMultiRanges && this.cellRanges.length > 1; if (invalid) { _warn(93); } @@ -1438,7 +1447,7 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, return; } - const suppressMultiRanges = _getSuppressMultiRanges(gos); + const { suppressMultiRanges } = this.getMultiRangeContext(); const hasRanges = cellRanges.length > 0; const isMeta = event.ctrlKey || event.metaKey; @@ -1449,6 +1458,10 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, return; } + if ((event as KeyboardEvent).key === KeyCode.ENTER) { + event.preventDefault(); + } + if (event.shiftKey) { // doing range selection const root = ctx.root; diff --git a/packages/ag-grid-enterprise/src/widgets/agFormulaInputField.ts b/packages/ag-grid-enterprise/src/widgets/agFormulaInputField.ts index f2d0472eee2..abe2d3e3201 100644 --- a/packages/ag-grid-enterprise/src/widgets/agFormulaInputField.ts +++ b/packages/ag-grid-enterprise/src/widgets/agFormulaInputField.ts @@ -6,7 +6,7 @@ import type { GridOptionsService, GridOptionsWithDefaults, } from 'ag-grid-community'; -import { AgContentEditableField, _createElement } from 'ag-grid-community'; +import { AgContentEditableField, _createElement, _getDocument, _getWindow } from 'ag-grid-community'; import { getRefTokenMatches } from '../formula/refUtils'; import { agFormulaInputFieldCSS } from './agFormulaInputField.css-GENERATED'; @@ -93,21 +93,6 @@ export class AgFormulaInputField extends AgContentEditableField< return this.currentValue; } - public placeCaretAtEnd(): void { - const contentEl = this.getContentElement(); - const selection = window.getSelection(); - - if (!selection) { - return; - } - - const range = document.createRange(); - range.selectNodeContents(contentEl); - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); - } - public setEditingCellRef(column: any, rowIndex: number | null | undefined): void { const colRef = column ? this.beans.formula?.getColRef(column as any) : undefined; const editingCellRef = @@ -122,7 +107,7 @@ export class AgFormulaInputField extends AgContentEditableField< } public rememberCaret(): void { - const caretOffset = getCaretOffset(this.getContentElement(), this.getCurrentValue()); + const caretOffset = getCaretOffset(this.beans, this.getContentElement(), this.getCurrentValue()); this.selectionCaretOffset = caretOffset ?? this.currentValue.length; } @@ -134,6 +119,7 @@ export class AgFormulaInputField extends AgContentEditableField< private renderFormula(params: { value: string; currentValue: string; caret?: number | null }): void { renderFormula({ + beans: this.beans, contentElement: this.getContentElement(), getColorIndexForToken: this.getColorIndexForToken.bind(this), ...params, @@ -142,10 +128,10 @@ export class AgFormulaInputField extends AgContentEditableField< private renderPlainValue(value: string, caret?: number | null): void { const contentElement = this.getContentElement(); - const caretOffset = caret ?? getCaretOffset(contentElement, this.currentValue); + const caretOffset = caret ?? getCaretOffset(this.beans, contentElement, this.currentValue); contentElement.textContent = value ?? ''; const targetCaret = caretOffset != null ? Math.min(caretOffset, value.length) : null; - restoreCaret(contentElement, targetCaret); + restoreCaret(this.beans, contentElement, targetCaret); } public getColorIndexForRef(ref: string): number | null { @@ -221,7 +207,7 @@ export class AgFormulaInputField extends AgContentEditableField< private onContentInput(): void { const contentElement = this.getContentElement(); const currentValue = this.getCurrentValue(); - const caret = getCaretOffset(contentElement, currentValue); + const caret = getCaretOffset(this.beans, contentElement, currentValue); const serialized = serializeContent(contentElement); const { isFormula, hasFormulaPrefix } = this.getFormulaState(serialized); @@ -337,7 +323,7 @@ export class AgFormulaInputField extends AgContentEditableField< public restoreCaretAfterToken(): void { const caretBase = this.lastTokenCaretOffset ?? - getCaretOffset(this.getContentElement(), this.getCurrentValue()) ?? + getCaretOffset(this.beans, this.getContentElement(), this.getCurrentValue()) ?? this.currentValue.length; const caret = caretBase + (this.lastTokenValueLength ?? 0); this.selectionCaretOffset = null; @@ -347,7 +333,7 @@ export class AgFormulaInputField extends AgContentEditableField< return; } this.getContentElement().focus({ preventScroll: true }); - restoreCaret(this.getContentElement(), caret); + restoreCaret(this.beans, this.getContentElement(), caret); }); } @@ -375,7 +361,7 @@ export class AgFormulaInputField extends AgContentEditableField< let valueOffset = 0; for (const child of Array.from(container.childNodes)) { - const caretLen = getNodeTextLength(child); + const caretLen = _getNodeTextLength(child); const valueLen = getNodeText(child).length; if (caretRemaining <= caretLen) { @@ -406,10 +392,12 @@ export class AgFormulaInputField extends AgContentEditableField< } ): { caretOffset: number; valueOffset: number } | null { // Snapshot the caret position in both caret units and raw string offsets. + const { beans } = this; const contentElement = this.getContentElement(); const caretOffset = options.useCachedCaret - ? this.selectionCaretOffset ?? getCaretOffset(contentElement, value) ?? this.currentValue.length - : getCaretOffset(contentElement, value); + ? this.selectionCaretOffset ?? getCaretOffset(beans, contentElement, value) ?? this.currentValue.length + : getCaretOffset(beans, contentElement, value); + if (caretOffset == null) { return null; } @@ -604,15 +592,20 @@ const getPreviousNonSpaceChar = (value: string, offset: number): string | null = }; // Rendering & caret helpers -const tokenize = (value: string, getColorIndexForToken: (tokenIndex: number) => number | null): Node[] => { +const tokenize = ( + beans: BeanCollection, + value: string, + getColorIndexForToken: (tokenIndex: number) => number | null +): Node[] => { // Split the formula into text + token nodes while preserving operators for display. const nodes: Node[] = []; let lastIndex = 0; const matches = getRefTokenMatches(value); + const doc = _getDocument(beans); for (const match of matches) { if (match.start > lastIndex) { - nodes.push(document.createTextNode(formatForDisplay(value.slice(lastIndex, match.start)))); + nodes.push(doc.createTextNode(formatForDisplay(value.slice(lastIndex, match.start)))); } const colorIndex = getColorIndexForToken(match.index); @@ -621,11 +614,11 @@ const tokenize = (value: string, getColorIndexForToken: (tokenIndex: number) => } if (lastIndex < value.length) { - nodes.push(document.createTextNode(formatForDisplay(value.slice(lastIndex)))); + nodes.push(doc.createTextNode(formatForDisplay(value.slice(lastIndex)))); } if (!nodes.length) { - nodes.push(document.createTextNode('')); + nodes.push(doc.createTextNode('')); } return nodes; @@ -663,6 +656,7 @@ const createReferenceNode = ( }; const renderFormula = (params: { + beans: BeanCollection; contentElement: HTMLElement; currentValue: string; value: string; @@ -670,83 +664,75 @@ const renderFormula = (params: { caret?: number | null; }): void => { // Rebuild the DOM and restore the caret to the same logical position. - const { contentElement, currentValue, value, getColorIndexForToken, caret } = params; - const caretOffset = caret ?? getCaretOffset(contentElement, currentValue); + const { beans, contentElement, currentValue, value, getColorIndexForToken, caret } = params; + const caretOffset = caret ?? getCaretOffset(beans, contentElement, currentValue); const maxCaret = value.length; contentElement.textContent = ''; - for (const node of tokenize(value, getColorIndexForToken)) { + for (const node of tokenize(beans, value, getColorIndexForToken)) { contentElement.append(node); } const targetCaret = caretOffset != null ? Math.min(caretOffset, maxCaret) : null; - restoreCaret(contentElement, targetCaret); + restoreCaret(beans, contentElement, targetCaret); }; -const getCaretOffset = (contentElement: HTMLElement, currentValue: string): number | null => { - // Translate the DOM selection into a caret offset that counts tokens as one unit. - const selection = window.getSelection(); - - if (!selection || selection.rangeCount === 0) { - return currentValue?.length ?? null; - } - - const range = selection.getRangeAt(0); - - if (!contentElement.contains(range.startContainer)) { - return currentValue?.length ?? null; +const getOffsetBeforeNode = (container: HTMLElement, node: Node, useValueLength: boolean = false): number | null => { + // Compute caret/value offsets before a specific node in the tokenized DOM. + if (!container.contains(node)) { + return null; } - // If the caret is directly on the container (between child nodes), the range offset is a - // child index, so convert it to caret units by summing preceding child lengths. - if (range.startContainer === contentElement) { - let offset = 0; - for (let i = 0; i < range.startOffset; i++) { - offset += getNodeTextLength(contentElement.childNodes[i]); + let offset = 0; + for (const child of Array.from(container.childNodes)) { + if (child === node) { + return offset; } - return offset; + offset += useValueLength ? getNodeText(child).length : _getNodeTextLength(child); } - let offset = range.startOffset; - let node: Node | null = range.startContainer; - - while (node && node !== contentElement) { - let sibling = node.previousSibling; + return null; +}; - while (sibling) { - offset += getNodeTextLength(sibling); - sibling = sibling.previousSibling; - } +// Serialization helpers +const serializeContent = (contentElement: HTMLElement): string => { + // Read the tokenized DOM back into the raw formula text. + let output = ''; - node = node.parentNode; - } + contentElement.childNodes.forEach((child) => { + output += getNodeText(child); + }); - return offset; + return output; }; -const restoreCaret = (contentElement: HTMLElement, offset: number | null): void => { - // Place the DOM caret at a logical offset within the tokenized content. - if (offset == null) { - return; +const getNodeText = (node: Node): string => { + // Convert DOM nodes back into value text, undoing display-only operator substitutions. + if (node.nodeType === Node.TEXT_NODE) { + return formatForValue(node.textContent ?? ''); } - const selection = window.getSelection(); - const range = document.createRange(); - const { node, localOffset } = findNodeAtOffset(contentElement, offset); + if (node.nodeType === Node.ELEMENT_NODE) { + return Array.from(node.childNodes) + .map((child) => getNodeText(child)) + .join(''); + } - if (!node || !selection || !contentElement.isConnected || !node.isConnected) { - return; + return ''; +}; + +const _getNodeTextLength = (node: Node): number => { + // Measure text length for caret math (tokens count as their displayed text). + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent?.length ?? 0; } - range.setStart(node, localOffset); - range.collapse(true); - selection.removeAllRanges(); - try { - selection.addRange(range); - } catch { - // Ignore invalid ranges when the editor is detached from the document. + if (node.nodeType === Node.ELEMENT_NODE) { + return Array.from(node.childNodes).reduce((sum, child) => sum + _getNodeTextLength(child), 0); } + + return 0; }; const findNodeAtOffset = (root: Node, offset: number): { node: Node | null; localOffset: number } => { @@ -755,7 +741,7 @@ const findNodeAtOffset = (root: Node, offset: number): { node: Node | null; loca for (let i = 0; i < root.childNodes.length; i++) { const child = root.childNodes[i]; - const length = getNodeTextLength(child); + const length = _getNodeTextLength(child); if (remaining > length) { remaining -= length; @@ -772,61 +758,72 @@ const findNodeAtOffset = (root: Node, offset: number): { node: Node | null; loca return { node: root, localOffset: root.childNodes.length }; }; -const getOffsetBeforeNode = (container: HTMLElement, node: Node, useValueLength: boolean = false): number | null => { - // Compute caret/value offsets before a specific node in the tokenized DOM. - if (!container.contains(node)) { - return null; +const restoreCaret = (beans: BeanCollection, contentElement: HTMLElement, offset: number | null): void => { + // Place the DOM caret at a logical offset within the tokenized content. + if (offset == null) { + return; } - let offset = 0; - for (const child of Array.from(container.childNodes)) { - if (child === node) { - return offset; - } - offset += useValueLength ? getNodeText(child).length : getNodeTextLength(child); + const win = _getWindow(beans); + const doc = _getDocument(beans); + const selection = win.getSelection(); + const range = doc.createRange(); + const { node, localOffset } = findNodeAtOffset(contentElement, offset); + + if (!node || !selection || !contentElement.isConnected || !node.isConnected) { + return; } - return null; + range.setStart(node, localOffset); + range.collapse(true); + selection.removeAllRanges(); + try { + selection.addRange(range); + } catch { + // Ignore invalid ranges when the editor is detached from the document. + } }; -// Serialization helpers -const serializeContent = (contentElement: HTMLElement): string => { - // Read the tokenized DOM back into the raw formula text. - let output = ''; +const getCaretOffset = (beans: BeanCollection, contentElement: HTMLElement, currentValue: string): number | null => { + // Translate the DOM selection into a caret offset that counts tokens as one unit. + const win = _getWindow(beans); + const selection = win.getSelection(); - contentElement.childNodes.forEach((child) => { - output += getNodeText(child); - }); + if (!selection || selection.rangeCount === 0) { + return currentValue?.length ?? null; + } - return output; -}; + const range = selection.getRangeAt(0); -const getNodeText = (node: Node): string => { - // Convert DOM nodes back into value text, undoing display-only operator substitutions. - if (node.nodeType === Node.TEXT_NODE) { - return formatForValue(node.textContent ?? ''); + if (!contentElement.contains(range.startContainer)) { + return currentValue?.length ?? null; } - if (node.nodeType === Node.ELEMENT_NODE) { - return Array.from(node.childNodes) - .map((child) => getNodeText(child)) - .join(''); + // If the caret is directly on the container (between child nodes), the range offset is a + // child index, so convert it to caret units by summing preceding child lengths. + if (range.startContainer === contentElement) { + let offset = 0; + for (let i = 0; i < range.startOffset; i++) { + offset += _getNodeTextLength(contentElement.childNodes[i]); + } + return offset; } - return ''; -}; + let offset = range.startOffset; + let node: Node | null = range.startContainer; -const getNodeTextLength = (node: Node): number => { - // Measure text length for caret math (tokens count as their displayed text). - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent?.length ?? 0; - } + while (node && node !== contentElement) { + let sibling = node.previousSibling; - if (node.nodeType === Node.ELEMENT_NODE) { - return Array.from(node.childNodes).reduce((sum, child) => sum + getNodeTextLength(child), 0); + while (sibling) { + offset += _getNodeTextLength(sibling); + sibling = sibling.previousSibling; + } + + node = node.parentNode; } - return 0; + return offset; }; // Token helpers diff --git a/packages/ag-grid-enterprise/src/widgets/formulaInputRangeSyncFeature.ts b/packages/ag-grid-enterprise/src/widgets/formulaInputRangeSyncFeature.ts index abe86e6343b..dc2cf837828 100644 --- a/packages/ag-grid-enterprise/src/widgets/formulaInputRangeSyncFeature.ts +++ b/packages/ag-grid-enterprise/src/widgets/formulaInputRangeSyncFeature.ts @@ -20,6 +20,7 @@ export class FormulaInputRangeSyncFeature extends BeanStub { private editingCellRef?: string; private editingColumn?: Column; private editingRowIndex?: number; + // Refs found in the formula that should have matching grid ranges (counts handle duplicates). private readonly trackedRangeRefs = new Map(); // Ranges we are actively tracking and their current ref string. @@ -40,6 +41,7 @@ export class FormulaInputRangeSyncFeature extends BeanStub { cellSelectionChanged: this.onCellSelectionChanged.bind(this), }); this.addDestroyFunc(() => this.disableRangeSelectionWhileEditing()); + this.addDestroyFunc(() => this.unregisterActiveEditor()); } public onValueUpdated(value: string, hasFormulaPrefix: boolean): void { @@ -48,6 +50,10 @@ export class FormulaInputRangeSyncFeature extends BeanStub { return; } + if (!this.isActiveEditor()) { + return; + } + if (hasFormulaPrefix) { // Enable range selection once the user is building a formula (even if it is just "="). const newlyEnabled = this.enableRangeSelectionWhileEditing(); @@ -67,6 +73,40 @@ export class FormulaInputRangeSyncFeature extends BeanStub { this.editingColumn = column; this.editingRowIndex = rowIndex ?? undefined; this.editingCellRef = editingCellRef; + this.registerActiveEditor(); + } + + private registerActiveEditor(): void { + const fieldId = this.field.getCompId(); + const { formula } = this.beans; + + if (!formula) { + return; + } + + if (formula.activeEditor !== fieldId) { + formula.activeEditor = fieldId; + } + } + + private unregisterActiveEditor(): void { + const fieldId = this.field.getCompId(); + const { formula } = this.beans; + + if (!formula) { + return; + } + + if (formula.activeEditor === fieldId) { + formula.activeEditor = null; + } + } + + private isActiveEditor(): boolean { + const fieldId = this.field.getCompId(); + const { formula } = this.beans; + + return !!formula && formula.activeEditor === fieldId; } private getTrackedRefCount(ref: string): number { @@ -105,12 +145,14 @@ export class FormulaInputRangeSyncFeature extends BeanStub { } this.rangeSelectionEnabled = false; this.beans.editSvc?.disableRangeSelectionWhileEditing?.(); - this.clearTrackedRanges(); + this.clearTrackedRanges(this.isActiveEditor()); } - private clearTrackedRanges(): void { - const refs = Array.from(this.trackedRangeRefs.keys()); - refs.forEach((ref) => this.removeRangeForRef(ref)); + private clearTrackedRanges(clearGridRanges: boolean = true): void { + if (clearGridRanges) { + const refs = Array.from(this.trackedRangeRefs.keys()); + refs.forEach((ref) => this.removeRangeForRef(ref)); + } this.trackedRangeRefs.clear(); this.trackedRanges.clear(); } @@ -168,6 +210,35 @@ export class FormulaInputRangeSyncFeature extends BeanStub { this.trackedRanges.set(range, { ref, tokenIndex: nextTokenIndex }); } + private getUntrackedFormulaRangesByRef(): Map { + const rangesByRef = new Map(); + const ranges = this.getLiveRanges(); + + for (const range of ranges) { + if (this.trackedRanges.has(range)) { + continue; + } + + if (getRangeColorIndexFromClass(range.colorClass) == null) { + continue; + } + + const ref = rangeToRef(this.beans, range); + if (!ref || ref === this.editingCellRef) { + continue; + } + + const existing = rangesByRef.get(ref); + if (existing) { + existing.push(range); + } else { + rangesByRef.set(ref, [range]); + } + } + + return rangesByRef; + } + private syncRangesFromFormula(value?: string | null): void { // Keep grid ranges in sync with the current refs in the editor text. // This is the "source of truth" pass: it creates/removes ranges to match tokens, @@ -213,6 +284,7 @@ export class FormulaInputRangeSyncFeature extends BeanStub { } } + const untrackedFormulaRanges = this.getUntrackedFormulaRangesByRef(); let reTagged = false; for (const [ref, tokenIndices] of desiredByRef.entries()) { const rangesForRef: CellRange[] = []; @@ -222,6 +294,14 @@ export class FormulaInputRangeSyncFeature extends BeanStub { } } + const reuseCandidates = untrackedFormulaRanges.get(ref); + while (rangesForRef.length < tokenIndices.length && reuseCandidates?.length) { + const candidate = reuseCandidates.shift(); + if (candidate) { + rangesForRef.push(candidate); + } + } + while (rangesForRef.length > tokenIndices.length) { const range = rangesForRef.pop(); if (range) { @@ -249,13 +329,33 @@ export class FormulaInputRangeSyncFeature extends BeanStub { } } + const unusedFormulaRanges: CellRange[] = []; + for (const ranges of untrackedFormulaRanges.values()) { + if (ranges.length) { + unusedFormulaRanges.push(...ranges); + } + } + + if (unusedFormulaRanges.length) { + const currentRanges = this.getLiveRanges(); + const remaining = currentRanges.filter((range) => !unusedFormulaRanges.includes(range)); + if (remaining.length !== currentRanges.length) { + this.setCellRangesSilently(remaining); + reTagged = true; + } + } + if (reTagged) { this.refreshRangeStyling(); } } private onCellSelectionChanged(event: CellSelectionChangedEvent): void { - if (!this.rangeSelectionEnabled || !this.beans.editSvc?.isRangeSelectionEnabledWhileEditing?.()) { + if ( + !this.isActiveEditor() || + !this.rangeSelectionEnabled || + !this.beans.editSvc?.isRangeSelectionEnabledWhileEditing?.() + ) { return; } if (this.ignoreNextRangeEvent) { @@ -713,9 +813,4 @@ export class FormulaInputRangeSyncFeature extends BeanStub { return updated; } - - public override destroy(): void { - this.clearTrackedRanges(); - super.destroy(); - } } diff --git a/testing/behavioural/src/selection/master-detail-row-selection.test.ts b/testing/behavioural/src/selection/master-detail-row-selection.test.ts index f2a09f9ccc8..d510fbf9958 100644 --- a/testing/behavioural/src/selection/master-detail-row-selection.test.ts +++ b/testing/behavioural/src/selection/master-detail-row-selection.test.ts @@ -1,10 +1,10 @@ import type { MockInstance } from 'vitest'; -import type { GetDetailRowDataParams, GridApi, GridOptions } from 'ag-grid-community'; +import type { DetailGridInfo, GetDetailRowDataParams, GridApi, GridOptions } from 'ag-grid-community'; import { ClientSideRowModelModule } from 'ag-grid-community'; import { MasterDetailModule } from 'ag-grid-enterprise'; -import { TestGridsManager, assertSelectedRowsByIndex, waitForEvent } from '../test-utils'; +import { TestGridsManager, assertSelectedRowsByIndex, asyncSetTimeout, waitForEvent } from '../test-utils'; import { GridActions } from './utils'; describe('Row Selection Grid Options', () => { @@ -208,4 +208,89 @@ describe('Row Selection Grid Options', () => { assertSelectedRowsByIndex([], info.api!); expect(node.isSelected()).toBe(false); }); + + test('detail state properly tracked and restored when collapsing and re-expanding detail grid', async () => { + const [api, actions] = await createGridAndWait({ + columnDefs, + rowData, + rowSelection: { mode: 'multiRow', masterSelects: 'detail' }, + masterDetail: true, + detailCellRendererParams: { + detailGridOptions: { + columnDefs: detailColumnDefs, + rowSelection: { mode: 'multiRow' }, + }, + getDetailRowData(params: GetDetailRowDataParams) { + params.successCallback(params.data.detail); + }, + }, + }); + + let info: DetailGridInfo | undefined; + let detailActions: GridActions; + let wait: Promise; + + ////////// + // Round 1 + ////////// + + await actions.expandGroupRowByIndex(1, { count: 1 }); + + info = api.getDetailGridInfo('detail_1')!; + expect(info).not.toBeUndefined(); + + await waitForEvent('firstDataRendered', info.api!); + + detailActions = new GridActions(info.api!, '[row-id="detail_1"]'); + + wait = waitForEvent('rowSelected', info.api!, 2); + detailActions.toggleCheckboxByIndex(0); + detailActions.toggleCheckboxByIndex(1); + await wait; + + // Detail rows selected + assertSelectedRowsByIndex([0, 1], info.api!); + + // Master indeterminate + const node = api.getRowNode('1')!; + expect(node).not.toBeUndefined(); + expect(node.isSelected()).toBeUndefined(); + + ////////// + // Round 2 + ////////// + + // Collapse and re-expand master row to hide/show detail grid + await actions.collapseGroupRowByIndex(1, { count: 1 }); + await actions.expandGroupRowByIndex(1, { count: 1 }); + await asyncSetTimeout(10); + + info = api.getDetailGridInfo('detail_1')!; + detailActions = new GridActions(info.api!, '[row-id="detail_1"]'); + + // Detail grid should have same rows selected + assertSelectedRowsByIndex([0, 1], info.api!); + + // Deselect a detail row + wait = waitForEvent('rowSelected', info.api!); + detailActions.toggleCheckboxByIndex(1); + await wait; + + assertSelectedRowsByIndex([0], info.api!); + + ////////// + // Round 3 + ////////// + + // Collapse and re-expand master row again + await actions.collapseGroupRowByIndex(1, { count: 1 }); + await actions.expandGroupRowByIndex(1, { count: 1 }); + await asyncSetTimeout(10); + + info = api.getDetailGridInfo('detail_1')!; + detailActions = new GridActions(info.api!, '[row-id="detail_1"]'); + + // Detail grid should have same rows selected + assertSelectedRowsByIndex([0], info.api!); + }); }); diff --git a/testing/behavioural/src/selection/utils.ts b/testing/behavioural/src/selection/utils.ts index 72becb9d2f5..90fef13081d 100644 --- a/testing/behavioural/src/selection/utils.ts +++ b/testing/behavioural/src/selection/utils.ts @@ -93,6 +93,12 @@ export class GridActions { ?.dispatchEvent(new MouseEvent('click', { ...opts, bubbles: true })); } + clickCollapseGroupRowByIndex(index: number, opts?: MouseEventInit): void { + this.getRowByIndex(index) + ?.querySelector('.ag-group-expanded') + ?.dispatchEvent(new MouseEvent('click', { ...opts, bubbles: true })); + } + async expandGroupRowByIndex(index: number, opts?: MouseEventInit & { count?: number }): Promise { const updated = waitForEvent('modelUpdated', this.api, opts?.count ?? 2); // attach listener first this.clickExpandGroupRowByIndex(index, opts); @@ -104,6 +110,12 @@ export class GridActions { this.clickExpandGroupRowById(id, opts); await updated; } + + async collapseGroupRowByIndex(index: number, opts?: MouseEventInit & { count?: number }): Promise { + const updated = waitForEvent('modelUpdated', this.api, opts?.count ?? 2); + this.clickCollapseGroupRowByIndex(index, opts); + await updated; + } } export function pressSpaceKey(element: HTMLElement, opts?: KeyboardEventInit): void {