Skip to content

Commit

Permalink
feat: implement cell format shared presets
Browse files Browse the repository at this point in the history
  • Loading branch information
ChronicStone committed Apr 29, 2024
1 parent ed259ac commit b0af7aa
Show file tree
Hide file tree
Showing 7 changed files with 62 additions and 22 deletions.
Binary file modified examples/financial-report.xlsx
Binary file not shown.
Binary file modified examples/kitchen-sink.xlsx
Binary file not shown.
Binary file modified examples/playground.xlsx
Binary file not shown.
34 changes: 24 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable ts/ban-types */
import XLSX, { type WorkSheet, utils } from 'xlsx-js-style'
import type { CellValue, Column, ColumnGroup, ExcelBuildOutput, ExcelBuildParams, ExcelSchema, GenericObject, NestedPaths, Not, SchemaColumnKeys, SheetConfig, SheetParams, SheetTable, SheetTableBuilder, TOutputType, TransformersMap } from './types'
import type { CellValue, Column, ColumnGroup, ExcelBuildOutput, ExcelBuildParams, ExcelSchema, FormattersMap, GenericObject, NestedPaths, Not, SchemaColumnKeys, SheetConfig, SheetParams, SheetTable, SheetTableBuilder, TOutputType, TransformersMap } from './types'
import { SheetCacheManager, applyGroupBorders, buildSheetConfig, createCell, getColumnHeaderStyle, getColumnSeparatorIndexes, getWorksheetColumnWidths, tableHasSummary } from './utils'

export type * from './types'
Expand All @@ -10,27 +10,34 @@ export class ExcelSchemaBuilder<
CellKeyPaths extends string,
UsedKeys extends string = never,
TransformMap extends TransformersMap = {},
FormatMap extends FormattersMap = {},
ContextMap extends { [key: string]: any } = {},
> {
private columns: Array<Column<T, CellKeyPaths | ((data: T) => CellValue), string, TransformMap> | ColumnGroup<T, string, CellKeyPaths, string, TransformMap, any>> = []
private columns: Array<Column<T, CellKeyPaths | ((data: T) => CellValue), string, TransformMap, FormatMap> | ColumnGroup<T, string, CellKeyPaths, string, TransformMap, FormatMap, any>> = []
private transformers: TransformMap = {} as TransformMap
private formatters: FormatMap = {} as FormatMap

public static create<T extends GenericObject, KeyPath extends string = NestedPaths<T>>(): ExcelSchemaBuilder<T, KeyPath> {
return new ExcelSchemaBuilder<T, KeyPath>()
}

public withTransformers<Transformers extends TransformersMap>(transformers: Transformers): ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys, TransformMap & Transformers> {
public withTransformers<Transformers extends TransformersMap>(transformers: Transformers): ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys, TransformMap & Transformers, FormatMap, ContextMap> {
this.transformers = transformers as TransformMap & Transformers
return this as unknown as ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys, TransformMap & Transformers, ContextMap>
return this as unknown as ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys, TransformMap & Transformers, FormatMap, ContextMap>
}

withFormatters<Formatters extends FormattersMap>(formatters: Formatters): ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys, TransformMap, FormatMap & Formatters, ContextMap> {
this.formatters = formatters as FormatMap & Formatters
return this as unknown as ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys, TransformMap, FormatMap & Formatters, ContextMap>
}

public column<
K extends string,
FieldValue extends CellKeyPaths | ((data: T) => CellValue),
>(
columnKey: Not<K, UsedKeys>,
column: Omit<Column<T, FieldValue, K, TransformMap>, 'columnKey' | 'type'>,
): ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys | K, TransformMap, ContextMap> {
column: Omit<Column<T, FieldValue, K, TransformMap, FormatMap>, 'columnKey' | 'type'>,
): ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys | K, TransformMap, FormatMap, ContextMap> {
if (this.columns.some(c => c.columnKey === columnKey))
throw new Error(`Column with key '${columnKey}' already exists.`)

Expand All @@ -43,19 +50,21 @@ export class ExcelSchemaBuilder<
Context,
>(
key: Not<K, UsedKeys>,
handler: (builder: ExcelSchemaBuilder<T, CellKeyPaths, never, TransformMap>, context: Context) => void,
handler: (builder: ExcelSchemaBuilder<T, CellKeyPaths, never, TransformMap, FormatMap>, context: Context) => void,
): ExcelSchemaBuilder<
T,
CellKeyPaths,
UsedKeys | K,
TransformMap,
FormatMap,
ContextMap & { [key in K]: Context }
> {
if (this.columns.some(c => c.columnKey === key))
throw new Error(`Column with key '${key}' already exists.`)

const builder = () => ExcelSchemaBuilder.create<T, CellKeyPaths>()
.withTransformers(this.transformers)
.withFormatters(this.formatters)

this.columns.push({
type: 'group',
Expand All @@ -78,6 +87,7 @@ export class ExcelSchemaBuilder<

return {
columns,
formatPresets: this.formatters as FormattersMap,
} as ExcelSchema<
T,
CellKeyPaths,
Expand Down Expand Up @@ -112,7 +122,7 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
private defineTable<
Key extends string,
T extends GenericObject,
Schema extends ExcelSchema<T, any, string>,
Schema extends ExcelSchema<T, any, string, any>,
ColKeys extends SchemaColumnKeys<Schema>,
SelectCols extends { [key in ColKeys]?: boolean } = {},
>(
Expand Down Expand Up @@ -178,6 +188,7 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
fill: { fgColor: { rgb: 'b4c4de' } },
font: { sz: 20 },
},
formatPresets: tableConfig.formatPresets,
})
})

Expand All @@ -193,6 +204,7 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
value: column.label,
bordered: params?.bordered ?? true,
style: getColumnHeaderStyle({ bordered: params?.bordered ?? true, customStyle: column._ref.headerStyle }),
formatPresets: tableConfig.formatPresets,
})

