Skip to content

Commit bea4b92

Browse files
authored
feat(rest): colour select/radio cells in xlsx exports (#2757) (#2761)
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.
1 parent 02f6af4 commit bea4b92

5 files changed

Lines changed: 195 additions & 6 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
feat(rest): colour select/radio cells in xlsx exports with their option colour
6+
7+
The data export route (`GET /data/:object/export`) now carries a select /
8+
radio field's option `color` into the generated Excel workbook as the cell's
9+
**font colour** (white cell background), so an exported sheet reads like the
10+
in-app coloured badges instead of plain black text. csv / json output is
11+
unchanged.
12+
13+
- `export-format.ts` gains `toArgb()` (hex `#RGB` / `#RRGGBB` → exceljs ARGB
14+
`FFRRGGBB`, `undefined` for anything not plain hex) and `cellFontColor()`
15+
(resolves the matched select/radio option's colour for one cell; returns
16+
`undefined` — i.e. leave it unstyled — for non-option fields, unmatched
17+
values, colourless options, or invalid hex). `ExportFieldMeta.options` now
18+
carries the option `color`.
19+
- `createXlsxStream(res, useStyles)` takes the flag through to exceljs'
20+
`WorkbookWriter`; the route enables styling and sets `cell.font.color`
21+
per-cell only for xlsx.
22+
23+
Styling is heavier than a bare value dump, so it is gated behind a **10 000-row
24+
cap** (`STYLE_ROW_CAP`): exports whose effective limit exceeds it stream
25+
without colours (all rows intact) and set `X-Export-Styles: dropped`; coloured
26+
exports set `X-Export-Styles: applied`. This mirrors the "formatted export has a
27+
lower ceiling than a raw dump" pattern used by Salesforce / ServiceNow. The
28+
existing 50 000-row hard cap is unchanged.
29+
30+
Closes #2757.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Unit tests for the xlsx colour helpers on the export path: {@link toArgb}
5+
* (hex → exceljs ARGB) and {@link cellFontColor} (select/radio option colour
6+
* for one cell). Both are pure and return `undefined` whenever a cell should
7+
* stay unstyled, so the export never emits an invalid workbook.
8+
*/
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { toArgb, cellFontColor, type ExportFieldMeta } from './export-format';
12+
13+
describe('toArgb', () => {
14+
it('expands 3-digit hex to opaque ARGB', () => {
15+
expect(toArgb('#3ab')).toBe('FF33AABB');
16+
expect(toArgb('abc')).toBe('FFAABBCC'); // leading # optional
17+
});
18+
19+
it('prefixes 6-digit hex with the opaque alpha, upper-cased', () => {
20+
expect(toArgb('#e11d48')).toBe('FFE11D48');
21+
expect(toArgb('E11D48')).toBe('FFE11D48');
22+
});
23+
24+
it('returns undefined for anything that is not plain hex', () => {
25+
for (const bad of ['', ' ', '#12', '#12345', '#1234567', 'red', 'rgb(1,2,3)', '#gggggg', null, undefined, 42, {}]) {
26+
expect(toArgb(bad as unknown)).toBeUndefined();
27+
}
28+
});
29+
});
30+
31+
describe('cellFontColor', () => {
32+
const priority: ExportFieldMeta = {
33+
name: 'priority', type: 'select', label: '优先级',
34+
options: [{ label: '高', value: 'high', color: '#e11d48' }, { label: '低', value: 'low', color: '#3ab' }],
35+
};
36+
37+
it('resolves the matched select option colour to ARGB', () => {
38+
expect(cellFontColor('high', priority)).toBe('FFE11D48');
39+
expect(cellFontColor('low', priority)).toBe('FF33AABB');
40+
});
41+
42+
it('works for radio the same as select', () => {
43+
const radio: ExportFieldMeta = { ...priority, type: 'radio' };
44+
expect(cellFontColor('high', radio)).toBe('FFE11D48');
45+
});
46+
47+
it('returns undefined when the cell should stay unstyled', () => {
48+
// No/blank value.
49+
expect(cellFontColor(null, priority)).toBeUndefined();
50+
expect(cellFontColor(undefined, priority)).toBeUndefined();
51+
// Value has no matching option.
52+
expect(cellFontColor('urgent', priority)).toBeUndefined();
53+
// Matched option carries no colour.
54+
const noColor: ExportFieldMeta = { name: 'p', type: 'select', options: [{ label: 'X', value: 'x' }] };
55+
expect(cellFontColor('x', noColor)).toBeUndefined();
56+
// Non-option field type is never coloured, even with a hex-looking value.
57+
const text: ExportFieldMeta = { name: 't', type: 'text' };
58+
expect(cellFontColor('#e11d48', text)).toBeUndefined();
59+
// Missing metadata entirely.
60+
expect(cellFontColor('high', undefined)).toBeUndefined();
61+
// Multiselect is out of scope (ambiguous single font colour for many values).
62+
const multi: ExportFieldMeta = { ...priority, type: 'multiselect' };
63+
expect(cellFontColor('high', multi)).toBeUndefined();
64+
});
65+
});

packages/rest/src/export-format.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface ExportFieldMeta {
1717
name: string;
1818
type?: string;
1919
label?: string;
20-
options?: Array<{ label?: string; value?: unknown }>;
20+
options?: Array<{ label?: string; value?: unknown; color?: string }>;
2121
/** Target object for lookup / master_detail / user fields. */
2222
reference?: string;
2323
/** Field on the referenced record to show as its label. */
@@ -128,6 +128,36 @@ function optionLabel(value: unknown, options?: Array<{ label?: string; value?: u
128128
return hit?.label ?? value;
129129
}
130130

131+
/**
132+
* Normalize a CSS-ish hex color to exceljs' 8-digit ARGB (`FFRRGGBB`, opaque).
133+
* Accepts `#RGB` / `#RRGGBB` with or without the leading `#`, any case.
134+
* Returns `undefined` for anything else (empty, named colors, rgb(), garbage)
135+
* so callers simply skip styling rather than emit an invalid workbook.
136+
*/
137+
export function toArgb(color: unknown): string | undefined {
138+
if (typeof color !== 'string') return undefined;
139+
const hex = color.trim().replace(/^#/, '');
140+
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
141+
const [r, g, b] = hex;
142+
return `FF${r}${r}${g}${g}${b}${b}`.toUpperCase();
143+
}
144+
if (/^[0-9a-fA-F]{6}$/.test(hex)) return `FF${hex}`.toUpperCase();
145+
return undefined;
146+
}
147+
148+
/**
149+
* Font color (exceljs ARGB) for one cell, driven by the matched select/radio
150+
* option's `color`. Returns `undefined` when the field is not option-typed, no
151+
* option matches, the option has no color, or the color is not a valid hex —
152+
* i.e. whenever the cell should stay unstyled.
153+
*/
154+
export function cellFontColor(value: unknown, meta?: ExportFieldMeta): string | undefined {
155+
if (value === null || value === undefined) return undefined;
156+
if (!meta || !meta.type || !OPTION_TYPES.has(meta.type) || !meta.options) return undefined;
157+
const hit = meta.options.find((o) => o && o.value === value);
158+
return toArgb(hit?.color);
159+
}
160+
131161
function displayFromRecord(rec: Record<string, unknown>, displayField?: string): string {
132162
if (displayField && rec[displayField] != null) return String(rec[displayField]);
133163
for (const k of NAME_KEY_FALLBACKS) {

packages/rest/src/export-integration.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ const TASK = {
132132
done: { name: 'done', type: 'boolean' as const, label: '完成' },
133133
priority: {
134134
name: 'priority', type: 'select' as const, label: '优先级',
135-
options: [{ label: '高', value: 'high' }, { label: '低', value: 'low' }],
135+
// `color` drives the xlsx font colour; '#3ab' exercises the 3-digit path.
136+
options: [{ label: '高', value: 'high', color: '#e11d48' }, { label: '低', value: 'low', color: '#3ab' }],
136137
},
137138
due: { name: 'due', type: 'date' as const, label: '截止' },
138139
owner: { name: 'owner', type: 'lookup' as const, label: '负责人', reference: 'user', displayField: 'name' },
@@ -255,6 +256,43 @@ describe('export route — real engine + protocol integration', () => {
255256
expect(r1).toEqual(['1', '写代码', '是', '高', '2026-06-30', '张三']);
256257
});
257258

259+
it('XLSX: select cells get the option colour as font colour; header signals applied', async () => {
260+
const { res, getBuffer, headers } = makeBinRes();
261+
await route.handler({ params: { object: 'task' }, query: { format: 'xlsx' } } as any, res);
262+
263+
// Default limit (10000) is within the style cap, so colours are applied.
264+
expect(headers['X-Export-Styles']).toBe('applied');
265+
266+
const wb = new ExcelJS.Workbook();
267+
await wb.xlsx.load(getBuffer() as any);
268+
const ws = wb.worksheets[0];
269+
// priority is column 4 (ID, 标题, 完成, 优先级, ...).
270+
const highCell = ws.getRow(2).getCell(4); // '高' → #e11d48
271+
const lowCell = ws.getRow(3).getCell(4); // '低' → #3ab (shorthand)
272+
expect((highCell.font?.color as any)?.argb).toBe('FFE11D48');
273+
expect((lowCell.font?.color as any)?.argb).toBe('FF33AABB');
274+
// A non-option cell (title) stays unstyled.
275+
expect(ws.getRow(2).getCell(2).font?.color).toBeUndefined();
276+
});
277+
278+
it('XLSX: exceeding the style cap drops styling but keeps all rows', async () => {
279+
const { res, getBuffer, headers } = makeBinRes();
280+
await route.handler(
281+
{ params: { object: 'task' }, query: { format: 'xlsx', limit: '20000' } } as any,
282+
res,
283+
);
284+
285+
expect(headers['X-Export-Styles']).toBe('dropped');
286+
const wb = new ExcelJS.Workbook();
287+
await wb.xlsx.load(getBuffer() as any);
288+
const ws = wb.worksheets[0];
289+
// Data is intact...
290+
const r1 = (ws.getRow(2).values as any[]).slice(1).map((v) => String(v));
291+
expect(r1).toEqual(['1', '写代码', '是', '高', '2026-06-30', '张三']);
292+
// ...but the select cell carries no font colour.
293+
expect(ws.getRow(2).getCell(4).font?.color).toBeUndefined();
294+
});
295+
258296
it('JSON: readable values, all rows present', async () => {
259297
const { res, chunks, headers } = makeRes();
260298
await route.handler({ params: { object: 'task' }, query: { format: 'json' } } as any, res);

packages/rest/src/rest-server.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
headerLabel,
1212
formatRowCells,
1313
formatRowForJson,
14+
cellFontColor,
1415
type ExportFieldMeta,
1516
} from './export-format.js';
1617
import { runImport } from './import-runner.js';
@@ -783,7 +784,7 @@ function rowsToCsv(
783784
* ended. Dynamically imported so `node:stream` / `exceljs` stay out of the
784785
* module's static graph.
785786
*/
786-
async function createXlsxStream(res: any): Promise<{
787+
async function createXlsxStream(res: any, useStyles = false): Promise<{
787788
ws: any;
788789
finalize: () => Promise<void>;
789790
}> {
@@ -797,7 +798,7 @@ async function createXlsxStream(res: any): Promise<{
797798
passthrough.on('error', reject);
798799
});
799800

800-
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles: false });
801+
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles });
801802
const ws = wb.addWorksheet('Export');
802803

803804
return {
@@ -4154,6 +4155,11 @@ export class RestServer {
41544155
// Streams the response so 50k-row exports do not buffer in memory; the
41554156
// xlsx path pipes exceljs' streaming writer straight onto the response.
41564157
// Filename suggests `${object}-${YYYY-MM-DD}.${ext}` for browsers.
4158+
//
4159+
// xlsx only: select / radio cells are coloured with their option's
4160+
// `color` as the font colour (white cell background) when the effective
4161+
// limit is <= 10000. Larger exports drop styling for performance and set
4162+
// `X-Export-Styles: dropped` (else `applied`); csv / json are unaffected.
41574163
this.routeManager.register({
41584164
method: 'GET',
41594165
path: `${dataPath}/:object/export`,
@@ -4177,9 +4183,17 @@ export class RestServer {
41774183
const includeHeader = String(q.header ?? 'true').toLowerCase() !== 'false';
41784184
const HARD_CAP = 50_000;
41794185
const MAX_CHUNK = 5_000;
4186+
// Styled xlsx (per-cell font colour from select options) is far
4187+
// heavier than a bare value dump, so cap it well below HARD_CAP;
4188+
// above this the export still succeeds, just without colours.
4189+
const STYLE_ROW_CAP = 10_000;
41804190
const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 10_000;
41814191
const limit = Math.min(requestedLimit, HARD_CAP);
41824192
const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
4193+
// Colour cells only for xlsx within the style cap; decided up
4194+
// front (before streaming) since we can't know the true row
4195+
// count until the stream drains.
4196+
const styled = format === 'xlsx' && limit <= STYLE_ROW_CAP;
41834197

41844198
let filter: any = undefined;
41854199
if (typeof q.filter === 'string' && q.filter.length > 0) {
@@ -4267,13 +4281,17 @@ export class RestServer {
42674281
}
42684282
res.header('X-Export-Format', format);
42694283
res.header('X-Export-Limit', String(limit));
4284+
// Signal whether select-option colours were applied. Only
4285+
// meaningful for xlsx; 'dropped' means the limit exceeded the
4286+
// style cap so the workbook is colourless but complete.
4287+
if (format === 'xlsx') res.header('X-Export-Styles', styled ? 'applied' : 'dropped');
42704288
res.header('Cache-Control', 'no-store');
42714289

42724290
let exported = 0;
42734291
let firstChunk = true;
42744292
let skip = 0;
42754293
if (format === 'json') res.write('[');
4276-
const xlsx = format === 'xlsx' ? await createXlsxStream(res) : null;
4294+
const xlsx = format === 'xlsx' ? await createXlsxStream(res, styled) : null;
42774295

42784296
while (exported < limit) {
42794297
const take = Math.min(chunkSize, limit - exported);
@@ -4312,8 +4330,16 @@ export class RestServer {
43124330
if (firstChunk && includeHeader) {
43134331
xlsx!.ws.addRow((fields ?? []).map((f) => headerLabel(f, metaMap))).commit();
43144332
}
4333+
const cols = fields ?? [];
43154334
for (const row of rows) {
4316-
xlsx!.ws.addRow(formatRowCells(row, fields ?? [], metaMap)).commit();
4335+
const r = xlsx!.ws.addRow(formatRowCells(row, cols, metaMap));
4336+
if (styled) {
4337+
cols.forEach((f, i) => {
4338+
const argb = cellFontColor(row?.[f], metaMap.get(f));
4339+
if (argb) r.getCell(i + 1).font = { color: { argb } };
4340+
});
4341+
}
4342+
r.commit();
43174343
}
43184344
} else {
43194345
for (let i = 0; i < rows.length; i++) {

0 commit comments

Comments
 (0)