From ae29ef9bf57e32626dfb8527799a5234a20e4d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Fri, 10 Jul 2026 04:38:43 -0700 Subject: [PATCH] feat(rest): colour select/radio cells in xlsx exports (#2757) Carry a select/radio option's `color` into the xlsx export as the cell font colour (white background), gated behind a 10k-row style cap that degrades to a colourless export (X-Export-Styles: dropped) above the cap. csv/json unchanged. --- .changeset/xlsx-export-select-colours.md | 30 +++++++++ packages/rest/src/export-format.test.ts | 65 ++++++++++++++++++++ packages/rest/src/export-format.ts | 32 +++++++++- packages/rest/src/export-integration.test.ts | 40 +++++++++++- packages/rest/src/rest-server.ts | 34 ++++++++-- 5 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 .changeset/xlsx-export-select-colours.md create mode 100644 packages/rest/src/export-format.test.ts diff --git a/.changeset/xlsx-export-select-colours.md b/.changeset/xlsx-export-select-colours.md new file mode 100644 index 0000000000..7283f167e9 --- /dev/null +++ b/.changeset/xlsx-export-select-colours.md @@ -0,0 +1,30 @@ +--- +"@objectstack/rest": minor +--- + +feat(rest): colour select/radio cells in xlsx exports with their option colour + +The data export route (`GET /data/:object/export`) now carries a select / +radio field's option `color` into the generated Excel workbook as the cell's +**font colour** (white cell background), so an exported sheet reads like the +in-app coloured badges instead of plain black text. csv / json output is +unchanged. + +- `export-format.ts` gains `toArgb()` (hex `#RGB` / `#RRGGBB` → exceljs ARGB + `FFRRGGBB`, `undefined` for anything not plain hex) and `cellFontColor()` + (resolves the matched select/radio option's colour for one cell; returns + `undefined` — i.e. leave it unstyled — for non-option fields, unmatched + values, colourless options, or invalid hex). `ExportFieldMeta.options` now + carries the option `color`. +- `createXlsxStream(res, useStyles)` takes the flag through to exceljs' + `WorkbookWriter`; the route enables styling and sets `cell.font.color` + per-cell only for xlsx. + +Styling is heavier than a bare value dump, so it is gated behind a **10 000-row +cap** (`STYLE_ROW_CAP`): exports whose effective limit exceeds it stream +without colours (all rows intact) and set `X-Export-Styles: dropped`; coloured +exports set `X-Export-Styles: applied`. This mirrors the "formatted export has a +lower ceiling than a raw dump" pattern used by Salesforce / ServiceNow. The +existing 50 000-row hard cap is unchanged. + +Closes #2757. diff --git a/packages/rest/src/export-format.test.ts b/packages/rest/src/export-format.test.ts new file mode 100644 index 0000000000..0e3e7ad3d8 --- /dev/null +++ b/packages/rest/src/export-format.test.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Unit tests for the xlsx colour helpers on the export path: {@link toArgb} + * (hex → exceljs ARGB) and {@link cellFontColor} (select/radio option colour + * for one cell). Both are pure and return `undefined` whenever a cell should + * stay unstyled, so the export never emits an invalid workbook. + */ + +import { describe, it, expect } from 'vitest'; +import { toArgb, cellFontColor, type ExportFieldMeta } from './export-format'; + +describe('toArgb', () => { + it('expands 3-digit hex to opaque ARGB', () => { + expect(toArgb('#3ab')).toBe('FF33AABB'); + expect(toArgb('abc')).toBe('FFAABBCC'); // leading # optional + }); + + it('prefixes 6-digit hex with the opaque alpha, upper-cased', () => { + expect(toArgb('#e11d48')).toBe('FFE11D48'); + expect(toArgb('E11D48')).toBe('FFE11D48'); + }); + + it('returns undefined for anything that is not plain hex', () => { + for (const bad of ['', ' ', '#12', '#12345', '#1234567', 'red', 'rgb(1,2,3)', '#gggggg', null, undefined, 42, {}]) { + expect(toArgb(bad as unknown)).toBeUndefined(); + } + }); +}); + +describe('cellFontColor', () => { + const priority: ExportFieldMeta = { + name: 'priority', type: 'select', label: '优先级', + options: [{ label: '高', value: 'high', color: '#e11d48' }, { label: '低', value: 'low', color: '#3ab' }], + }; + + it('resolves the matched select option colour to ARGB', () => { + expect(cellFontColor('high', priority)).toBe('FFE11D48'); + expect(cellFontColor('low', priority)).toBe('FF33AABB'); + }); + + it('works for radio the same as select', () => { + const radio: ExportFieldMeta = { ...priority, type: 'radio' }; + expect(cellFontColor('high', radio)).toBe('FFE11D48'); + }); + + it('returns undefined when the cell should stay unstyled', () => { + // No/blank value. + expect(cellFontColor(null, priority)).toBeUndefined(); + expect(cellFontColor(undefined, priority)).toBeUndefined(); + // Value has no matching option. + expect(cellFontColor('urgent', priority)).toBeUndefined(); + // Matched option carries no colour. + const noColor: ExportFieldMeta = { name: 'p', type: 'select', options: [{ label: 'X', value: 'x' }] }; + expect(cellFontColor('x', noColor)).toBeUndefined(); + // Non-option field type is never coloured, even with a hex-looking value. + const text: ExportFieldMeta = { name: 't', type: 'text' }; + expect(cellFontColor('#e11d48', text)).toBeUndefined(); + // Missing metadata entirely. + expect(cellFontColor('high', undefined)).toBeUndefined(); + // Multiselect is out of scope (ambiguous single font colour for many values). + const multi: ExportFieldMeta = { ...priority, type: 'multiselect' }; + expect(cellFontColor('high', multi)).toBeUndefined(); + }); +}); diff --git a/packages/rest/src/export-format.ts b/packages/rest/src/export-format.ts index c367fa4f80..2386670477 100644 --- a/packages/rest/src/export-format.ts +++ b/packages/rest/src/export-format.ts @@ -17,7 +17,7 @@ export interface ExportFieldMeta { name: string; type?: string; label?: string; - options?: Array<{ label?: string; value?: unknown }>; + options?: Array<{ label?: string; value?: unknown; color?: string }>; /** Target object for lookup / master_detail / user fields. */ reference?: string; /** Field on the referenced record to show as its label. */ @@ -128,6 +128,36 @@ function optionLabel(value: unknown, options?: Array<{ label?: string; value?: u return hit?.label ?? value; } +/** + * Normalize a CSS-ish hex color to exceljs' 8-digit ARGB (`FFRRGGBB`, opaque). + * Accepts `#RGB` / `#RRGGBB` with or without the leading `#`, any case. + * Returns `undefined` for anything else (empty, named colors, rgb(), garbage) + * so callers simply skip styling rather than emit an invalid workbook. + */ +export function toArgb(color: unknown): string | undefined { + if (typeof color !== 'string') return undefined; + const hex = color.trim().replace(/^#/, ''); + if (/^[0-9a-fA-F]{3}$/.test(hex)) { + const [r, g, b] = hex; + return `FF${r}${r}${g}${g}${b}${b}`.toUpperCase(); + } + if (/^[0-9a-fA-F]{6}$/.test(hex)) return `FF${hex}`.toUpperCase(); + return undefined; +} + +/** + * Font color (exceljs ARGB) for one cell, driven by the matched select/radio + * option's `color`. Returns `undefined` when the field is not option-typed, no + * option matches, the option has no color, or the color is not a valid hex — + * i.e. whenever the cell should stay unstyled. + */ +export function cellFontColor(value: unknown, meta?: ExportFieldMeta): string | undefined { + if (value === null || value === undefined) return undefined; + if (!meta || !meta.type || !OPTION_TYPES.has(meta.type) || !meta.options) return undefined; + const hit = meta.options.find((o) => o && o.value === value); + return toArgb(hit?.color); +} + function displayFromRecord(rec: Record, displayField?: string): string { if (displayField && rec[displayField] != null) return String(rec[displayField]); for (const k of NAME_KEY_FALLBACKS) { diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index e315376458..72e08b36a1 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -132,7 +132,8 @@ const TASK = { done: { name: 'done', type: 'boolean' as const, label: '完成' }, priority: { name: 'priority', type: 'select' as const, label: '优先级', - options: [{ label: '高', value: 'high' }, { label: '低', value: 'low' }], + // `color` drives the xlsx font colour; '#3ab' exercises the 3-digit path. + options: [{ label: '高', value: 'high', color: '#e11d48' }, { label: '低', value: 'low', color: '#3ab' }], }, due: { name: 'due', type: 'date' as const, label: '截止' }, owner: { name: 'owner', type: 'lookup' as const, label: '负责人', reference: 'user', displayField: 'name' }, @@ -255,6 +256,43 @@ describe('export route — real engine + protocol integration', () => { expect(r1).toEqual(['1', '写代码', '是', '高', '2026-06-30', '张三']); }); + it('XLSX: select cells get the option colour as font colour; header signals applied', async () => { + const { res, getBuffer, headers } = makeBinRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'xlsx' } } as any, res); + + // Default limit (10000) is within the style cap, so colours are applied. + expect(headers['X-Export-Styles']).toBe('applied'); + + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(getBuffer() as any); + const ws = wb.worksheets[0]; + // priority is column 4 (ID, 标题, 完成, 优先级, ...). + const highCell = ws.getRow(2).getCell(4); // '高' → #e11d48 + const lowCell = ws.getRow(3).getCell(4); // '低' → #3ab (shorthand) + expect((highCell.font?.color as any)?.argb).toBe('FFE11D48'); + expect((lowCell.font?.color as any)?.argb).toBe('FF33AABB'); + // A non-option cell (title) stays unstyled. + expect(ws.getRow(2).getCell(2).font?.color).toBeUndefined(); + }); + + it('XLSX: exceeding the style cap drops styling but keeps all rows', async () => { + const { res, getBuffer, headers } = makeBinRes(); + await route.handler( + { params: { object: 'task' }, query: { format: 'xlsx', limit: '20000' } } as any, + res, + ); + + expect(headers['X-Export-Styles']).toBe('dropped'); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(getBuffer() as any); + const ws = wb.worksheets[0]; + // Data is intact... + const r1 = (ws.getRow(2).values as any[]).slice(1).map((v) => String(v)); + expect(r1).toEqual(['1', '写代码', '是', '高', '2026-06-30', '张三']); + // ...but the select cell carries no font colour. + expect(ws.getRow(2).getCell(4).font?.color).toBeUndefined(); + }); + it('JSON: readable values, all rows present', async () => { const { res, chunks, headers } = makeRes(); await route.handler({ params: { object: 'task' }, query: { format: 'json' } } as any, res); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e5f21819c7..6c0f01e5d1 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -11,6 +11,7 @@ import { headerLabel, formatRowCells, formatRowForJson, + cellFontColor, type ExportFieldMeta, } from './export-format.js'; import { runImport } from './import-runner.js'; @@ -783,7 +784,7 @@ function rowsToCsv( * ended. Dynamically imported so `node:stream` / `exceljs` stay out of the * module's static graph. */ -async function createXlsxStream(res: any): Promise<{ +async function createXlsxStream(res: any, useStyles = false): Promise<{ ws: any; finalize: () => Promise; }> { @@ -797,7 +798,7 @@ async function createXlsxStream(res: any): Promise<{ passthrough.on('error', reject); }); - const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles: false }); + const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles }); const ws = wb.addWorksheet('Export'); return { @@ -3949,6 +3950,11 @@ export class RestServer { // Streams the response so 50k-row exports do not buffer in memory; the // xlsx path pipes exceljs' streaming writer straight onto the response. // Filename suggests `${object}-${YYYY-MM-DD}.${ext}` for browsers. + // + // xlsx only: select / radio cells are coloured with their option's + // `color` as the font colour (white cell background) when the effective + // limit is <= 10000. Larger exports drop styling for performance and set + // `X-Export-Styles: dropped` (else `applied`); csv / json are unaffected. this.routeManager.register({ method: 'GET', path: `${dataPath}/:object/export`, @@ -3972,9 +3978,17 @@ export class RestServer { const includeHeader = String(q.header ?? 'true').toLowerCase() !== 'false'; const HARD_CAP = 50_000; const MAX_CHUNK = 5_000; + // Styled xlsx (per-cell font colour from select options) is far + // heavier than a bare value dump, so cap it well below HARD_CAP; + // above this the export still succeeds, just without colours. + const STYLE_ROW_CAP = 10_000; const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 10_000; const limit = Math.min(requestedLimit, HARD_CAP); const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500)); + // Colour cells only for xlsx within the style cap; decided up + // front (before streaming) since we can't know the true row + // count until the stream drains. + const styled = format === 'xlsx' && limit <= STYLE_ROW_CAP; let filter: any = undefined; if (typeof q.filter === 'string' && q.filter.length > 0) { @@ -4062,13 +4076,17 @@ export class RestServer { } res.header('X-Export-Format', format); res.header('X-Export-Limit', String(limit)); + // Signal whether select-option colours were applied. Only + // meaningful for xlsx; 'dropped' means the limit exceeded the + // style cap so the workbook is colourless but complete. + if (format === 'xlsx') res.header('X-Export-Styles', styled ? 'applied' : 'dropped'); res.header('Cache-Control', 'no-store'); let exported = 0; let firstChunk = true; let skip = 0; if (format === 'json') res.write('['); - const xlsx = format === 'xlsx' ? await createXlsxStream(res) : null; + const xlsx = format === 'xlsx' ? await createXlsxStream(res, styled) : null; while (exported < limit) { const take = Math.min(chunkSize, limit - exported); @@ -4107,8 +4125,16 @@ export class RestServer { if (firstChunk && includeHeader) { xlsx!.ws.addRow((fields ?? []).map((f) => headerLabel(f, metaMap))).commit(); } + const cols = fields ?? []; for (const row of rows) { - xlsx!.ws.addRow(formatRowCells(row, fields ?? [], metaMap)).commit(); + const r = xlsx!.ws.addRow(formatRowCells(row, cols, metaMap)); + if (styled) { + cols.forEach((f, i) => { + const argb = cellFontColor(row?.[f], metaMap.get(f)); + if (argb) r.getCell(i + 1).font = { color: { argb } }; + }); + } + r.commit(); } } else { for (let i = 0; i < rows.length; i++) {