tableConfig.content.forEach((row, rowIndex) => {
Expand All @@ -214,6 +226,7 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
bordered: params?.bordered ?? true,
rowIndex,
subRowIndex: valueIndex,
formatPresets: tableConfig.formatPresets,
})
})

Expand All @@ -223,7 +236,7 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
c: colIndex + COL_OFFSET,
r: prevRowHeight + ROW_OFFSET + (chunk.hasTitle ? 1 : 0) + (valueIndex + 1),
})
worksheet[cellRef] = createCell({ value: '', bordered: params?.bordered ?? true })
worksheet[cellRef] = createCell({ value: '', bordered: params?.bordered ?? true, formatPresets: tableConfig.formatPresets })
}
if (values.length === 1) {
worksheet['!merges'].push({
Expand All @@ -247,6 +260,7 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
value: '',
bordered: params?.bordered ?? true,
style: getColumnHeaderStyle({ bordered: params?.bordered ?? true }),
formatPresets: tableConfig.formatPresets,
})
continue
}
Expand All @@ -263,7 +277,7 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
fill: { fgColor: { rgb: 'E9E9E9' } },
alignment: { vertical: 'center' },
},

formatPresets: tableConfig.formatPresets,
})
}
}
Expand Down
21 changes: 15 additions & 6 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export interface TransformersMap {
[key: string]: ValueTransformer
}

export interface FormattersMap {
[key: string]: string
}

