feat(base): add csv cell serializer with per-type rules

This commit is contained in:
Philipinho
2026-04-18 18:10:47 +01:00
parent db6f82ff7a
commit da0321b468
2 changed files with 177 additions and 0 deletions
@@ -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('');
});
});
@@ -0,0 +1,87 @@
import { BasePropertyType, BasePropertyTypeValue } from '../base.schemas';
export type CellCsvContext = {
userNames?: Map<string, string>;
};
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);
}
}