diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index b7e0ede..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: [antfu] diff --git a/package.json b/package.json index e22a25d..56aca70 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "prepare": "simple-git-hooks" }, "dependencies": { + "deepmerge-ts": "^5.1.0", "json-as-xlsx": "^2.5.6", "xlsx-js-style": "^1.2.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 076126d..c1f3dad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + deepmerge-ts: + specifier: ^5.1.0 + version: 5.1.0 json-as-xlsx: specifier: ^2.5.6 version: 2.5.6 @@ -1880,6 +1883,11 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepmerge-ts@5.1.0: + resolution: {integrity: sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==} + engines: {node: '>=16.0.0'} + dev: false + /deepmerge@4.2.2: resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} engines: {node: '>=0.10.0'} diff --git a/src/index.ts b/src/index.ts index 93ed94d..6ac06d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,9 @@ import xlsx, { type IColumn, type IJsonSheet } from 'json-as-xlsx' +import type { CellStyle } from 'xlsx-js-style' +import XLSX from 'xlsx-js-style' +import { deepmerge } from 'deepmerge-ts' import type { CellValue, Column, ExcelSchema, GenericObject, NestedPaths, Not, Sheet, TransformersMap, ValueTransformer } from './types' -import { getPropertyFromPath } from './utils' +import { getPropertyFromPath, getSheetCellKey } from './utils' export class ExcelSchemaBuilder< T extends GenericObject, @@ -70,16 +73,17 @@ export class ExcelBuilder { .filter(column => !sheet.select || sheet.select.includes(column.columnKey)) .map((column) => { return { - label: column.columnKey, + label: column?.label ?? column.columnKey, value: (row) => { - const value = typeof column.value === 'string' - ? getPropertyFromPath(row, column.value) - : column.value(row) + const value = typeof column.key === 'string' + ? getPropertyFromPath(row, column.key) + : column.key(row) if (!value) return column.default ?? '' return column.transform ? (column.transform as ValueTransformer)(value) : value }, + format: column.format, } satisfies IColumn }), content: sheet.data, @@ -95,7 +99,47 @@ export class ExcelBuilder { // eslint-disable-next-line node/prefer-global/buffer }) as Buffer - return fileBody + const workbook = XLSX.read(fileBody, { type: 'buffer' }) + workbook.SheetNames.forEach((sheetName) => { + const sheetConfig = this.sheets.find(sheet => sheet.sheetKey === sheetName) + if (!sheetConfig) + return + + sheetConfig.schema.forEach((column, index) => { + const headerCellRef = getSheetCellKey(index + 1, 1) + if (!workbook.Sheets[sheetName][headerCellRef]) + return + workbook.Sheets[sheetName][headerCellRef].s = { + font: { bold: true }, + alignment: { horizontal: 'center' }, + fill: { fgColor: { rgb: 'E9E9E9' } }, + border: { + bottom: { style: 'thin', color: { rgb: '000000' } }, + left: { style: 'thin', color: { rgb: '000000' } }, + right: { style: 'thin', color: { rgb: '000000' } }, + top: { style: 'thin', color: { rgb: '000000' } }, + }, + } satisfies CellStyle + sheetConfig.data.forEach((row, rowIndex) => { + const cellRef = getSheetCellKey(index + 1, rowIndex + 2) + const style = column.cellStyle?.(row) ?? {} + workbook.Sheets[sheetName][cellRef].s = deepmerge( + style, + { + border: { + bottom: { style: 'thin', color: { rgb: '000000' } }, + left: { style: 'thin', color: { rgb: '000000' } }, + right: { style: 'thin', color: { rgb: '000000' } }, + top: { style: 'thin', color: { rgb: '000000' } }, + }, + }, + ) + }) + }) + }) + + // eslint-disable-next-line node/prefer-global/buffer + return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer // for() } } diff --git a/src/types.ts b/src/types.ts index f0b24c9..c62f034 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,14 +1,17 @@ +import type { CellStyle } from 'xlsx-js-style' + export type GenericObject = Record export type NestedPaths = T extends Array - ? U extends object ? never : never - : T extends object - ? { - [K in keyof T & (string | number)]: K extends string - ? `${K}` | (NonNullable extends object ? `${K}.${NestedPaths>}` : never) - : never; - }[keyof T & (string | number)] - : never + ? U extends (object | Date) ? never : never + : T extends Date ? never + : T extends object + ? { + [K in keyof T & (string | number)]: K extends string + ? `${K}` | (NonNullable extends object ? `${K}.${NestedPaths>}` : never) + : never; + }[keyof T & (string | number)] + : never export type NestedPathsForType = T extends Array ? U extends object ? never : never @@ -50,7 +53,7 @@ export type DeepRequired = { } export type TypedTransformersMap = { - [K in keyof TransformMap]: Parameters[0] extends Value ? K : never; + [K in keyof TransformMap]: Value extends Parameters[0] ? K : never; }[keyof TransformMap] export type ExtractColumnValue< @@ -63,12 +66,13 @@ export type Column< FieldValue extends string | ((data: T) => CellValue), ColKey extends string, TransformMap extends TransformersMap, - > = { + label?: string columnKey: ColKey - value: FieldValue + key: FieldValue default?: CellValue - // test?: FieldValue extends string ? TypeFromPath : FieldValue extends (data: T) => CellValue ? ReturnType : never + format?: string + cellStyle?: (rowData: T) => CellStyle } & ( ExtractColumnValue extends CellValue ? { transform?: TypedTransformersMap> | ((value: ExtractColumnValue) => CellValue) } diff --git a/test.xlsx b/test.xlsx deleted file mode 100644 index 9f42a7d..0000000 Binary files a/test.xlsx and /dev/null differ diff --git a/test/index.test.ts b/test/index.test.ts index 4e445b2..1b33aaf 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -21,18 +21,20 @@ interface User { technical: { overall: number } interview?: { overall: number } } + createdAt: Date } const transformers = { - boolean: (value: boolean) => value ? 'Yes' : 'No', - list: (value: (string)[]) => value.join(', '), - arrayLength: (value: any[]) => value.length, + boolean: (key: boolean) => key ? 'Yes' : 'No', + list: (key: (string)[]) => key.join(', '), + arrayLength: (key: any[] | string) => key.length, + date: (key: Date) => key.toLocaleDateString(), } satisfies TransformersMap // Usage example describe('should', () => { it('exported', () => { - const users: User[] = Array.from({ length: 100 }, (_, id) => ({ + const users: User[] = Array.from({ length: 10 }, (_, id) => ({ id, firstName: faker.person.firstName(), lastName: faker.person.lastName(), @@ -48,20 +50,22 @@ describe('should', () => { technical: { overall: Math.floor(Math.random() * 10) }, ...(Math.random() > 0.5 ? { interview: { overall: Math.floor(Math.random() * 10) } } : {}), }, + createdAt: faker.date.past(), })) const assessmentExport = ExcelSchemaBuilder .create() .withTransformers(transformers) - .column('id', { value: 'id' }) - .column('firstName', { value: 'firstName' }) - .column('lastName', { value: 'lastName' }) - .column('email', { value: 'email' }) - .column('roles', { value: 'roles', transform: 'list' }) - .column('nbOrgs', { value: 'organizations', transform: 'arrayLength' }) - .column('orgs', { value: 'organizations', transform: value => value.map(org => org.name).join(', ') }) - .column('generalScore', { value: 'results.general.overall' }) - .column('technicalScore', { value: 'results.technical.overall' }) - .column('interviewScore', { value: 'results.interview.overall', default: 'N/A' }) + .column('id', { key: 'id' }) + .column('firstName', { key: 'firstName', cellStyle: () => ({ fill: { fgColor: { rgb: 'E9E9E9' } } }) }) + .column('lastName', { key: 'lastName' }) + .column('email', { key: 'email' }) + .column('roles', { key: 'roles', transform: 'list' }) + .column('nbOrgs', { key: 'organizations', transform: 'arrayLength' }) + .column('orgs', { key: 'organizations', transform: org => org.map(org => org.name).join(', ') }) + .column('generalScore', { key: 'results.general.overall' }) + .column('technicalScore', { key: 'results.technical.overall' }) + .column('interviewScore', { key: 'results.interview.overall', default: 'N/A' }) + .column('createdAt', { key: 'createdAt', transform: 'date' }) .build() const buffer = ExcelBuilder