From f376420024563a9e511fae78b08b6e79ef5ff079 Mon Sep 17 00:00:00 2001 From: Guilherme Lopes Date: Mon, 26 Jan 2026 12:03:44 -0300 Subject: [PATCH 1/5] AG-16636 - [excel-exporter] - Custom Metadata (#12966) --- .../main.ts | 2 - .../example.spec.ts | 11 +++ .../index.html | 8 +++ .../main.ts | 68 +++++++++++++++++++ .../styles.css | 39 +++++++++++ .../index.mdoc | 26 +++++++ .../excel-export-api/excel-api.json | 3 + .../src/interfaces/iExcelCreator.ts | 9 +++ packages/ag-grid-community/src/main.ts | 2 + .../src/excelExport/excelCreator.ts | 48 +++++++++---- .../src/excelExport/excelXlsxFactory.test.ts | 38 ++++++++++- .../src/excelExport/excelXlsxFactory.ts | 26 +++++-- .../excelExport/files/ooxml/contentTypes.ts | 13 +++- .../files/ooxml/customProperties.ts | 44 ++++++++++++ 14 files changed, 314 insertions(+), 23 deletions(-) create mode 100644 documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/example.spec.ts create mode 100644 documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/index.html create mode 100644 documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/main.ts create mode 100644 documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/styles.css create mode 100644 packages/ag-grid-enterprise/src/excelExport/files/ooxml/customProperties.ts diff --git a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-column-group-headers/main.ts b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-column-group-headers/main.ts index 8532f5c8f68..dd56d00ed46 100644 --- a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-column-group-headers/main.ts +++ b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-column-group-headers/main.ts @@ -11,8 +11,6 @@ import { CsvExportModule, ModuleRegistry, NumberFilterModule, - ProcessCellForExportParams, - ProcessRowGroupForExportParams, ValidationModule, createGrid, } from 'ag-grid-community'; diff --git a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/example.spec.ts new file mode 100644 index 00000000000..ed79767b830 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/example.spec.ts @@ -0,0 +1,11 @@ +import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; + +test.agExample(import.meta, () => { + test.eachFramework('Example', async ({ page }) => { + // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS + await ensureGridReady(page); + await waitForGridContent(page); + await clickAllButtons(page); + // END PLACEHOLDER + }); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/index.html b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/index.html new file mode 100644 index 00000000000..abd395e2bc7 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/index.html @@ -0,0 +1,8 @@ +
+
+ +
+
+
+
+
diff --git a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/main.ts b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/main.ts new file mode 100644 index 00000000000..813d7a70598 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/main.ts @@ -0,0 +1,68 @@ +import type { GridApi, GridOptions } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + CsvExportModule, + ModuleRegistry, + NumberFilterModule, + ValidationModule, + createGrid, +} from 'ag-grid-community'; +import { ExcelExportModule } from 'ag-grid-enterprise'; + +ModuleRegistry.registerModules([ + ClientSideRowModelModule, + CsvExportModule, + ExcelExportModule, + NumberFilterModule, + ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), +]); + +interface ReportRow { + department: string; + reportId: string; + owner: string; + cost: number; +} + +const rowData: ReportRow[] = [ + { department: 'Security', reportId: 'RPT-001', owner: 'Morgan', cost: 1200 }, + { department: 'Finance', reportId: 'RPT-014', owner: 'Avery', cost: 5400 }, + { department: 'Operations', reportId: 'RPT-082', owner: 'Jordan', cost: 3100 }, + { department: 'Legal', reportId: 'RPT-109', owner: 'Taylor', cost: 2700 }, +]; + +const excelCustomMetadata = { + ExportID: '12345', + GeneratedBy: 'AgGrid', + ExpirationDate: '2025-01-01T12:00:00Z', +}; + +let gridApi: GridApi; + +const gridOptions: GridOptions = { + columnDefs: [ + { field: 'department', minWidth: 160 }, + { field: 'reportId', minWidth: 140 }, + { field: 'owner', minWidth: 140 }, + { field: 'cost', filter: 'agNumberColumnFilter', minWidth: 120 }, + ], + defaultColDef: { + filter: true, + flex: 1, + minWidth: 120, + }, + rowData, + defaultExcelExportParams: { + excelCustomMetadata: excelCustomMetadata, + }, +}; + +function onBtExport() { + gridApi!.exportDataAsExcel(); +} + +// setup the grid after the page has finished loading +document.addEventListener('DOMContentLoaded', () => { + const gridDiv = document.querySelector('#myGrid')!; + gridApi = createGrid(gridDiv, gridOptions); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/styles.css b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/styles.css new file mode 100644 index 00000000000..bb5a0074402 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/styles.css @@ -0,0 +1,39 @@ +.details > label { + margin-bottom: 10px; +} + +.details > label:first-of-type { + margin-top: 10px; +} + +.details > label:last-of-type { + margin-bottom: 0; +} + +.option { + display: block; + margin: 5px 10px 5px 0; +} + +.grid-wrapper { + display: flex; + flex: 1 1 0px; +} + +.grid-wrapper > div { + width: 100%; + height: 100%; +} + +.container { + display: flex; + flex-direction: column; + height: 100%; +} + +.columns { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; +} diff --git a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/index.mdoc b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/index.mdoc index ce99f31a035..352cf13386b 100644 --- a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/index.mdoc @@ -58,6 +58,32 @@ The following example shows Excel customisations where the exported document has {% gridExampleRunner title="Excel Export - Customising Column Group Headers" name="excel-export-customising-column-group-headers" /%} +## Custom Metadata + +Use `excelCustomMetadata` to write custom document properties to the exported file. The values are added as metadata to the Excel file and serialised as strings. + +This is useful for attaching internal identifiers, workflow hints, or metadata consumed by downstream systems. + +```{% frameworkTransform=true %} +gridApi.exportDataAsExcel({ + excelCustomMetadata: { + 'ExportID': '12345', + 'GeneratedBy': 'AgGrid', + 'ExpirationDate': '2025-01-01T12:00:00Z', + }, +}); +``` + +{% interfaceDocumentation interfaceName="ExcelExportParams" names=["excelCustomMetadata"] /%} + +The following example adds custom properties during export. Some labelling workflows read custom properties (for example, Microsoft Purview uses `MSIP_Label_*` keys). + +{% note %} +The Grid does not interpret these values or apply labels; it only writes the custom properties that are provide in the `excelCustomMetadata` parameter. +{% /note %} + +{% gridExampleRunner title="Excel Export - Custom Metadata" name="excel-export-customising-custom-metadata" /%} + ## Next Up Continue to the next section: [Images](./excel-export-images/). diff --git a/documentation/ag-grid-docs/src/content/interface-documentation/excel-export-api/excel-api.json b/documentation/ag-grid-docs/src/content/interface-documentation/excel-export-api/excel-api.json index 691ac6be0df..39df7932620 100644 --- a/documentation/ag-grid-docs/src/content/interface-documentation/excel-export-api/excel-api.json +++ b/documentation/ag-grid-docs/src/content/interface-documentation/excel-export-api/excel-api.json @@ -7,6 +7,9 @@ "prependContent": { "description": "Content to put at the bottom of the exported sheet. An array of ExcelRow objects, see [Extra Content section](./excel-export-extra-content/)." }, + "excelCustomMetadata": { + "description": "Custom metadata to be written to in the exported file. Values are serialised as strings." + }, "fileName": { "default": "export.xlsx" } diff --git a/packages/ag-grid-community/src/interfaces/iExcelCreator.ts b/packages/ag-grid-community/src/interfaces/iExcelCreator.ts index ae2b27bfb9e..7e4413dc7d5 100644 --- a/packages/ag-grid-community/src/interfaces/iExcelCreator.ts +++ b/packages/ag-grid-community/src/interfaces/iExcelCreator.ts @@ -575,6 +575,9 @@ export interface ExcelWorksheetConfigParams { ) => { image: ExcelImage; value?: string } | undefined; } +export type ExcelCustomMetadataValue = string | number | boolean; +export type ExcelCustomMetadata = Record; + interface ExcelFileParams { /** * String to use as the file name or a function that returns a string. @@ -596,6 +599,12 @@ interface ExcelFileParams { * @default 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' */ mimeType?: string; + + /** + * Custom metadata to write to `docProps/custom.xml` in the exported file. + * Values are serialised as strings. + */ + excelCustomMetadata?: ExcelCustomMetadata; } export interface ExcelExportParams extends ExcelFileParams, ExcelWorksheetConfigParams, ExportParams {} diff --git a/packages/ag-grid-community/src/main.ts b/packages/ag-grid-community/src/main.ts index 97a60252f9f..96ae1b749aa 100644 --- a/packages/ag-grid-community/src/main.ts +++ b/packages/ag-grid-community/src/main.ts @@ -84,6 +84,8 @@ export { ExcelCell, ExcelColumn, ExcelContentType, + ExcelCustomMetadata, + ExcelCustomMetadataValue, ExcelData, ExcelDataType, ExcelExportMultipleSheetParams, diff --git a/packages/ag-grid-enterprise/src/excelExport/excelCreator.ts b/packages/ag-grid-enterprise/src/excelExport/excelCreator.ts index 6035b834883..f28ddbf6c59 100644 --- a/packages/ag-grid-enterprise/src/excelExport/excelCreator.ts +++ b/packages/ag-grid-enterprise/src/excelExport/excelCreator.ts @@ -1,6 +1,7 @@ import type { AgColumn, AgColumnGroup, + ExcelCustomMetadata, ExcelExportMultipleSheetParams, ExcelExportParams, ExcelFactoryMode, @@ -28,6 +29,7 @@ import { XLSX_WORKSHEET_IMAGES, createXlsxContentTypes, createXlsxCore, + createXlsxCustomProperties, createXlsxDrawing, createXlsxDrawingRel, createXlsxRelationships, @@ -151,16 +153,25 @@ const createExcelXmlCoreSheets = ( fontSize: number, author: string, sheetLen: number, - activeTab: number + activeTab: number, + customMetadata?: ExcelCustomMetadata ): void => { + const hasCustomMetadata = + !!customMetadata && Object.keys(customMetadata).some((key) => customMetadata[key] != null); + zipContainer.addFile('xl/workbook.xml', createXlsxWorkbook(activeTab)); zipContainer.addFile('xl/styles.xml', createXlsxStylesheet(fontSize)); zipContainer.addFile('xl/sharedStrings.xml', createXlsxSharedStrings()); zipContainer.addFile('xl/theme/theme1.xml', createXlsxTheme()); zipContainer.addFile('xl/_rels/workbook.xml.rels', createXlsxWorkbookRels(sheetLen)); zipContainer.addFile('docProps/core.xml', createXlsxCore(author)); - zipContainer.addFile('[Content_Types].xml', createXlsxContentTypes(sheetLen)); - zipContainer.addFile('_rels/.rels', createXlsxRels()); + + if (hasCustomMetadata) { + zipContainer.addFile('docProps/custom.xml', createXlsxCustomProperties(customMetadata)); + } + + zipContainer.addFile('[Content_Types].xml', createXlsxContentTypes(sheetLen, hasCustomMetadata)); + zipContainer.addFile('_rels/.rels', createXlsxRels(hasCustomMetadata)); }; const createExcelFileForExcel = ( @@ -172,6 +183,7 @@ const createExcelFileForExcel = ( fontSize?: number; author?: string; activeTab?: number; + customMetadata?: ExcelCustomMetadata; } = {}, workbook: Workbook ): boolean => { @@ -183,7 +195,7 @@ const createExcelFileForExcel = ( workbook.syncOrderWithSheetData(data); - const { fontSize = 11, author = 'AG Grid', activeTab = 0 } = options; + const { fontSize = 11, author = 'AG Grid', activeTab = 0, customMetadata } = options; const len = data.length; const activeTabWithinBounds = Math.max(Math.min(activeTab, len - 1), 0); @@ -191,7 +203,7 @@ const createExcelFileForExcel = ( createExcelXMLCoreFolderStructure(zipContainer); createExcelXmlTables(zipContainer); createExcelXmlWorksheets(zipContainer, data); - createExcelXmlCoreSheets(zipContainer, fontSize, author, len, activeTabWithinBounds); + createExcelXmlCoreSheets(zipContainer, fontSize, author, len, activeTabWithinBounds, customMetadata); workbook.reset(); @@ -202,7 +214,7 @@ const getMultipleSheetsAsExcelCompressed = ( params: ExcelExportMultipleSheetParams, workbook: Workbook = new Workbook() ): Promise => { - const { data, fontSize, author, activeSheetIndex } = params; + const { data, fontSize, author, activeSheetIndex, excelCustomMetadata } = params; const mimeType = params.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const zipContainer = new ZipContainer(); @@ -214,6 +226,7 @@ const getMultipleSheetsAsExcelCompressed = ( author, fontSize, activeTab: activeSheetIndex, + customMetadata: excelCustomMetadata, }, workbook ) @@ -228,7 +241,7 @@ export const getMultipleSheetsAsExcel = ( params: ExcelExportMultipleSheetParams, workbook: Workbook = new Workbook() ): Blob | undefined => { - const { data, fontSize, author, activeSheetIndex } = params; + const { data, fontSize, author, activeSheetIndex: activeTab, excelCustomMetadata: customMetadata } = params; const mimeType = params.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const zipContainer = new ZipContainer(); @@ -239,7 +252,8 @@ export const getMultipleSheetsAsExcel = ( { author, fontSize, - activeTab: activeSheetIndex, + activeTab, + customMetadata, }, workbook ) @@ -285,11 +299,14 @@ export class ExcelCreator const mergedParams = this.getMergedParams(userParams); const data = this.getData(mergedParams); + const { fontSize, author, mimeType, excelCustomMetadata } = mergedParams; + const exportParams: ExcelExportMultipleSheetParams = { data: [data], - fontSize: mergedParams.fontSize, - author: mergedParams.author, - mimeType: mergedParams.mimeType, + fontSize, + author, + mimeType, + excelCustomMetadata, }; this.packageCompressedFile(exportParams).then((packageFile) => { @@ -318,11 +335,14 @@ export class ExcelCreator const mergedParams = this.getMergedParams(params); const data = this.getData(mergedParams); + const { fontSize, author, mimeType, excelCustomMetadata } = mergedParams; + const exportParams: ExcelExportMultipleSheetParams = { data: [data], - fontSize: mergedParams.fontSize, - author: mergedParams.author, - mimeType: mergedParams.mimeType, + fontSize, + author, + mimeType, + excelCustomMetadata, }; return this.packageFile(exportParams); diff --git a/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.test.ts b/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.test.ts index a97cfeccd76..c1b08b61cfe 100644 --- a/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.test.ts +++ b/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.test.ts @@ -1,6 +1,6 @@ import type { ExcelGridSerializingParams } from './excelSerializingSession'; import { ExcelSerializingSession } from './excelSerializingSession'; -import { Workbook } from './excelXlsxFactory'; +import { Workbook, createXlsxContentTypes, createXlsxCustomProperties, createXlsxRels } from './excelXlsxFactory'; const stubParams = ( overrides: Partial = {}, @@ -151,6 +151,42 @@ describe('excelXlsxFactory Workbook', () => { }); }); +describe('excelXlsxFactory custom metadata', () => { + afterEach(() => { + new Workbook().reset(); + }); + + it('writes custom properties using stringified values', () => { + const xml = createXlsxCustomProperties({ + 'MSIP_Label_8f3c2a91-bd44-4e6a-9d7c-5e3b9c2f1a84_Enabled': true, + 'MSIP_Label_8f3c2a91-bd44-4e6a-9d7c-5e3b9c2f1a84_SetDate': '2026-01-01T12:00:00Z', + 'MSIP_Label_8f3c2a91-bd44-4e6a-9d7c-5e3b9c2f1a84_Method': 'Privileged', + 'MSIP_Label_8f3c2a91-bd44-4e6a-9d7c-5e3b9c2f1a84_Name': 'Confidential', + 'MSIP_Label_8f3c2a91-bd44-4e6a-9d7c-5e3b9c2f1a84_SiteId': '2c6d7f14-91e8-4a2f-b0b5-9c1d3e4f6a72', + 'MSIP_Label_8f3c2a91-bd44-4e6a-9d7c-5e3b9c2f1a84_ActionId': 'a17d9c6b-43f5-4c82-9a8e-6b2f1e3c9d40', + 'MSIP_Label_8f3c2a91-bd44-4e6a-9d7c-5e3b9c2f1a84_ContentBits': 2, + }); + + expect(xml).toContain('custom-properties'); + expect(xml).toContain('MSIP_Label_8f3c2a91-bd44-4e6a-9d7c-5e3b9c2f1a84_Enabled'); + expect(xml).toContain('true'); + expect(xml).toContain('2026-01-01T12:00:00Z'); + expect(xml).toContain('Confidential'); + expect(xml).toContain('2c6d7f14-91e8-4a2f-b0b5-9c1d3e4f6a72'); + expect(xml).toContain('a17d9c6b-43f5-4c82-9a8e-6b2f1e3c9d40'); + expect(xml).toContain('2'); + }); + + it('adds custom properties parts to rels and content types', () => { + const rels = createXlsxRels(true); + const contentTypes = createXlsxContentTypes(1, true); + + expect(rels).toContain('custom-properties'); + expect(contentTypes).toContain('custom-properties'); + expect(contentTypes).toContain('/docProps/custom.xml'); + }); +}); + describe('excel styles', () => { const workbookStub: Workbook = { getStringPosition: (() => { diff --git a/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.ts b/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.ts index 7e0eb456f64..02d74747ccb 100644 --- a/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.ts +++ b/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.ts @@ -1,5 +1,6 @@ import type { AgColumn, + ExcelCustomMetadata, ExcelExportParams, ExcelFactoryMode, ExcelHeaderFooterImage, @@ -23,6 +24,7 @@ import { createXmlPart, setExcelImageTotalHeight, setExcelImageTotalWidth } from import type { ExcelGridSerializingParams } from './excelSerializingSession'; import contentTypesFactory, { _normaliseImageExtension } from './files/ooxml/contentTypes'; import coreFactory from './files/ooxml/core'; +import customPropertiesFactory from './files/ooxml/customProperties'; import drawingFactory from './files/ooxml/drawing'; import relationshipsFactory from './files/ooxml/relationships'; import sharedStringsFactory from './files/ooxml/sharedStrings'; @@ -321,12 +323,16 @@ export function createXlsxCore(author: string): string { return createXmlPart(coreFactory.getTemplate(author)); } -export function createXlsxContentTypes(sheetLen: number): string { - return createXmlPart(contentTypesFactory.getTemplate(sheetLen)); +export function createXlsxCustomProperties(metadata: ExcelCustomMetadata): string { + return createXmlPart(customPropertiesFactory.getTemplate(metadata)); } -export function createXlsxRels(): string { - const rs = relationshipsFactory.getTemplate([ +export function createXlsxContentTypes(sheetLen: number, hasCustomProperties?: boolean): string { + return createXmlPart(contentTypesFactory.getTemplate({ sheetLen, hasCustomProperties })); +} + +export function createXlsxRels(hasCustomProperties?: boolean): string { + const relationships: ExcelRelationship[] = [ { Id: 'rId1', Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument', @@ -337,7 +343,17 @@ export function createXlsxRels(): string { Type: 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties', Target: 'docProps/core.xml', }, - ]); + ]; + + if (hasCustomProperties) { + relationships.push({ + Id: 'rId3', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties', + Target: 'docProps/custom.xml', + }); + } + + const rs = relationshipsFactory.getTemplate(relationships); return createXmlPart(rs); } diff --git a/packages/ag-grid-enterprise/src/excelExport/files/ooxml/contentTypes.ts b/packages/ag-grid-enterprise/src/excelExport/files/ooxml/contentTypes.ts index cb2dd048a9e..57b993bbcbd 100644 --- a/packages/ag-grid-enterprise/src/excelExport/files/ooxml/contentTypes.ts +++ b/packages/ag-grid-enterprise/src/excelExport/files/ooxml/contentTypes.ts @@ -12,7 +12,7 @@ type ImageExtension = 'jpeg' | 'png' | 'gif'; export const _normaliseImageExtension = (ext: 'jpg' | 'png' | 'gif'): ImageExtension => (ext === 'jpg' ? 'jpeg' : ext); const contentTypesFactory: ExcelOOXMLTemplate = { - getTemplate(sheetLen: number) { + getTemplate({ sheetLen, hasCustomProperties }: { sheetLen: number; hasCustomProperties?: boolean }) { const worksheets = new Array(sheetLen).fill(undefined).map((v, i) => ({ name: 'Override', ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml', @@ -43,6 +43,16 @@ const contentTypesFactory: ExcelOOXMLTemplate = { }); }); + const customPropertiesDocs = hasCustomProperties + ? [ + { + name: 'Override', + ContentType: 'application/vnd.openxmlformats-officedocument.custom-properties+xml', + PartName: '/docProps/custom.xml', + }, + ] + : []; + const imageTypes = Object.keys(imageTypesObject).map((ext) => ({ name: 'Default', ContentType: `image/${ext}`, @@ -97,6 +107,7 @@ const contentTypesFactory: ExcelOOXMLTemplate = { ContentType: 'application/vnd.openxmlformats-package.core-properties+xml', PartName: '/docProps/core.xml', }, + ...customPropertiesDocs, ].map((contentType) => contentTypeFactory.getTemplate(contentType)); return { diff --git a/packages/ag-grid-enterprise/src/excelExport/files/ooxml/customProperties.ts b/packages/ag-grid-enterprise/src/excelExport/files/ooxml/customProperties.ts new file mode 100644 index 00000000000..1ffd7466ed7 --- /dev/null +++ b/packages/ag-grid-enterprise/src/excelExport/files/ooxml/customProperties.ts @@ -0,0 +1,44 @@ +import { _escapeString } from 'ag-grid-community'; +import type { ExcelCustomMetadata, ExcelOOXMLTemplate, XmlElement } from 'ag-grid-community'; + +import { replaceInvisibleCharacters } from '../../assets/excelUtils'; + +const DEFAULT_FMTID = '{D5CDD505-2E9C-101B-9397-08002B2CF9AE}'; + +const buildPropertyElements = (metadata: ExcelCustomMetadata): XmlElement[] => { + const keys = Object.keys(metadata).filter((name) => name && metadata[name] != null); + + return keys.map((name, index) => ({ + name: 'property', + properties: { + rawMap: { + fmtid: DEFAULT_FMTID, + pid: (index + 2).toString(), + name: _escapeString(name) ?? '', + }, + }, + children: [ + { + name: 'vt:lpwstr', + textNode: _escapeString(replaceInvisibleCharacters(String(metadata[name]))) ?? '', + }, + ], + })); +}; + +const customPropertiesFactory: ExcelOOXMLTemplate = { + getTemplate(metadata: ExcelCustomMetadata) { + return { + name: 'Properties', + properties: { + rawMap: { + xmlns: 'http://schemas.openxmlformats.org/officeDocument/2006/custom-properties', + 'xmlns:vt': 'http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes', + }, + }, + children: buildPropertyElements(metadata), + }; + }, +}; + +export default customPropertiesFactory; From 18024de185946e71f1bc161b916e041425570968 Mon Sep 17 00:00:00 2001 From: Guilherme Lopes Date: Mon, 26 Jan 2026 12:15:20 -0300 Subject: [PATCH 2/5] AG-16628 - toggle ag-sticky-label to improve performance (#12964) * AG-16628 - toggle ag-sticky-label to improve performance Co-authored-by: Bernie Sumption * AG-16628 use JS model for scroll instead of DOM --------- Co-authored-by: Bernie Sumption --- .../src/columns/columnViewportService.ts | 4 + .../columnGroup/groupStickyLabelFeature.ts | 89 +++++++++++++++++++ .../cells/columnGroup/headerGroupComp.ts | 5 +- 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/ag-grid-community/src/headerRendering/cells/columnGroup/groupStickyLabelFeature.ts diff --git a/packages/ag-grid-community/src/columns/columnViewportService.ts b/packages/ag-grid-community/src/columns/columnViewportService.ts index 44fa2f83a17..312277ea7de 100644 --- a/packages/ag-grid-community/src/columns/columnViewportService.ts +++ b/packages/ag-grid-community/src/columns/columnViewportService.ts @@ -50,6 +50,10 @@ export class ColumnViewportService extends BeanStub implements NamedBean { this.suppressColumnVirtualisation = this.gos.get('suppressColumnVirtualisation'); } + public getScrollPosition(): number { + return this.scrollPosition; + } + public setScrollPosition(scrollWidth: number, scrollPosition: number, afterScroll: boolean = false): void { const { visibleCols } = this; const bodyWidthDirty = visibleCols.isBodyWidthDirty; diff --git a/packages/ag-grid-community/src/headerRendering/cells/columnGroup/groupStickyLabelFeature.ts b/packages/ag-grid-community/src/headerRendering/cells/columnGroup/groupStickyLabelFeature.ts new file mode 100644 index 00000000000..cd037785f50 --- /dev/null +++ b/packages/ag-grid-community/src/headerRendering/cells/columnGroup/groupStickyLabelFeature.ts @@ -0,0 +1,89 @@ +import { BeanStub } from '../../../context/beanStub'; +import type { AgColumnGroup } from '../../../entities/agColumnGroup'; +import type { BodyScrollEvent } from '../../../events'; + +export class GroupStickyLabelFeature extends BeanStub { + private isSticky = false; + private left: number | null = null; + private right: number | null = null; + + constructor( + private readonly eLabel: HTMLElement, + private readonly columnGroup: AgColumnGroup + ) { + super(); + } + + public postConstruct(): void { + const { columnGroup, beans } = this; + const { ctrlsSvc } = beans; + ctrlsSvc.whenReady(this, () => { + const refreshPosition = this.refreshPosition.bind(this); + + if (columnGroup.getPinned() == null) { + this.addManagedEventListeners({ + bodyScroll: (event: BodyScrollEvent) => { + if (event.direction === 'horizontal') { + this.updateSticky(event.left); + } + }, + }); + } + + this.addManagedListeners(columnGroup, { + leftChanged: refreshPosition, + displayedChildrenChanged: refreshPosition, + }); + this.addManagedEventListeners({ + columnResized: refreshPosition, + }); + + this.refreshPosition(); + }); + } + + private refreshPosition(): void { + const { columnGroup, beans } = this; + const left = columnGroup.getLeft(); + const width = columnGroup.getActualWidth(); + + if (left == null || width === 0) { + this.left = null; + this.right = null; + this.setSticky(false); + return; + } + + this.left = left; + this.right = left + width; + + const scrollPosition = beans.colViewport.getScrollPosition(); + if (scrollPosition != null) { + this.updateSticky(scrollPosition); + } + } + + private updateSticky(scrollLeft: number): void { + const { beans, left, right } = this; + + if (left == null || right == null) { + this.setSticky(false); + return; + } + + const { gos, visibleCols } = beans; + const isRtl = gos.get('enableRtl'); + const viewportEdge = isRtl ? visibleCols.bodyWidth - scrollLeft : scrollLeft; + this.setSticky(left < viewportEdge && right > viewportEdge); + } + + private setSticky(value: boolean): void { + const { isSticky, eLabel } = this; + if (isSticky === value) { + return; + } + + this.isSticky = value; + eLabel.classList.toggle('ag-sticky-label', value); + } +} diff --git a/packages/ag-grid-community/src/headerRendering/cells/columnGroup/headerGroupComp.ts b/packages/ag-grid-community/src/headerRendering/cells/columnGroup/headerGroupComp.ts index d83a4d2dabe..f9f2a804537 100644 --- a/packages/ag-grid-community/src/headerRendering/cells/columnGroup/headerGroupComp.ts +++ b/packages/ag-grid-community/src/headerRendering/cells/columnGroup/headerGroupComp.ts @@ -14,6 +14,7 @@ import type { IconName } from '../../../utils/icon'; import { _createIconNoSpan } from '../../../utils/icon'; import { _warn } from '../../../validation/logging'; import { Component } from '../../../widgets/component'; +import { GroupStickyLabelFeature } from './groupStickyLabelFeature'; export interface IHeaderGroupParams extends AgGridCommon { /** The column group the header is for. */ @@ -237,7 +238,9 @@ export class HeaderGroupComp extends Component implements IHeaderGroupComp { this.agLabel.textContent = _toString(displayName); } - this.toggleCss('ag-sticky-label', !columnGroup.getColGroupDef()?.suppressStickyLabel); + if (!columnGroup.getColGroupDef()?.suppressStickyLabel) { + this.createManagedBean(new GroupStickyLabelFeature(this.getGui(), columnGroup as AgColumnGroup)); + } } public override destroy(): void { From 07b41fcbd7cef15bdd4136c8f3599dafb46955ef Mon Sep 17 00:00:00 2001 From: Tak Tran Date: Mon, 26 Jan 2026 16:11:54 +0000 Subject: [PATCH 3/5] AG-16531 - Add modern slavery policy to website (#12972) * AG-16531 - Add modern slavery policy to website * AG-16531 - Use disc for inner unordered lists on policy pages * AG-16531 - Update signature * AG-16531 - Replace heading with strong and br * AG-16531 - Remove policy wording --- .../src/content/footer/footer.json | 4 + .../src/pages/modern-slavery.astro | 5 ++ .../policies/pages/modern-slavery.astro | 67 ++++++++++++++++ .../policies/policyPage.module.scss | 4 + .../src/content/policies/modern-slavery.mdoc | 76 +++++++++++++++++++ 5 files changed, 156 insertions(+) create mode 100644 documentation/ag-grid-docs/src/pages/modern-slavery.astro create mode 100644 external/ag-website-shared/src/components/policies/pages/modern-slavery.astro create mode 100644 external/ag-website-shared/src/content/policies/modern-slavery.mdoc diff --git a/documentation/ag-grid-docs/src/content/footer/footer.json b/documentation/ag-grid-docs/src/content/footer/footer.json index 1eff099533d..2dde68a2a94 100644 --- a/documentation/ag-grid-docs/src/content/footer/footer.json +++ b/documentation/ag-grid-docs/src/content/footer/footer.json @@ -70,6 +70,10 @@ "name": "Cookies Policy", "url": "/cookies" }, + { + "name": "Modern Slavery", + "url": "/modern-slavery" + }, { "name": "Sitemap", "url": "/sitemap" diff --git a/documentation/ag-grid-docs/src/pages/modern-slavery.astro b/documentation/ag-grid-docs/src/pages/modern-slavery.astro new file mode 100644 index 00000000000..1b24c079266 --- /dev/null +++ b/documentation/ag-grid-docs/src/pages/modern-slavery.astro @@ -0,0 +1,5 @@ +--- +import ModernSlavery from '@ag-website-shared/components/policies/pages/modern-slavery.astro'; +--- + + diff --git a/external/ag-website-shared/src/components/policies/pages/modern-slavery.astro b/external/ag-website-shared/src/components/policies/pages/modern-slavery.astro new file mode 100644 index 00000000000..da48b1c75e0 --- /dev/null +++ b/external/ag-website-shared/src/components/policies/pages/modern-slavery.astro @@ -0,0 +1,67 @@ +--- +import Layout from '@layouts/Layout.astro'; +import { Content as ModernSlaveryPolicies } from '@ag-website-shared/content/policies/modern-slavery.mdoc'; +import styles from '../policyPage.module.scss'; + +interface Props { + name: string; +} + +const { name } = Astro.props; +--- + + +
+
+

