Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/xlsx-export-select-colours.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions packages/rest/src/export-format.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
32 changes: 31 additions & 1 deletion packages/rest/src/export-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, unknown>, displayField?: string): string {
if (displayField && rec[displayField] != null) return String(rec[displayField]);
for (const k of NAME_KEY_FALLBACKS) {
Expand Down
40 changes: 39 additions & 1 deletion packages/rest/src/export-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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);
Expand Down
34 changes: 30 additions & 4 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
headerLabel,
formatRowCells,
formatRowForJson,
cellFontColor,
type ExportFieldMeta,
} from './export-format.js';
import { runImport } from './import-runner.js';
Expand Down Expand Up @@ -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<void>;
}> {
Expand All @@ -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 {
Expand Down Expand Up @@ -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`,
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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++) {
Expand Down