export type NonNullableDeep<T> = T extends null | undefined ? never : T
export type DeepRequired<T> = {
[P in keyof T]-?: DeepRequired<NonNullableDeep<T[P]>>;
Expand All @@ -76,13 +80,14 @@ export type Column<
FieldValue extends string | ((data: T) => CellValue),
ColKey extends string,
TransformMap extends TransformersMap,
FormatMap extends FormattersMap,
> = {
type: 'column'
label?: string
columnKey: ColKey
key: FieldValue
default?: CellValue
format?: string | ((rowData: T, rowIndex: number, subRowIndex: number) => string)
format?: string | { preset: keyof FormatMap } | ((rowData: T, rowIndex: number, subRowIndex: number) => string | { preset: keyof FormatMap })
cellStyle?: CellStyle | ((rowData: T, rowIndex: number, subRowIndex: number) => CellStyle)
headerStyle?: CellStyle
summary?: Array<{
Expand All @@ -102,39 +107,43 @@ export interface ColumnGroup<
KeyPaths extends string,
UsedKeys extends string,
TransformMap extends TransformersMap,
FormatMap extends FormattersMap,
Context,
// eslint-disable-next-line unused-imports/no-unused-vars
ContextMap extends Record<string, any> = {},
> {
type: 'group'
columnKey: ColKey
builder: () => ExcelSchemaBuilder<T, KeyPaths, UsedKeys, TransformMap>
handler: GroupHandler<T, KeyPaths, UsedKeys, TransformMap, Context>
builder: () => ExcelSchemaBuilder<T, KeyPaths, UsedKeys, TransformMap, FormatMap>
handler: GroupHandler<T, KeyPaths, UsedKeys, TransformMap, FormatMap, Context>
}

export type GroupHandler<
T extends GenericObject,
CellKeyPaths extends string,
UsedKeys extends string,
TransformMap extends TransformersMap,
FormatMap extends FormattersMap,
Context,
> = (
builder: ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys, TransformMap>,
builder: ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys, TransformMap, FormatMap>,
context: Context,
) => void

export interface ExcelSchema<
T extends GenericObject,
KeyPaths extends string,
Key extends string,
// eslint-disable-next-line unused-imports/no-unused-vars
ContextMap extends { [key: string]: any } = {},
> {
columns: Array<Column<T, KeyPaths, Key, any> | ColumnGroup<T, Key, KeyPaths, string, any, any, ContextMap>>
columns: Array<Column<T, KeyPaths, Key, any, any> | ColumnGroup<T, Key, KeyPaths, string, any, any, any>>
formatPresets: FormattersMap
}

export type SchemaColumnKeys<
T extends ExcelSchema<any, any, string>,
> = T['columns'] extends Array<Column<any, any, infer K, any> | ColumnGroup<any, infer K, any, any, any, any>> ? K : never
> = T['columns'] extends Array<Column<any, any, infer K, any, any> | ColumnGroup<any, infer K, any, any, any, any, any>> ? K : never

export type SheetTable<
T extends GenericObject,
Expand Down
18 changes: 13 additions & 5 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { utils } from 'xlsx-js-style'
import type XLSX from 'xlsx-js-style'
import type { CellStyle, ExcelDataType, WorkSheet } from 'xlsx-js-style'
import { deepmerge } from 'deepmerge-ts'
import type { BaseCellValue, CellValue, Column, GenericObject, SheetConfig, ValueTransformer } from './types'
import type { BaseCellValue, CellValue, Column, FormattersMap, GenericObject, SheetConfig, ValueTransformer } from './types'
import { THICK_BORDER_STYLE, THIN_BORDER_STYLE } from './const'

export function getPropertyFromPath(obj: GenericObject, path: string) {
Expand Down Expand Up @@ -59,15 +59,15 @@ export function buildSheetConfig(sheets: Array<SheetConfig>) {

return false
})
.map((column): Column<any, any, any, any> | Column<any, any, any, any>[] => {
.map((column): Column<any, any, any, any, any> | Column<any, any, any, any, any>[] => {
if (column.type === 'column') {
return column
}
else {
const builder = column.builder()
column.handler(builder, ((table.context ?? {}) as any)[column.columnKey])
const { columns } = builder.build()
return (columns as Column<any, any, any, any>[])
return (columns as Column<any, any, any, any, any>[])
}
})
.flat()
Expand Down Expand Up @@ -99,6 +99,7 @@ export function buildSheetConfig(sheets: Array<SheetConfig>) {
content: table.data,
columns,
enableSummary: table.summary ?? true,
formatPresets: table.schema.formatPresets,
}
}),
}))
Expand Down Expand Up @@ -334,18 +335,25 @@ export function createCell(params: {
data?: GenericObject
value?: BaseCellValue
style?: CellStyle | ((rowData: any, rowIndex: number, subRowIndex: number) => CellStyle)
format?: string | ((rowData: any, rowIndex: number, subRowIndex: number) => string)
format?: string | { preset: string | number | symbol } | ((rowData: any, rowIndex: number, subRowIndex: number) => string | { preset: string | number | symbol })
extraStyle?: CellStyle
bordered?: boolean
rowIndex?: number
subRowIndex?: number
formatPresets: FormattersMap
}): XLSX.CellObject {
const style = typeof params.style === 'function'
? params.style(params.data ?? {}, params?.rowIndex ?? 0, params?.subRowIndex ?? 0)
: params.style ?? {}
const format = typeof params.format === 'function'
const rawFormat = typeof params.format === 'function'
? params.format(params.data ?? {}, params?.rowIndex ?? 0, params?.subRowIndex ?? 0)
: params.format

const format = typeof rawFormat === 'string'
? rawFormat
: rawFormat?.preset
? params.formatPresets[rawFormat.preset as string]
: ''
return {
v: params.value === null ? '' : params.value,
t: getCellDataType(params.value),
Expand Down
11 changes: 10 additions & 1 deletion test/play.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
import fs from 'node:fs'
import { describe, it } from 'vitest'
import { faker } from '@faker-js/faker'
import { ExcelBuilder, ExcelSchemaBuilder } from '../src'

describe('should generate the play excel file', () => {
it('exported', () => {
interface User { id: string, name: string }
interface User { id: string, name: string, birthDate: Date, balance: number }

// Group definition within the schema
const schema = ExcelSchemaBuilder.create<User>()
.withFormatters({
date: 'd mmm yyyy',
currency: '$#,##0.00',
})
.column('id', { key: 'id' })
.column('name', {
key: 'name',
cellStyle: { fill: { fgColor: { rgb: 'FFFF00' } } },
headerStyle: { fill: { fgColor: { rgb: '00FF00' } } },
})
.column('birthDate', { key: 'birthDate', format: { preset: 'date' } })
.column('balance', { key: 'balance', format: { preset: 'currency' } })
.build()

const users: User[] = Array.from({ length: 100000 }, (_, i) => ({
id: i.toString(),
name: 'John',
balance: +faker.finance.amount({ min: 0, max: 1000000, dec: 2 }),
birthDate: faker.date.past(),

}))

Expand Down

0 comments on commit b0af7aa

Please sign in to comment.