AG Grid Modern Slavery and Human Trafficking Statement

+
+ +
+
+

For the Financial Year Ending 31 December 2026

+

Version: 1.0

+

Effective Date: 01 January 2026

+
+ + +
+ +
+ +
+
+
+
diff --git a/external/ag-website-shared/src/components/policies/policyPage.module.scss b/external/ag-website-shared/src/components/policies/policyPage.module.scss index 53eaa174a32..634bb3fd11d 100644 --- a/external/ag-website-shared/src/components/policies/policyPage.module.scss +++ b/external/ag-website-shared/src/components/policies/policyPage.module.scss @@ -59,6 +59,10 @@ font-size: var(--text-fs-xl); } + ul { + list-style-type: disc; + } + @media screen and (min-width: $breakpoint-policy-page-extra-large) { list-style: decimal; padding-left: unset; diff --git a/external/ag-website-shared/src/content/policies/modern-slavery.mdoc b/external/ag-website-shared/src/content/policies/modern-slavery.mdoc new file mode 100644 index 00000000000..072dadf2cea --- /dev/null +++ b/external/ag-website-shared/src/content/policies/modern-slavery.mdoc @@ -0,0 +1,76 @@ +1. ### Introduction {% id="intro-modern-slavery" %} + + *** + + This statement is made pursuant to Section 54 of the Modern Slavery Act 2015. It sets out the steps that AG Grid Ltd has taken and is continuing to take to ensure that modern slavery or human trafficking is not taking place within our business or supply chain. + + AG Grid Ltd has a zero-tolerance approach to modern slavery. We are committed to acting ethically and with integrity in all our business dealings and relationships and to implementing and enforcing effective systems and controls to ensure modern slavery is not taking place anywhere in our own business or in any of our supply chains. + +2. ### Organisation Structure and Supply Chain {% id="organisation-structure" %} + + *** + + AG Grid Ltd constitutes a software development company headquartered in London, United Kingdom. We specialise in providing JavaScript libraries for charts, grids, and dashboards, alongside the maintenance and support of those libraries. We currently employ approximately 60 staff members. + + Our supply chain is relatively simple and primarily supports our office operations and technology infrastructure. Our direct suppliers are predominantly based in the United Kingdom. Given the nature of our business (software development) and our geographical focus, we consider the risk of modern slavery within our direct operations to be low. + + However, we remain vigilant, particularly regarding indirect supply chains (such as hardware procurement and office services). + +3. ### Policies in Relation to Modern Slavery {% id="policies-modern-slavery" %} + + *** + + We operate several internal policies to ensure that we are conducting business in an ethical and transparent manner. These include: + + - **Whistleblowing Policy:** We maintain a policy that encourages all employees to report any concerns related to the direct activities, or the supply chains of, the organisation. This includes any circumstances that may give rise to an enhanced risk of slavery or human trafficking. + + - **Recruitment Policy:** We operate a robust recruitment policy, including conducting eligibility to work in the UK checks for all employees to safeguard against human trafficking or individuals being forced to work against their will. + + - **Anti-Bribery and Corruption Policy:** We maintain strict policies against bribery and corruption, ensuring all business is conducted lawfully. + +4. ### Due Diligence Processes {% id="due-diligence" %} + + *** + + We undertake due diligence when considering taking on new suppliers, and regularly review our existing suppliers. Our current due diligence process includes: + + - Conducting internal research and review processes on prospective suppliers. + - Issuing questionnaires to key suppliers to assess their suitability and their own stance on modern slavery. + - Using only approved and reputable recruitment agencies for hiring staff, ensuring they adhere to UK employment laws. + +5. ### Risk Assessment {% id="risk-assessment" %} + + *** + + We consider the overall risk of modern slavery within our business to be low, primarily because: + + 1. Our business is in the high-skilled technology sector. + 2. Our operations are based in the UK, a jurisdiction with strong employment protections. + 3. Our supply chain consists mainly of professional services and low-volume procurement. + + Despite this low risk, we understand that modern slavery can occur in any sector (particularly in indirect areas such as cleaning, catering, or hardware manufacturing) and we are committed to constant vigilance. + +6. ### Training and Awareness {% id="training-awareness" %} + + *** + + We have not yet implemented specific Modern Slavery training for all staff, given our low-risk profile. However, our HR and management teams are responsible for ensuring strict compliance with Right to Work checks and employment laws. + +7. ### Key Performance Indicators and Future Steps {% id="kpis-future" %} + + *** + + To ensure we continue to improve our approach to combatting modern slavery, we have set the following specific goal for the financial year 2026: + + - **Creation of a Supplier Code of Conduct:** We will draft and implement a formal Supplier Code of Conduct. We will require our key suppliers to acknowledge and adhere to this code, which will explicitly prohibit the use of forced, compulsory, or trafficked labour. + +8. ### Approval {% id="approval-modern-slavery" %} + + *** + + This statement was approved by the Board of Directors on 2026-01-20. + + Signed, + + **John Masterson**{% br /%} + CEO AG Grid Ltd From 883abb20e3ddc986b8a669da68ff3e734db7faa4 Mon Sep 17 00:00:00 2001 From: Salvatore Previti Date: Mon, 26 Jan 2026 17:30:32 +0000 Subject: [PATCH 4/5] AG-16634 backpspace on unedited cell (#12969) * AG-16634-backpspace-on-unedited-cell --- .rulesync/rules/testing.md | 20 ++++- .../src/edit/editModelService.ts | 31 ++++--- .../ag-grid-community/src/edit/editService.ts | 50 ++++++++--- .../src/edit/utils/editors.ts | 90 ++++++++++++------- .../src/interfaces/iEditModelService.ts | 2 +- .../src/interfaces/iEditService.ts | 3 +- .../src/valueService/valueService.ts | 18 +--- .../cell-editing-regression.test.ts | 88 ++++++++++++++++++ 8 files changed, 222 insertions(+), 80 deletions(-) diff --git a/.rulesync/rules/testing.md b/.rulesync/rules/testing.md index 754aa06c789..3b8a7a33af7 100644 --- a/.rulesync/rules/testing.md +++ b/.rulesync/rules/testing.md @@ -35,7 +35,9 @@ packages/ag-grid-community/src/ ## Running Tests -### Unit Tests +### Unit Tests (Jest) + +Unit tests in `packages/` use Jest. Use `--testPathPattern` and `--testNamePattern`: ```bash # Run all tests for a package @@ -55,13 +57,23 @@ yarn nx test ag-grid-community --testPathPattern="featureName" --testNamePattern yarn nx e2e ag-grid-docs ``` -### Behavioural Tests +### Behavioural Tests (Vitest) + +Behavioural tests in `testing/behavioural/` use Vitest via Nx. Use `--run` to execute once (without watch mode): ```bash -# Run behavioural test suite -yarn nx test ag-behavioural-testing +# Run all behavioural tests +yarn nx test ag-behavioural-testing --run + +# Run specific test file +yarn nx test ag-behavioural-testing --run "cell-editing-regression" + +# Run specific test by name +yarn nx test ag-behavioural-testing --run "cell-editing-regression" -t "should handle" ``` +**Note:** Vitest does not support `--testPathPattern` or `--testNamePattern`. Use positional arguments for file matching and `-t` for test name filtering. + ## Test Patterns ### Jest Unit Tests diff --git a/packages/ag-grid-community/src/edit/editModelService.ts b/packages/ag-grid-community/src/edit/editModelService.ts index 8f51e2a8d15..9a178e0dafa 100644 --- a/packages/ag-grid-community/src/edit/editModelService.ts +++ b/packages/ag-grid-community/src/edit/editModelService.ts @@ -112,21 +112,28 @@ export class EditModelService extends BeanStub implements NamedBean, IEditModelS return data; } - public getEdit(position: EditPosition, copy?: false): Readonly | undefined { - const edit = this._getEdit(position); - return copy && edit ? { ...edit } : edit; - } + public getEdit(position: EditPosition, params?: GetEditsParams): EditValue | undefined { + const { rowNode, column } = position; + const edits = this.edits; + if (this.suspendEdits || edits.size === 0 || !rowNode || !column) { + return undefined; // no edits or incomplete position + } - private _getEdit(position: EditPosition): EditValue | undefined { - if (this.suspendEdits) { - return undefined; + // Check the row's edits first + const edit = edits.get(rowNode)?.get(column); + if (edit) { + return edit; // found edit for the cell } - if (this.edits.size === 0) { - return undefined; + // If checkSiblings, also check the pinned sibling for the column + if (params?.checkSiblings) { + const pinnedSibling = (rowNode as RowNode).pinnedSibling; + if (pinnedSibling) { + return edits.get(pinnedSibling)?.get(column); // return edit from pinned sibling if found + } } - return position.rowNode && position.column && this.getEditRow(position.rowNode)?.get(position.column); + return undefined; } public getEditMap(copy = true): EditMap { @@ -168,7 +175,7 @@ export class EditModelService extends BeanStub implements NamedBean, IEditModelS edits.set(position.rowNode, new Map()); } - const currentEdit = this._getEdit(position); + const currentEdit = this.getEdit(position); const updatedEdit = Object.assign({ editorState: { @@ -188,7 +195,7 @@ export class EditModelService extends BeanStub implements NamedBean, IEditModelS const { rowNode, column } = position; if (rowNode) { if (column) { - const edit = this._getEdit(position); + const edit = this.getEdit(position); if (edit) { edit.editorValue = undefined; edit.pendingValue = edit.sourceValue; diff --git a/packages/ag-grid-community/src/edit/editService.ts b/packages/ag-grid-community/src/edit/editService.ts index ebff3b45bf5..db86930ef8c 100644 --- a/packages/ag-grid-community/src/edit/editService.ts +++ b/packages/ag-grid-community/src/edit/editService.ts @@ -93,6 +93,7 @@ const CANCEL_PARAMS: StopEditParams = { cancel: true, source: 'api' }; const COMMIT_PARAMS: StopEditParams = { cancel: false, source: 'api' }; +/** Params to also check the pinnedSibling row when looking up edits (pinned rows share edit state with their unpinned counterpart). */ const CHECK_SIBLING = { checkSiblings: true }; const FORCE_REFRESH = { force: true, suppressFlash: true }; @@ -864,26 +865,47 @@ export class EditService extends BeanStub implements NamedBean, IEditService { return res; } - public getCellDataValue({ rowNode, column }: Required, preferEditor = true): any { - if (!rowNode || !column) { - return undefined; + /** Gets the pending edit value for display (used by ValueService). Returns undefined to fallback to valueGetter. */ + public getCellValueForDisplay(rowNode: IRowNode, column: Column, source: 'ui' | 'api' | string): any { + if (source !== 'ui') { + return undefined; // only show edit values for UI operations } - let edit = this.model.getEdit({ rowNode, column }); + const edit = this.model.getEdit({ rowNode, column }, CHECK_SIBLING); - const pinnedSibling = (rowNode as RowNode).pinnedSibling; - if (pinnedSibling) { - const siblingEdit = this.model.getEdit({ rowNode: pinnedSibling, column }); - if (siblingEdit) { - edit = siblingEdit; - } + // Skip if no edit, or during stopEditing when value was already committed (non-batch, no editor opened) + if (!edit || (this.stopping && !this.batch && !edit.editorState?.cellStartedEditing)) { + return undefined; // no edit or value already committed + } + + const editorValue = edit.editorValue; + if (editorValue != null && editorValue !== UNEDITED) { + return editorValue; // live value from editor component + } + + const pendingValue = edit.pendingValue; + if (pendingValue !== UNEDITED) { + return pendingValue; // synced pending value } - const newValue = preferEditor ? edit?.editorValue ?? edit?.pendingValue : edit?.pendingValue; + return undefined; // fallback to valueGetter + } + + public getCellDataValue(position: Required): any { + const edit = this.model.getEdit(position, CHECK_SIBLING); + if (edit) { + const newValue = edit.pendingValue; + if (newValue !== UNEDITED) { + return newValue; // return edit value if exists + } + const sourceValue = edit.sourceValue; + if (sourceValue != null) { + return sourceValue; // return source value if no edit value + } + } - return newValue === UNEDITED || !edit - ? edit?.sourceValue ?? this.valueSvc.getValue(column as AgColumn, rowNode, false, 'api') - : newValue; + // fallback to getting value from ValueService + return this.valueSvc.getValue(position.column as AgColumn, position.rowNode, false, 'api'); } public addStopEditingWhenGridLosesFocus(viewports: HTMLElement[]): void { diff --git a/packages/ag-grid-community/src/edit/utils/editors.ts b/packages/ag-grid-community/src/edit/utils/editors.ts index 5ad94a97ddc..f7626805cb7 100644 --- a/packages/ag-grid-community/src/edit/utils/editors.ts +++ b/packages/ag-grid-community/src/edit/utils/editors.ts @@ -71,7 +71,7 @@ export function _setupEditors( const newValue = cellStartValue ?? - editSvc?.getCellDataValue(cellPosition, false) ?? + editSvc?.getCellDataValue(cellPosition) ?? valueSvc.getValueForDisplay({ column: cellColumn as AgColumn, node: cellRowNode })?.value ?? oldValue ?? UNEDITED; @@ -171,7 +171,7 @@ export function _setupEditor( comp?.setEditDetails(compDetails, popup, popupLocation, gos.get('reactiveCustomComponents')); rowCtrl?.refreshRow({ suppressFlash: true }); - const edit = editModelSvc?.getEdit(position, true); + const edit = editModelSvc?.getEdit(position); if (!silent && !edit?.editorState?.cellStartedEditing) { editSvc?.dispatchCellEvent(position, event, 'cellEditingStarted', { value: newValue }); @@ -230,7 +230,7 @@ function _createEditorParams( const editor = cellCtrl.comp?.getCellEditor(); - const cellDataValue = editSvc?.getCellDataValue(position, false); + const cellDataValue = editSvc?.getCellDataValue(position); const initialNewValue = cellDataValue === undefined ? editor @@ -364,7 +364,7 @@ export function _syncFromEditor( return; } - let edit = editModelSvc.getEdit(position, true); + let edit = editModelSvc.getEdit(position); if (!edit?.sourceValue) { // sourceValue not set means sync called without corresponding startEdit - from API call @@ -405,7 +405,7 @@ function getNormalisedFormula(beans: BeanCollection, value: any, forEditing: boo function _persistEditorValue(beans: BeanCollection, position: Required): void { const { editModelSvc } = beans; - const edit = editModelSvc?.getEdit(position, true); + const edit = editModelSvc?.getEdit(position); // propagate the editor value to pending. editModelSvc?.setEdit(position, { @@ -435,10 +435,9 @@ export function _destroyEditor( params?: DestroyEditorParams, cellCtrl = _getCellCtrl(beans, position) ): void { - const enableGroupEditing = beans.gos.get('enableGroupEdit'); const editModelSvc = beans.editModelSvc; - const edit = editModelSvc?.getEdit(position, true); + const edit = editModelSvc?.getEdit(position); if (!cellCtrl) { if (edit) { @@ -457,8 +456,8 @@ export function _destroyEditor( if (edit) { editModelSvc?.setEdit(position, { state: 'changed' }); - const args = enableGroupEditing - ? groupEditOverrides(params, edit) + const args = beans.gos.get('enableGroupEdit') + ? _enabledGroupEditStoppedArgs(edit, params?.cancel) : { valueChanged: false, newValue: undefined, @@ -493,37 +492,60 @@ export function _destroyEditor( const latest = editModelSvc?.getEdit(position); if (latest && latest.state === 'changed') { - const args = enableGroupEditing - ? groupEditOverrides(params, latest) - : { - valueChanged: _sourceAndPendingDiffer(latest) && !params?.cancel, - newValue: - params?.cancel || latest.editorState.isCancelAfterEnd - ? undefined - : latest?.editorValue ?? edit?.pendingValue, - oldValue: latest?.sourceValue, - }; - + const cancel = params?.cancel; + const args = beans.gos.get('enableGroupEdit') + ? _enabledGroupEditStoppedArgs(latest, cancel) + : _cellEditStoppedArgs(latest, edit, cancel); dispatchEditingStopped(beans, position, args, params); } } type EditingStoppedArgs = Partial>; -function groupEditOverrides(params: DestroyEditorParams | undefined, latest: Readonly): EditingStoppedArgs { - return params?.cancel - ? { - valueChanged: false, - oldValue: latest.sourceValue, - newValue: undefined, - value: latest.sourceValue, - } - : { - valueChanged: _sourceAndPendingDiffer(latest), - oldValue: latest.sourceValue, - newValue: latest.pendingValue, - value: latest.sourceValue, - }; +/** Group editing event args (AG-15792): uses sourceValue for oldValue/value, does not check isCancelAfterEnd. */ +function _enabledGroupEditStoppedArgs(latest: Readonly, cancel: boolean | undefined): EditingStoppedArgs { + const { sourceValue, pendingValue } = latest; + + let newValue: any; + if (!cancel && pendingValue !== UNEDITED) { + newValue = pendingValue; + } + + return { + valueChanged: !cancel && _sourceAndPendingDiffer(latest), + newValue, + oldValue: sourceValue, + value: sourceValue, + }; +} + +/** Standard cell editing event args: newValue from editorValue (fallback to pendingValue), value is newValue. */ +function _cellEditStoppedArgs( + latest: Readonly, + edit: Readonly | undefined, + cancel: boolean | undefined +): EditingStoppedArgs { + if (cancel || latest.editorState.isCancelAfterEnd) { + return { + valueChanged: false, + newValue: undefined, + oldValue: latest.sourceValue, + }; + } + + let newValue: any = latest.editorValue; + if (newValue == null || newValue === UNEDITED) { + newValue = edit?.pendingValue; + } + if (newValue === UNEDITED) { + newValue = undefined; + } + + return { + valueChanged: _sourceAndPendingDiffer(latest), + newValue, + oldValue: latest.sourceValue, + }; } function dispatchEditingStopped( diff --git a/packages/ag-grid-community/src/interfaces/iEditModelService.ts b/packages/ag-grid-community/src/interfaces/iEditModelService.ts index 8f4c3c75b3b..669fa119ece 100644 --- a/packages/ag-grid-community/src/interfaces/iEditModelService.ts +++ b/packages/ag-grid-community/src/interfaces/iEditModelService.ts @@ -40,7 +40,7 @@ export interface IEditModelService { suspend(suspend: boolean): void; removeEdits({ rowNode, column }: EditPosition): void; - getEdit(position: EditPosition, copy?: boolean): Readonly | undefined; + getEdit(position: EditPosition, params?: GetEditsParams): EditValue | undefined; getEditPositions(editMap?: EditMap): EditPositionValue[]; getEditRow(rowNode: IRowNode, params?: GetEditsParams): EditRow | undefined; getEditRowDataValue(rowNode: IRowNode, params?: GetEditsParams): any; diff --git a/packages/ag-grid-community/src/interfaces/iEditService.ts b/packages/ag-grid-community/src/interfaces/iEditService.ts index 934730ee187..a42f313a101 100644 --- a/packages/ag-grid-community/src/interfaces/iEditService.ts +++ b/packages/ag-grid-community/src/interfaces/iEditService.ts @@ -101,7 +101,8 @@ export interface IEditService extends NamedBean { event?: KeyboardEvent, source?: EditSource ): boolean | null; - getCellDataValue(position: Required, preferEditor: boolean): any; + getCellValueForDisplay(rowNode: IRowNode, column: Column, source: 'ui' | 'api' | string): any; + getCellDataValue(position: Required): any; addStopEditingWhenGridLosesFocus(viewports: HTMLElement[]): void; createPopupEditorWrapper(params: ICellEditorParams): PopupEditorWrapper; setDataValue(position: Required, newValue: any, eventSource?: string): boolean | undefined; diff --git a/packages/ag-grid-community/src/valueService/valueService.ts b/packages/ag-grid-community/src/valueService/valueService.ts index b7bdeaebbc8..09f39fd6be5 100644 --- a/packages/ag-grid-community/src/valueService/valueService.ts +++ b/packages/ag-grid-community/src/valueService/valueService.ts @@ -33,7 +33,6 @@ export class ValueService extends BeanStub implements NamedBean { private valueCache?: ValueCache; private dataTypeSvc?: DataTypeService; private editSvc?: IEditService; - private hasEditSvc: boolean = false; private formulaDataSvc?: IFormulaDataService; public wireBeans(beans: BeanCollection): void { @@ -42,7 +41,6 @@ export class ValueService extends BeanStub implements NamedBean { this.valueCache = beans.valueCache; this.dataTypeSvc = beans.dataTypeSvc; this.editSvc = beans.editSvc; - this.hasEditSvc = !!beans.editSvc; this.formulaDataSvc = beans.formulaDataSvc; } @@ -191,18 +189,10 @@ export class ValueService extends BeanStub implements NamedBean { return; } - // pull these out to make code below easier to read - - if (this.hasEditSvc && source === 'ui') { - const editSvc = this.editSvc!; - - // if the row is editing, we want to return the new value, if available - if (editSvc.isEditing()) { - const newValue = editSvc.getCellDataValue({ rowNode, column }, true); - if (newValue !== undefined) { - return newValue; - } - } + // If editing, return the pending value if available + const pending = this.editSvc?.getCellValueForDisplay(rowNode, column, source); + if (pending !== undefined) { + return pending; } const colDef = column.getColDef(); diff --git a/testing/behavioural/src/cell-editing/cell-editing-regression.test.ts b/testing/behavioural/src/cell-editing/cell-editing-regression.test.ts index 19b072bf51d..92c3ae9a0cc 100644 --- a/testing/behavioural/src/cell-editing/cell-editing-regression.test.ts +++ b/testing/behavioural/src/cell-editing/cell-editing-regression.test.ts @@ -749,4 +749,92 @@ describe('Cell Editing Regression', () => { }); }); }); + + test('Delete key on cell with valueGetter passes correct oldValue to valueSetter', async () => { + const valueSetterCalls: Array<{ oldValue: any; newValue: any }> = []; + + const api = await gridMgr.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'name' }, + { + headerName: 'Total Medals', + colId: 'totalMedals', + editable: true, + valueGetter: (params) => params.data.medals, + valueSetter: (params) => { + valueSetterCalls.push({ + oldValue: structuredClone(params.oldValue), + newValue: params.newValue, + }); + if (params.newValue == null) { + params.data.medals = { gold: 0, silver: 0, bronze: 0 }; + return true; + } + return false; + }, + valueFormatter: ({ value }) => value.gold + value.silver + value.bronze, + }, + { + field: 'score', + editable: true, + valueGetter: (params) => params.data.scoreData.value, + valueSetter: (params) => { + valueSetterCalls.push({ + oldValue: params.oldValue, + newValue: params.newValue, + }); + params.data.scoreData.value = params.newValue ?? 0; + return true; + }, + }, + ], + defaultColDef: { + flex: 1, + editable: true, + cellDataType: false, + }, + rowData: [ + { + name: 'Michael Phelps', + medals: { gold: 8, silver: 2, bronze: 0 }, + scoreData: { value: 42 }, + }, + ], + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + await asyncSetTimeout(1); + + // Test object valueGetter + const medalsCell = getByTestId(gridDiv, agTestIdFor.cell('0', 'totalMedals')); + expect(medalsCell).toHaveTextContent('10'); + + await userEvent.click(medalsCell); + await asyncSetTimeout(1); + expect(api.getEditingCells()).toHaveLength(0); + + await userEvent.keyboard('{Delete}'); + await asyncSetTimeout(1); + + expect(valueSetterCalls).toHaveLength(1); + expect(valueSetterCalls[0].newValue).toBeNull(); + expect(valueSetterCalls[0].oldValue).toEqual({ gold: 8, silver: 2, bronze: 0 }); + expect(medalsCell).toHaveTextContent('0'); + + // Test primitive valueGetter + const scoreCell = getByTestId(gridDiv, agTestIdFor.cell('0', 'score')); + expect(scoreCell).toHaveTextContent('42'); + + await userEvent.click(scoreCell); + await asyncSetTimeout(1); + expect(api.getEditingCells()).toHaveLength(0); + + await userEvent.keyboard('{Delete}'); + await asyncSetTimeout(1); + + expect(valueSetterCalls).toHaveLength(2); + expect(valueSetterCalls[1].newValue).toBeNull(); + expect(valueSetterCalls[1].oldValue).toBe(42); + expect(scoreCell).toHaveTextContent('0'); + }); }); From b15ef3e44a2c46dc1c30687cb8736bb75748ab4a Mon Sep 17 00:00:00 2001 From: Guilherme Lopes Date: Mon, 26 Jan 2026 14:48:12 -0300 Subject: [PATCH 5/5] AG-16636 - [excel-export] - Excel Metadata (#12974) * AG-16636 - [excel-export] - metadata support * improved docs * docs cleanup --- .../main.ts | 8 +++---- .../index.mdoc | 22 +++++++++++-------- .../excel-export-api/excel-api.json | 2 +- .../src/interfaces/iExcelCreator.ts | 2 +- .../src/excelExport/excelCreator.ts | 14 ++++++------ 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/main.ts b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/main.ts index 813d7a70598..90851c6ec1a 100644 --- a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/main.ts +++ b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/_examples/excel-export-customising-custom-metadata/main.ts @@ -31,10 +31,10 @@ const rowData: ReportRow[] = [ { department: 'Legal', reportId: 'RPT-109', owner: 'Taylor', cost: 2700 }, ]; -const excelCustomMetadata = { - ExportID: '12345', - GeneratedBy: 'AgGrid', +const customMetadata = { + ExportID: 'EXP-2026-001', ExpirationDate: '2025-01-01T12:00:00Z', + Disclaimer: 'Preliminary data; subject to audit', }; let gridApi: GridApi; @@ -53,7 +53,7 @@ const gridOptions: GridOptions = { }, rowData, defaultExcelExportParams: { - excelCustomMetadata: excelCustomMetadata, + customMetadata: customMetadata, }, }; diff --git a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/index.mdoc b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/index.mdoc index 352cf13386b..30304d148d6 100644 --- a/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/excel-export-customising-content/index.mdoc @@ -60,26 +60,30 @@ The following example shows Excel customisations where the exported document has ## Custom Metadata -Use `excelCustomMetadata` to write custom document properties to the exported file. The values are added as metadata to the Excel file and serialised as strings. +Use `customMetadata` to write custom document properties to the exported file. The values are added as metadata to the Excel file and serialised as strings. This is useful for attaching internal identifiers, workflow hints, or metadata consumed by downstream systems. +Use cases for custom metadata may include: + +- Internal workflow tagging (for example, adding `ExportID` or `GeneratedBy` for tracking in automation scripts). +- Integration with document management systems (for example, embedding `ContractType` or `ExpirationDate` for indexing in SharePoint or similar tools). +- Integration with third-party analytics tools (for example, passing `CampaignID` for BI dashboard automation). + ```{% frameworkTransform=true %} gridApi.exportDataAsExcel({ - excelCustomMetadata: { - 'ExportID': '12345', - 'GeneratedBy': 'AgGrid', - 'ExpirationDate': '2025-01-01T12:00:00Z', + customMetadata: { + ExportID: 'EXP-2026-001', + ExpirationDate: '2025-01-01T12:00:00Z', + Disclaimer: 'Preliminary data; subject to audit', }, }); ``` -{% interfaceDocumentation interfaceName="ExcelExportParams" names=["excelCustomMetadata"] /%} - -The following example adds custom properties during export. Some labelling workflows read custom properties (for example, Microsoft Purview uses `MSIP_Label_*` keys). +{% interfaceDocumentation interfaceName="ExcelExportParams" names=["customMetadata"] /%} {% note %} -The Grid does not interpret these values or apply labels; it only writes the custom properties that are provide in the `excelCustomMetadata` parameter. +The Grid does not interpret these values or apply labels; it only writes the custom properties provided in the `customMetadata` parameter. This feature does not replace or integrate with officially endorsed labelling systems, such as Microsoft Purview Sensitivity Labels, which require specific SDKs or APIs for enforcement, encryption, and compliance. {% /note %} {% gridExampleRunner title="Excel Export - Custom Metadata" name="excel-export-customising-custom-metadata" /%} diff --git a/documentation/ag-grid-docs/src/content/interface-documentation/excel-export-api/excel-api.json b/documentation/ag-grid-docs/src/content/interface-documentation/excel-export-api/excel-api.json index 39df7932620..66c21869176 100644 --- a/documentation/ag-grid-docs/src/content/interface-documentation/excel-export-api/excel-api.json +++ b/documentation/ag-grid-docs/src/content/interface-documentation/excel-export-api/excel-api.json @@ -7,7 +7,7 @@ "prependContent": { "description": "Content to put at the bottom of the exported sheet. An array of ExcelRow objects, see [Extra Content section](./excel-export-extra-content/)." }, - "excelCustomMetadata": { + "customMetadata": { "description": "Custom metadata to be written to in the exported file. Values are serialised as strings." }, "fileName": { diff --git a/packages/ag-grid-community/src/interfaces/iExcelCreator.ts b/packages/ag-grid-community/src/interfaces/iExcelCreator.ts index 7e4413dc7d5..826c7581f53 100644 --- a/packages/ag-grid-community/src/interfaces/iExcelCreator.ts +++ b/packages/ag-grid-community/src/interfaces/iExcelCreator.ts @@ -604,7 +604,7 @@ interface ExcelFileParams { * Custom metadata to write to `docProps/custom.xml` in the exported file. * Values are serialised as strings. */ - excelCustomMetadata?: ExcelCustomMetadata; + customMetadata?: ExcelCustomMetadata; } export interface ExcelExportParams extends ExcelFileParams, ExcelWorksheetConfigParams, ExportParams {} diff --git a/packages/ag-grid-enterprise/src/excelExport/excelCreator.ts b/packages/ag-grid-enterprise/src/excelExport/excelCreator.ts index f28ddbf6c59..de880fabdcf 100644 --- a/packages/ag-grid-enterprise/src/excelExport/excelCreator.ts +++ b/packages/ag-grid-enterprise/src/excelExport/excelCreator.ts @@ -214,7 +214,7 @@ const getMultipleSheetsAsExcelCompressed = ( params: ExcelExportMultipleSheetParams, workbook: Workbook = new Workbook() ): Promise => { - const { data, fontSize, author, activeSheetIndex, excelCustomMetadata } = params; + const { data, fontSize, author, activeSheetIndex, customMetadata } = params; const mimeType = params.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const zipContainer = new ZipContainer(); @@ -226,7 +226,7 @@ const getMultipleSheetsAsExcelCompressed = ( author, fontSize, activeTab: activeSheetIndex, - customMetadata: excelCustomMetadata, + customMetadata, }, workbook ) @@ -241,7 +241,7 @@ export const getMultipleSheetsAsExcel = ( params: ExcelExportMultipleSheetParams, workbook: Workbook = new Workbook() ): Blob | undefined => { - const { data, fontSize, author, activeSheetIndex: activeTab, excelCustomMetadata: customMetadata } = params; + const { data, fontSize, author, activeSheetIndex: activeTab, customMetadata } = params; const mimeType = params.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const zipContainer = new ZipContainer(); @@ -299,14 +299,14 @@ export class ExcelCreator const mergedParams = this.getMergedParams(userParams); const data = this.getData(mergedParams); - const { fontSize, author, mimeType, excelCustomMetadata } = mergedParams; + const { fontSize, author, mimeType, customMetadata } = mergedParams; const exportParams: ExcelExportMultipleSheetParams = { data: [data], fontSize, author, mimeType, - excelCustomMetadata, + customMetadata, }; this.packageCompressedFile(exportParams).then((packageFile) => { @@ -335,14 +335,14 @@ export class ExcelCreator const mergedParams = this.getMergedParams(params); const data = this.getData(mergedParams); - const { fontSize, author, mimeType, excelCustomMetadata } = mergedParams; + const { fontSize, author, mimeType, customMetadata } = mergedParams; const exportParams: ExcelExportMultipleSheetParams = { data: [data], fontSize, author, mimeType, - excelCustomMetadata, + customMetadata, }; return this.packageFile(exportParams);