Skip to content

Commit

Permalink
feat: add support for column multi-cell rows & auto-merging
Browse files Browse the repository at this point in the history
  • Loading branch information
ChronicStone committed Apr 22, 2024
1 parent a1c17b9 commit 59176aa
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 71 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
[![License][license-src]][license-href]




> ### **Export any data into xls/xlsx files effortlessly, while benefiting from great type-safety & developper experience.**
## Key Features :
Expand Down
Binary file modified example.xlsx
Binary file not shown.
164 changes: 105 additions & 59 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,80 +189,127 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
})
}

tableConfig.columns.forEach((column, index) => {
const headerCellRef = utils.encode_cell({ c: index + COL_OFFSET, r: ROW_OFFSET + (rowHasTitle ? 1 : 0) })
tableConfig.columns.forEach((column, colIndex) => {
const headerCellRef = utils.encode_cell({ c: colIndex + COL_OFFSET, r: ROW_OFFSET + (rowHasTitle ? 1 : 0) })
worksheet[headerCellRef] = {
v: column.label,
t: 's',
s: getColumnHeaderStyle({ bordered: params?.bordered ?? true }),
} satisfies XLSX.CellObject

tableConfig.content.forEach((row, rowIndex) => {
const cellRef = utils.encode_cell({ c: index + COL_OFFSET, r: rowIndex + 1 + ROW_OFFSET + (rowHasTitle ? 1 : 0) })
const value = column.value(row)
const maxRowHeight = tableConfig.columns.reduce((acc, column) => {
const _resolvedValue = column.value(row)
const values = Array.isArray(_resolvedValue) ? _resolvedValue : [_resolvedValue]
return Math.max(acc, values.length)
}, 1)

const _resolvedValue = column.value(row)
const values = Array.isArray(_resolvedValue) ? _resolvedValue : [_resolvedValue]
const style = typeof column._ref.cellStyle === 'function'
? column._ref.cellStyle(row)
: column._ref.cellStyle ?? {}
const format = typeof column._ref.format === 'function'
? column._ref.format(row)
: column._ref.format

worksheet[cellRef] = {
v: value === null ? '' : value,
t: getCellDataType(value),
z: format,
s: deepmerge(
style,
{
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
values.forEach((value, valueIndex) => {
const cellRef = utils.encode_cell({ c: colIndex + COL_OFFSET, r: (rowIndex * maxRowHeight) + 1 + ROW_OFFSET + (rowHasTitle ? 1 : 0) + valueIndex })
worksheet[cellRef] = {
v: value === null ? '' : value,
t: getCellDataType(value),
z: format,
s: deepmerge(
style,
{
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
})

if (values.length < maxRowHeight && maxRowHeight > 1) {
for (let i = values.length; i < maxRowHeight; i++) {
const cellRef = utils.encode_cell({ c: colIndex + COL_OFFSET, r: (rowIndex * maxRowHeight) + 1 + ROW_OFFSET + (rowHasTitle ? 1 : 0) + i })
worksheet[cellRef] = {
v: '',
t: 's',
s: deepmerge(
style,
{
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
}
if (values.length === 1) {
if (!worksheet['!merges'])
worksheet['!merges'] = []

worksheet['!merges'].push({
s: { c: colIndex + COL_OFFSET, r: (rowIndex * maxRowHeight) + 1 + ROW_OFFSET + (rowHasTitle ? 1 : 0) },
e: { c: colIndex + COL_OFFSET, r: (rowIndex * maxRowHeight) + 1 + ROW_OFFSET + (rowHasTitle ? 1 : 0) + maxRowHeight - 1 },
})
}
}
// if (colIndex === 0)
// ROW_OFFSET += (maxRowHeight - 1)
})

if (tableHasSummary(tableConfig)) {
const summaryRowIndex = tableConfig.content.length + 1
for (const columnIndex in tableConfig.columns) {
const column = tableConfig.columns[columnIndex]
for (const summaryIndex in column._ref?.summary ?? []) {
const summary = column._ref?.summary?.[summaryIndex]
if (!summary)
continue

const cellRef = utils.encode_cell({ c: +columnIndex + COL_OFFSET, r: summaryRowIndex + ROW_OFFSET + +summaryIndex + (rowHasTitle ? 1 : 0) })
if (!summary) {
worksheet[cellRef] = {
v: '',
t: 's',
s: getColumnHeaderStyle({ bordered: params?.bordered ?? true }),
} satisfies XLSX.CellObject

continue
}

const style = typeof summary.cellStyle === 'function'
? summary.cellStyle(tableConfig.content)
: summary.cellStyle ?? {}
const format = typeof summary.format === 'function'
? summary.format(tableConfig.content)
: summary.format
const value = summary.value(tableConfig.content)
const tableContentExtraRows = tableConfig.columns.reduce((acc, col) => {
return Math.max(acc, tableConfig.content.reduce((acc, row) => {
const _resolvedValue = col.value(row)
const values = Array.isArray(_resolvedValue) ? _resolvedValue : [_resolvedValue]
return values.length - 1 + acc
}, 0))
}, 0)

if (tableHasSummary(tableConfig)) {
const summaryRowIndex = tableConfig.content.length + 1 + tableContentExtraRows
for (const summaryIndex in column._ref?.summary ?? []) {
const summary = column._ref?.summary?.[summaryIndex]
const cellRef = utils.encode_cell({ c: +colIndex + COL_OFFSET, r: summaryRowIndex + ROW_OFFSET + +summaryIndex + (rowHasTitle ? 1 : 0) })
if (!summary) {
worksheet[cellRef] = {
v: value === null ? '' : value,
t: getCellDataType(value),
z: format,
s: deepmerge(
style,
v: '',
t: 's',
s: getColumnHeaderStyle({ bordered: params?.bordered ?? true }),
} satisfies XLSX.CellObject

continue
}

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

worksheet[cellRef] = {
v: value === null ? '' : value,
t: getCellDataType(value),
z: format,
s: deepmerge(
style,
{
font: { bold: true },
fill: { fgColor: { rgb: 'E9E9E9' } },
Expand All @@ -277,9 +324,8 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
: {},
numFmt: format,
} satisfies CellStyle,
),
} satisfies XLSX.CellObject
}
),
} satisfies XLSX.CellObject
}
}
})
Expand Down
5 changes: 3 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export type Prettify<T> = {
[K in keyof T]: T[K]
} & {}

export type CellValue = string | number | boolean | null | undefined | Date
export type BaseCellValue = string | number | boolean | null | undefined | Date
export type CellValue = BaseCellValue | BaseCellValue[]

export type ValueTransformer = (value: any) => CellValue

Expand Down Expand Up @@ -86,7 +87,7 @@ export type Column<
format?: string | ((rowData: T) => string)
cellStyle?: CellStyle | ((rowData: T) => CellStyle)
summary?: Array<{
value: (data: T[]) => CellValue
value: (data: T[]) => BaseCellValue
format?: string | ((data: T[]) => string)
cellStyle?: CellStyle | ((data: T[]) => CellStyle)
}>
Expand Down
36 changes: 28 additions & 8 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function buildSheetConfig(sheets: Array<SheetConfig>) {
.map((column) => {
return {
label: column?.label ?? formatKey(column.columnKey),
value: (row: GenericObject) => {
value: (row: GenericObject): CellValue => {
const value = typeof column.key === 'string'
? getPropertyFromPath(row, column.key)
: column.key(row)
Expand Down Expand Up @@ -183,8 +183,19 @@ export function getSheetChunkMaxHeight(
return tables.reduce((acc, table) => {
const hasTitle = !!table.title
const summaryRowLength = tableSummaryRowLength(table)
const tableHeight = table.content.length + 1 + summaryRowLength + (hasTitle ? 1 : 0)
return Math.max(acc, tableHeight)

// Calculate the maximum row span needed for any row within this table using .reduce
const maxRowSpan = table.content.reduce((max, row) => {
return Math.max(max, ...table.columns.map((column) => {
const values = column.value(row)
return Array.isArray(values) ? values.length : 1
}))
}, 1) // Start with 1 as the minimum span

// Calculate the total height of the table, considering the max row span
const tableHeight = (table.content.length * maxRowSpan) + 1 + summaryRowLength + (hasTitle ? 1 : 0)

return Math.max(acc, tableHeight) // Update the accumulated maximum with the current table's height
}, 0)
}

Expand All @@ -204,7 +215,7 @@ export function computeSheetRange(sheetRows: Array<ReturnType<typeof buildSheetC
const sheetWidth = sheetRows.reduce((acc, tables) => {
const rowWidth = tables.reduce((acc, table) => {
const tableWidth = table.columns.length
return acc + tableWidth + 1
return acc + tableWidth + 1 // Includes space for column separation
}, 0)
return Math.max(acc, rowWidth)
}, 0)
Expand All @@ -213,16 +224,25 @@ export function computeSheetRange(sheetRows: Array<ReturnType<typeof buildSheetC
const rowHeight = tables.reduce((acc, table) => {
const hasTitle = !!table.title
const summaryRowLength = tableSummaryRowLength(table)
const tableHeight = table.content.length + summaryRowLength + 1 + (hasTitle ? 1 : 0)
return Math.max(acc, tableHeight)

// Compute max row span due to multi-value columns
const maxRowSpan = table.columns.reduce((max, column) => {
return Math.max(max, table.content.reduce((maxRow, row) => {
const values = column.value(row)
return Array.isArray(values) ? Math.max(maxRow, values.length) : maxRow
}, 1))
}, 1)

const tableHeight = (table.content.length * maxRowSpan) + summaryRowLength + (hasTitle ? 1 : 0)
return Math.max(acc, tableHeight) // We find the maximum height needed for any table in the row
}, 0)
return acc + rowHeight + 1
return acc + rowHeight + 1 // Adding each row's height plus one for spacing between rows
}, 0)

return {
sheetHeight,
sheetWidth,
sheetRange: utils.encode_range({ s: { c: 0, r: 0 }, e: { c: sheetWidth, r: sheetHeight } }),
sheetRange: utils.encode_range({ s: { c: 0, r: 0 }, e: { c: sheetWidth - 1, r: sheetHeight - 1 } }), // Adjust end column and row index by subtracting 1
}
}

Expand Down
4 changes: 2 additions & 2 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ describe('should generate the example excel', () => {
key: 'id',
summary: [{ value: () => 'TOTAL BEFORE VAT' }, { value: () => 'TOTAL' }],
})
.column('firstName', { key: 'firstName' })
.column('lastName', { key: 'lastName' })
.column('firstName', { key: 'firstName', transform: () => ['Cyprien', 'THAO'] })
.column('lastName', { key: 'lastName', transform: () => ['Thao', 'Other', 'Test'] })
.column('email', { key: 'email' })
.column('roles', {
key: 'roles',
Expand Down
Binary file added ~$example.xlsx
Binary file not shown.

0 comments on commit 59176aa

Please sign in to comment.