Skip to content

Commit

Permalink
feat: add table summary support
Browse files Browse the repository at this point in the history
  • Loading branch information
ChronicStone committed Dec 5, 2023
1 parent f885be2 commit 91038bc
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 23 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ const assessmentExport = ExcelSchemaBuilder
})
}
})
.summary({
id: { value: () => 'TOTAL' },
balance: { value: data => data.reduce((acc, user) => acc + user.balance, 0), format: '"$"#,##0.00_);\\("$"#,##0.00\\)' },
generalScore: { value: data => data.reduce((acc, user) => acc + user.results.general.overall, 0) / data.length },
technicalScore: { value: data => data.reduce((acc, user) => acc + user.results.technical.overall, 0) / data.length },
interviewScore: { value: data => data.reduce((acc, user) => acc + (user.results.interview?.overall ?? 0), 0) / data.length },
})
.build()
```

Expand Down
Binary file modified consumption.xlsx
Binary file not shown.
Binary file modified example.xlsx
Binary file not shown.
126 changes: 107 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,28 @@ import type { Buffer } from 'node:buffer'
import xlsx, { type IColumn, type IJsonSheet, getWorksheetColumnWidths } from 'json-as-xlsx'
import XLSX, { type CellStyle } from 'xlsx-js-style'
import { deepmerge } from 'deepmerge-ts'
import type { CellValue, Column, ColumnGroup, ExcelBuildOutput, ExcelBuildParams, ExcelSchema, GenericObject, NestedPaths, Not, SchemaColumnKeys, Sheet, TOutputType, TransformersMap, ValueTransformer } from './types'
import { formatKey, getPropertyFromPath, getSheetCellKey } from './utils'
import type { CellValue, Column, ColumnGroup, ExcelBuildOutput, ExcelBuildParams, ExcelSchema, GenericObject, NestedPaths, Not, SchemaColumnKeys, Sheet, TOutputType, TableSummary, TransformersMap, ValueTransformer } from './types'
import { formatKey, getCellDataType, getPropertyFromPath, getSheetCellKey } from './utils'

export class ExcelSchemaBuilder<
T extends GenericObject,
CellKeyPaths extends string,
UsedKeys extends string = never,
TransformMap extends TransformersMap = {},
ContextMap extends { [key: string]: any } = {},
SummaryMap extends TableSummary<T, UsedKeys> = {},
> {
private columns: Array<Column<T, CellKeyPaths | ((data: T) => CellValue), string, TransformMap> | ColumnGroup<T, string, CellKeyPaths, string, TransformMap, any>> = []
private transformers: TransformMap = {} as TransformMap
private summaryMap: SummaryMap = {} as SummaryMap

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, SummaryMap> {
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, ContextMap, SummaryMap>
}

public column<
Expand All @@ -31,12 +33,12 @@ export class ExcelSchemaBuilder<
>(
columnKey: Not<K, UsedKeys>,
column: Omit<Column<T, FieldValue, K, TransformMap>, 'columnKey' | 'type'>,
): ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys | K, TransformMap, ContextMap> {
): ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys | K, TransformMap, ContextMap, SummaryMap> {
if (this.columns.some(c => c.columnKey === columnKey))
throw new Error(`Column with key '${columnKey}' already exists.`)

this.columns.push({ type: 'column', columnKey, ...column } as any)
return this as unknown as ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys | K, TransformMap, ContextMap>
return this
}

public group<
Expand All @@ -50,7 +52,8 @@ export class ExcelSchemaBuilder<
CellKeyPaths,
UsedKeys | K,
TransformMap,
ContextMap & { [key in K]: Context }
ContextMap & { [key in K]: Context },
SummaryMap
> {
if (this.columns.some(c => c.columnKey === key))
throw new Error(`Column with key '${key}' already exists.`)
Expand All @@ -64,23 +67,34 @@ export class ExcelSchemaBuilder<
builder,
handler,
} as any)
return this
return this as any
}

summary<Summary extends TableSummary<T, UsedKeys>>(summary: Summary): ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys, TransformMap, ContextMap, Summary> {
this.summaryMap = summary as SummaryMap & Summary
return this as unknown as ExcelSchemaBuilder<T, CellKeyPaths, UsedKeys, TransformMap, ContextMap, SummaryMap & Summary>
}

public build() {
return this.columns.map(column => column.type === 'column'
const columns = this.columns.map(column => column.type === 'column'
? ({
...column,
transform: typeof column.transform === 'string'
? this.transformers[column.transform]
: column.transform,
})
: column) as ExcelSchema<
T,
CellKeyPaths,
UsedKeys,
ContextMap
>
: column)

return {
columns,
summary: this.summaryMap,
} as ExcelSchema<
T,
CellKeyPaths,
UsedKeys,
ContextMap,
SummaryMap
>
}
}

Expand Down Expand Up @@ -109,12 +123,13 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
}

public build<
OutputType extends TOutputType,
Output = ExcelBuildOutput<OutputType>,
>(params: ExcelBuildParams<OutputType>): Output {
OutputType extends TOutputType,
Output = ExcelBuildOutput<OutputType>,
>(params: ExcelBuildParams<OutputType>): Output {
const _sheets = this.sheets.map(sheet => ({
sheet: sheet.sheetKey,
columns: sheet.schema
.columns
.filter((column) => {
if (!column)
return false
Expand All @@ -137,7 +152,7 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
else {
const builder = column.builder()
column.handler(builder, ((sheet.context ?? {}) as any)[column.columnKey])
const columns = builder.build()
const { columns } = builder.build()
return columns as Column<any, any, any, any>[]
}
})
Expand All @@ -164,6 +179,8 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
} satisfies (IColumn & { _ref: Column<any, any, any, any> })
}),
content: sheet.data,
summary: sheet.schema.summary,
enableSummary: sheet.summary ?? true,
})) satisfies IJsonSheet[]

const fileBody = xlsx(_sheets, {
Expand Down Expand Up @@ -235,6 +252,77 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
)
})
})

const hasSummary = Object.keys(sheetConfig.summary).length > 0

if (hasSummary && sheetConfig.enableSummary) {
const summaryRowIndex = sheetConfig.content.length + 2
workbook.Sheets[sheetName]['!ref'] = `A1:${getSheetCellKey(sheetConfig.columns.length, summaryRowIndex)}` as any

for (const columnIndex in sheetConfig.columns) {
const column = sheetConfig.columns[columnIndex]
const summary = (sheetConfig.summary as TableSummary<GenericObject, string>)[column._ref.columnKey]
const cellRef = getSheetCellKey(+columnIndex + 1, summaryRowIndex)
if (!summary) {
workbook.Sheets[sheetName][cellRef] = {
v: '',
t: 's',
s: {
fill: { fgColor: { rgb: 'E9E9E9' } },
alignment: { vertical: 'center' },
border: (params?.bordered ?? true)
? {
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,
} satisfies XLSX.CellObject

continue
}

const style = typeof summary.cellStyle === 'function'
? summary.cellStyle(sheetConfig.content)
: summary.cellStyle ?? {}
const format = typeof summary.format === 'function'
? summary.format(sheetConfig.content)
: summary.format
const value = summary.value(sheetConfig.content)

workbook.Sheets[sheetName][cellRef] = {
v: value === null ? '' : value,
t: getCellDataType(value),
z: format,
s: deepmerge(
style,
{
font: { bold: true },
fill: { fgColor: { rgb: 'E9E9E9' } },
alignment: { vertical: 'center' },
border: (params?.bordered ?? true)
? {
bottom: { style: 'thin', color: { rgb: '000000' } },
left: { style: 'thin', color: { rgb: '000000' } },
right: { style: 'thin', color: { rgb: '000000' } },
top: { style: 'thin', color: { rgb: '000000' } },
}
: {},
numFmt: format,
} satisfies CellStyle,
),
} satisfies XLSX.CellObject
}
}

workbook.Sheets[sheetName]['!rows'] = Array.from({ length: sheetConfig.content.length + (hasSummary ? 2 : 1),
}, () => ({ hpt: params?.rowHeight ?? 30 }))

workbook.Sheets[sheetName]['!cols'] = getWorksheetColumnWidths(workbook.Sheets[sheetName], params?.extraLength ?? 5).map(({ width }) => ({
wch: width,
}))
})

return params.output === 'workbook' ? workbook : (XLSX.write(workbook, { type: params.output, bookType: 'xlsx' }))
Expand Down
19 changes: 16 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,28 @@ export type GroupHandler<
context: Context,
) => void

export type ExcelSchema<
export type TableSummary<T extends GenericObject, UsedKeys extends string> = {
[K in UsedKeys]?: {
value: (data: T[]) => CellValue
format?: string | ((data: T[]) => string)
cellStyle?: CellStyle | ((data: T[]) => CellStyle)
}
}

export interface ExcelSchema<
T extends GenericObject,
KeyPaths extends string,
Key extends string,
ContextMap extends { [key: string]: any } = {},
> = Array<Column<T, KeyPaths, Key, any> | ColumnGroup<T, Key, KeyPaths, string, any, any, ContextMap>>
SummaryMap extends TableSummary<T, Key> = {},
> {
columns: Array<Column<T, KeyPaths, Key, any> | ColumnGroup<T, Key, KeyPaths, string, any, any, ContextMap>>
summary: SummaryMap
}

export type SchemaColumnKeys<
T extends ExcelSchema<any, any, string>,
> = T 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> | ColumnGroup<any, infer K, any, any, any, any>> ? K : never

export type Sheet<
T extends GenericObject,
Expand All @@ -143,6 +155,7 @@ export type Sheet<
data: T[]
select?: SelectColsMap
context?: {}
summary?: {}
} & (keyof SelectedContextMap extends never ? {} : { context: Prettify<SelectedContextMap> })

export type ExtractContextMap<
Expand Down
13 changes: 12 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GenericObject } from './types'
import type { ExcelDataType } from 'xlsx-js-style'
import type { CellValue, GenericObject } from './types'

export function getPropertyFromPath(obj: GenericObject, path: string) {
try {
Expand Down Expand Up @@ -34,3 +35,13 @@ export function formatKey(key: string) {
.join(' ')
)
}

export function getCellDataType(value: CellValue): ExcelDataType {
if (value instanceof Date)
return 'd'
if (typeof value === 'number')
return 'n'
if (typeof value === 'boolean')
return 'b'
return 's'
}
7 changes: 7 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ describe('should generate the example excel', () => {
})
}
})
.summary({
id: { value: () => 'TOTAL' },
balance: { value: data => data.reduce((acc, user) => acc + user.balance, 0), format: '"$"#,##0.00_);\\("$"#,##0.00\\)' },
generalScore: { value: data => data.reduce((acc, user) => acc + user.results.general.overall, 0) / data.length },
technicalScore: { value: data => data.reduce((acc, user) => acc + user.results.technical.overall, 0) / data.length },
interviewScore: { value: data => data.reduce((acc, user) => acc + (user.results.interview?.overall ?? 0), 0) / data.length },
})
.build()

const buffer = ExcelBuilder
Expand Down

0 comments on commit 91038bc

Please sign in to comment.