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
2 changes: 1 addition & 1 deletion packages/plugin-export-advanced/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"directory": "packages/plugin-export-advanced"
},
"dependencies": {
"excel4node": "^1.8.0"
"write-excel-file": "^4.1.1"
},
"devDependencies": {
"@forestadmin/datasource-customizer": "1.71.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-export-advanced/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export async function addExportAdvanced(

const renderer = renderers[format];
const records = await context.collection.list(context.filter, fields);
const output = renderer.handler(records, fields);
const output = await renderer.handler(records, fields);

return resultBuilder.file(output, `${filename}${format}`, renderer.mimeType);
},
Expand Down
78 changes: 35 additions & 43 deletions packages/plugin-export-advanced/src/renderers/xlsx.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,53 @@
import type { Readable } from 'stream';
import type { Cell } from 'write-excel-file/node';

import xl from 'excel4node';
import writeXlsxFile from 'write-excel-file/node';

import getFieldValue from '../utils/get-field-value';

function getExcel4NodeTypeFromValue(value: unknown): string {
// Matches excel4node's default date format so exports stay identical after the migration.
const XLSX_DATE_FORMAT = 'm/d/yy';

export function toCell(value: unknown): Cell {
if (value === null || value === undefined) return null;
if (typeof value === 'boolean') return 'bool';
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return { type: Boolean, value };

if (typeof value === 'string') {
if (value === 'true' || value === 'false') return 'bool';
if (!Number.isNaN(Number(value))) return 'number';
if (!Number.isNaN(Date.parse(value))) return 'date';
if (typeof value === 'number') {
return Number.isFinite(value)
? { type: Number, value }
: { type: String, value: String(value) };
}

return 'string';
if (value instanceof Date) {
return Number.isFinite(value.getTime())
? { type: Date, value, format: XLSX_DATE_FORMAT }
: null;
}

if (value instanceof Date) return 'date';
if (typeof value === 'string') {
if (value === 'true' || value === 'false') return { type: Boolean, value: value === 'true' };

return 'string';
}
if (value.trim() !== '' && Number.isFinite(Number(value))) {
return { type: Number, value: Number(value) };
}

function castValue(value) {
switch (getExcel4NodeTypeFromValue(value)) {
case 'bool':
return Boolean(value);
case 'number':
return Number(value);
case 'string':
return String(value);
case 'date':
return new Date(value);
default:
return String(value);
}
}
const timestamp = Date.parse(value);

export default function render(records: Record<string, unknown>[], projection: string[]): Readable {
const wb = new xl.Workbook();
const ws = wb.addWorksheet('Export');
if (!Number.isNaN(timestamp)) {
return { type: Date, value: new Date(timestamp), format: XLSX_DATE_FORMAT };
}

for (const [index, name] of projection.entries()) {
ws.cell(1, index + 1).string(name);
return { type: String, value };
}

for (const [row, record] of records.entries()) {
for (const [col, name] of projection.entries()) {
const value = getFieldValue(record, name);
const castedValue = castValue(value);
const excel4NodeCellFunction = getExcel4NodeTypeFromValue(castedValue);
return { type: String, value: String(value) };
}

if (value !== null && value !== undefined) {
ws.cell(2 + row, 1 + col)[excel4NodeCellFunction](castedValue);
}
}
}
export default function render(
records: Record<string, unknown>[],
projection: string[],
): Promise<Buffer> {
const header: Cell[] = projection.map(name => ({ type: String, value: name }));
const rows = records.map(record => projection.map(name => toCell(getFieldValue(record, name))));

return wb.writeToBuffer();
return writeXlsxFile([header, ...rows], { sheet: 'Export' }).toBuffer();
}
21 changes: 21 additions & 0 deletions packages/plugin-export-advanced/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,27 @@ describe('plugin-export-advanced', () => {
expect((result as FileResult).mimeType).toStrictEqual(mimeType);
expect((result as FileResult).name).toStrictEqual(`file${format}`);
});

test('the xlsx export should be a non-empty workbook', async () => {
const result = await dataSource.getCollection('books').execute(
factories.caller.build(),
'Export books (advanced)',
{
Format: '.xlsx',
Filename: 'file',
Fields: ['id', 'title', 'isPublished', 'publishedAt', 'author:id', 'author:fullname'],
},
factories.filter.build(),
);

const chunks: Uint8Array[] = [];
for await (const chunk of (result as FileResult).stream) chunks.push(chunk as Uint8Array);
const buffer = Buffer.concat(chunks);

expect(buffer.length).toBeGreaterThan(0);
// xlsx is a zip archive: it must start with the "PK" local-file-header magic bytes.
expect(buffer.subarray(0, 2).toString('latin1')).toStrictEqual('PK');
});
});

describe('When providing settings (on datasource)', () => {
Expand Down
32 changes: 32 additions & 0 deletions packages/plugin-export-advanced/test/renderers/xlsx.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { toCell } from '../../src/renderers/xlsx';

describe('xlsx toCell', () => {
test.each([
['true', true],
['false', false],
])('casts the boolean string "%s" to a boolean cell', (input, expected) => {
expect(toCell(input)).toStrictEqual({ type: Boolean, value: expected });
});

test('casts a numeric string to a number cell', () => {
expect(toCell('42')).toStrictEqual({ type: Number, value: 42 });
});

test('casts a non-primitive value to a string cell', () => {
expect(toCell({ foo: 'bar' })).toStrictEqual({ type: String, value: '[object Object]' });
});

// A non-finite number would ship as <v>NaN</v>/<v>Infinity</v> and make Excel repair the file.
test('falls back to a string cell for a non-finite number', () => {
expect(toCell(NaN)).toStrictEqual({ type: String, value: 'NaN' });
expect(toCell(Infinity)).toStrictEqual({ type: String, value: 'Infinity' });
});

test('drops an invalid Date to an empty cell', () => {
expect(toCell(new Date('nope'))).toBeNull();
});

test.each(['Infinity', '1e309'])('keeps the non-finite numeric string "%s" as text', input => {
expect(toCell(input)).toStrictEqual({ type: String, value: input });
});
});
95 changes: 12 additions & 83 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8274,23 +8274,6 @@ eventsource@^3.0.2:
dependencies:
eventsource-parser "^3.0.1"

excel4node@^1.8.0:
version "1.8.2"
resolved "https://registry.yarnpkg.com/excel4node/-/excel4node-1.8.2.tgz#2d2f8b2ae56a3d3c7ae6a29bb2652b25807b021f"
integrity sha512-v5BZZy8y4cibFQ/xvztUleAoyYmIBol1qTKWuDWZZPpFGBAy4P7qkswdpBkTkQgLIQ/WkCpyV/P6liW4mIb/wQ==
dependencies:
deepmerge "^4.2.2"
image-size "^1.0.2"
jszip "^3.10.0"
lodash.get "^4.4.2"
lodash.isequal "^4.5.0"
lodash.isundefined "^3.0.1"
lodash.reduce "^4.6.0"
lodash.uniqueid "^4.0.1"
mime "^3.0.0"
uuid "^9.0.0"
xmlbuilder "^15.1.1"

execa@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376"
Expand Down Expand Up @@ -8870,6 +8853,11 @@ fdir@^6.5.0:
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==

fflate@^0.8.2:
version "0.8.3"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc"
integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==

figures@3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af"
Expand Down Expand Up @@ -10076,18 +10064,6 @@ ignore@^5.2.0:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78"
integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==

image-size@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/image-size/-/image-size-1.0.2.tgz#d778b6d0ab75b2737c1556dd631652eb963bc486"
integrity sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==
dependencies:
queue "6.0.2"

immediate@~3.0.5:
version "3.0.6"
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==

import-fresh@^3.0.0, import-fresh@^3.2.1, import-fresh@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
Expand Down Expand Up @@ -11599,16 +11575,6 @@ jsonwebtoken@^9.0.0:
ms "^2.1.1"
semver "^7.5.4"

jszip@^3.10.0:
version "3.10.1"
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2"
integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==
dependencies:
lie "~3.3.0"
pako "~1.0.2"
readable-stream "~2.3.6"
setimmediate "^1.0.5"

just-diff-apply@^5.2.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/just-diff-apply/-/just-diff-apply-5.5.0.tgz#771c2ca9fa69f3d2b54e7c3f5c1dfcbcc47f9f0f"
Expand Down Expand Up @@ -12005,13 +11971,6 @@ libnpmversion@^8.0.3:
proc-log "^6.0.0"
semver "^7.3.7"

lie@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==
dependencies:
immediate "~3.0.5"

light-my-request@6.6.0, light-my-request@^6.0.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/light-my-request/-/light-my-request-6.6.0.tgz#c9448772323f65f33720fb5979c7841f14060add"
Expand Down Expand Up @@ -12204,11 +12163,6 @@ lodash.isstring@^4.0.1:
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==

lodash.isundefined@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz#23ef3d9535565203a66cefd5b830f848911afb48"
integrity sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==

lodash.kebabcase@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
Expand All @@ -12234,11 +12188,6 @@ lodash.once@^4.0.0:
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==

lodash.reduce@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b"
integrity sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==

lodash.snakecase@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d"
Expand All @@ -12259,11 +12208,6 @@ lodash.uniqby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302"
integrity sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==

lodash.uniqueid@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.uniqueid/-/lodash.uniqueid-4.0.1.tgz#3268f26a7c88e4f4b1758d679271814e31fa5b26"
integrity sha512-GQQWaIeGlL6DIIr06kj1j6sSmBxyNMwI8kaX9aKpHR/XsMTiaXDVPNPAkiboOTK9OJpTJF/dXT3xYoFQnj386Q==

lodash.upperfirst@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce"
Expand Down Expand Up @@ -12869,11 +12813,6 @@ mime@2.6.0:
resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==

mime@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7"
integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==

mime@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-4.1.0.tgz#ec55df7aa21832a36d44f0bbee5c04639b27802f"
Expand Down Expand Up @@ -14516,11 +14455,6 @@ pacote@^21.0.0, pacote@^21.0.2, pacote@^21.0.4:
ssri "^13.0.0"
tar "^7.4.3"

pako@~1.0.2:
version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==

parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
Expand Down Expand Up @@ -15362,13 +15296,6 @@ queue-microtask@^1.1.2, queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==

queue@6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.2.tgz#b91525283e2315c7553d2efa18d83e76432fed65"
integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==
dependencies:
inherits "~2.0.3"

quick-format-unescaped@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-3.0.3.tgz#fb3e468ac64c01d22305806c39f121ddac0d1fb9"
Expand Down Expand Up @@ -18545,6 +18472,13 @@ wrappy@1, wrappy@1.0.2:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==

write-excel-file@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/write-excel-file/-/write-excel-file-4.1.1.tgz#b2c7065605d87b8d43601f63c4a726b7fba1be3c"
integrity sha512-MUnCnNtQrcZek832ZcU24uU0rSphFmKPD1DvIjXOlygVb93CV7Tme6H3jUTkxsMmjB2W7HIzERzjqTi5kui71A==
dependencies:
fflate "^0.8.2"

write-file-atomic@5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7"
Expand Down Expand Up @@ -18605,11 +18539,6 @@ xml-naming@^0.3.0:
resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.3.0.tgz#46c1e18bfe2858479982dd2accf34d16e749eda2"
integrity sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==

xmlbuilder@^15.1.1:
version "15.1.1"
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5"
integrity sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==

xtend@^4.0.0, xtend@~4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
Expand Down
Loading