From da0321b4683576655a0c04b0ed9a70e1f7b0bfb0 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:10:47 +0100 Subject: [PATCH] feat(base): add csv cell serializer with per-type rules --- .../base/export/cell-csv-serializer.spec.ts | 90 +++++++++++++++++++ .../core/base/export/cell-csv-serializer.ts | 87 ++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 apps/server/src/core/base/export/cell-csv-serializer.spec.ts create mode 100644 apps/server/src/core/base/export/cell-csv-serializer.ts diff --git a/apps/server/src/core/base/export/cell-csv-serializer.spec.ts b/apps/server/src/core/base/export/cell-csv-serializer.spec.ts new file mode 100644 index 00000000..fb563fc5 --- /dev/null +++ b/apps/server/src/core/base/export/cell-csv-serializer.spec.ts @@ -0,0 +1,90 @@ +import { serializeCellForCsv } from './cell-csv-serializer'; +import { BasePropertyType } from '../base.schemas'; + +const p = (type: string, typeOptions: unknown = {}) => ({ + id: 'prop-1', + type: type as any, + typeOptions, +}); + +describe('serializeCellForCsv', () => { + const userNames = new Map([ + ['u1', 'Alice'], + ['u2', 'Bob'], + ]); + + it('returns empty string for null/undefined', () => { + expect(serializeCellForCsv(p(BasePropertyType.TEXT), null, {})).toBe(''); + expect(serializeCellForCsv(p(BasePropertyType.NUMBER), undefined, {})).toBe(''); + }); + + it('stringifies text/url/email as-is', () => { + expect(serializeCellForCsv(p(BasePropertyType.TEXT), 'hi', {})).toBe('hi'); + expect(serializeCellForCsv(p(BasePropertyType.URL), 'https://x', {})).toBe('https://x'); + expect(serializeCellForCsv(p(BasePropertyType.EMAIL), 'a@b.com', {})).toBe('a@b.com'); + }); + + it('stringifies number', () => { + expect(serializeCellForCsv(p(BasePropertyType.NUMBER), 42, {})).toBe('42'); + expect(serializeCellForCsv(p(BasePropertyType.NUMBER), 0, {})).toBe('0'); + }); + + it('renders checkbox as true/false', () => { + expect(serializeCellForCsv(p(BasePropertyType.CHECKBOX), true, {})).toBe('true'); + expect(serializeCellForCsv(p(BasePropertyType.CHECKBOX), false, {})).toBe('false'); + }); + + it('resolves select/status choice name', () => { + const prop = p(BasePropertyType.SELECT, { + choices: [ + { id: 'c1', name: 'Red', color: 'red' }, + { id: 'c2', name: 'Green', color: 'green' }, + ], + }); + expect(serializeCellForCsv(prop, 'c1', {})).toBe('Red'); + expect(serializeCellForCsv(prop, 'unknown', {})).toBe(''); + }); + + it('joins multiSelect names with "; " preserving order', () => { + const prop = p(BasePropertyType.MULTI_SELECT, { + choices: [ + { id: 'c1', name: 'A', color: 'red' }, + { id: 'c2', name: 'B', color: 'blue' }, + ], + }); + expect(serializeCellForCsv(prop, ['c2', 'c1'], {})).toBe('B; A'); + }); + + it('resolves person scalar and array', () => { + const prop = p(BasePropertyType.PERSON); + expect(serializeCellForCsv(prop, 'u1', { userNames })).toBe('Alice'); + expect(serializeCellForCsv(prop, ['u1', 'u2', 'missing'], { userNames })).toBe( + 'Alice; Bob', + ); + }); + + it('joins file names from cell payload', () => { + const prop = p(BasePropertyType.FILE); + expect( + serializeCellForCsv( + prop, + [ + { id: 'f1', fileName: 'a.pdf' }, + { id: 'f2', fileName: 'b.png' }, + ], + {}, + ), + ).toBe('a.pdf; b.png'); + }); + + it('dates pass through as ISO strings', () => { + const iso = '2026-04-18T12:00:00.000Z'; + expect(serializeCellForCsv(p(BasePropertyType.DATE), iso, {})).toBe(iso); + }); + + it('lastEditedBy resolves via userNames', () => { + const prop = p(BasePropertyType.LAST_EDITED_BY); + expect(serializeCellForCsv(prop, 'u2', { userNames })).toBe('Bob'); + expect(serializeCellForCsv(prop, 'missing', { userNames })).toBe(''); + }); +}); diff --git a/apps/server/src/core/base/export/cell-csv-serializer.ts b/apps/server/src/core/base/export/cell-csv-serializer.ts new file mode 100644 index 00000000..3882419c --- /dev/null +++ b/apps/server/src/core/base/export/cell-csv-serializer.ts @@ -0,0 +1,87 @@ +import { BasePropertyType, BasePropertyTypeValue } from '../base.schemas'; + +export type CellCsvContext = { + userNames?: Map; +}; + +type PropertyLike = { + id: string; + type: BasePropertyTypeValue | string; + typeOptions?: unknown; +}; + +function resolveChoiceName(typeOptions: unknown, id: unknown): string { + if (!typeOptions || typeof typeOptions !== 'object') return ''; + const choices = (typeOptions as any).choices; + if (!Array.isArray(choices)) return ''; + const match = choices.find((c: any) => c?.id === id); + return typeof match?.name === 'string' ? match.name : ''; +} + +function resolveUser(id: unknown, ctx: CellCsvContext): string { + if (typeof id !== 'string') return ''; + return ctx.userNames?.get(id) ?? ''; +} + +export function serializeCellForCsv( + property: PropertyLike, + value: unknown, + ctx: CellCsvContext, +): string { + if (value === null || value === undefined) return ''; + + switch (property.type) { + case BasePropertyType.TEXT: + case BasePropertyType.URL: + case BasePropertyType.EMAIL: + return String(value); + + case BasePropertyType.NUMBER: + return typeof value === 'number' ? String(value) : String(value ?? ''); + + case BasePropertyType.CHECKBOX: + return value === true ? 'true' : 'false'; + + case BasePropertyType.DATE: + case BasePropertyType.CREATED_AT: + case BasePropertyType.LAST_EDITED_AT: + if (value instanceof Date) return value.toISOString(); + return String(value); + + case BasePropertyType.SELECT: + case BasePropertyType.STATUS: + return resolveChoiceName(property.typeOptions, value); + + case BasePropertyType.MULTI_SELECT: + if (!Array.isArray(value)) return ''; + return value + .map((v) => resolveChoiceName(property.typeOptions, v)) + .filter((s) => s.length > 0) + .join('; '); + + case BasePropertyType.PERSON: { + const ids = Array.isArray(value) ? value : [value]; + return ids + .map((id) => resolveUser(id, ctx)) + .filter((s) => s.length > 0) + .join('; '); + } + + case BasePropertyType.FILE: + if (!Array.isArray(value)) return ''; + return value + .map((f: any) => + f && typeof f === 'object' && typeof f.fileName === 'string' + ? f.fileName + : '', + ) + .filter((s) => s.length > 0) + .join('; '); + + case BasePropertyType.LAST_EDITED_BY: + return resolveUser(value, ctx); + + default: + return typeof value === 'object' ? JSON.stringify(value) : String(value); + } +}