Add WithAutoFilter, WithFrozenRows/Columns, and FromTemplate#12
Conversation
Closes #4 — WithAutoFilter + WithFrozenRows: - WithAutoFilter: set Excel auto-filter dropdowns with explicit range or 'auto' mode that detects range from headings - WithFrozenRows: freeze N rows from top (sticky headers) - WithFrozenColumns: freeze N columns from left - numberToColumnLetter helper for auto-filter range calculation Closes #5 — FromTemplate: - Load an existing .xlsx template and replace {{placeholder}} bindings - WithTemplateData: insert repeating row data at a specific start cell - Preserves all original formatting, formulas, and merged cells - Supports combining with WithProperties and WithCsvSettings - Handles embedded placeholders in longer strings - Single-placeholder cells get raw typed values (numbers, dates, etc.) Tests: 76 passing (18 new), 100% lines/functions coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds new “concerns” to enhance generated spreadsheets (auto-filters, freeze panes) and introduces a template-based export mode that fills existing .xlsx files with placeholder bindings and optional repeating row data.
Changes:
- Add
WithAutoFilter(explicit range or auto-detected from headings) andWithFrozenRows/WithFrozenColumns(freeze panes) support. - Add
FromTemplate+WithTemplateDatato load.xlsxtemplates, replace{{placeholders}}, and write repeating rows. - Add helper
numberToColumnLetter()and expand test coverage for the new concerns/features.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/fixtures/create-template.ts | Adds a programmatic .xlsx template fixture used by template-related tests. |
| test/excel.service.spec.ts | Adds tests for numberToColumnLetter, auto-filtering, freeze panes, and template exports. |
| src/index.ts | Re-exports new concerns from the public package entrypoint. |
| src/helpers/index.ts | Re-exports numberToColumnLetter from helpers. |
| src/helpers/file-type-detector.ts | Implements numberToColumnLetter() helper. |
| src/excel.writer.ts | Adds template-based export path (FromTemplate, WithTemplateData) and CSV/XLSX serialization for templates. |
| src/excel.sheet.ts | Implements WithAutoFilter and frozen panes logic during sheet population. |
| src/concerns/with-frozen-rows.interface.ts | Introduces WithFrozenRows / WithFrozenColumns interfaces. |
| src/concerns/with-auto-filter.interface.ts | Introduces WithAutoFilter interface. |
| src/concerns/from-template.interface.ts | Introduces FromTemplate / WithTemplateData interfaces. |
| src/concerns/index.ts | Exports the newly added concerns. |
Comments suppressed due to low confidence (1)
src/excel.sheet.ts:181
- In
automode, the filter row is computed usingheadingStartRow, which is currently set to the first heading row. SinceWithHeadingssupports multiple heading rows, this will place the auto-filter dropdowns on the wrong row when headings are provided asstring[][]. Consider tracking the last heading row written (e.g.,headingEndRow = currentRow - 1after the headings loop) and using that row for the auto-filter range.
let headingColCount = 0;
let headingStartRow = startRow;
// --- headings -----------------------------------------------------
if (isWithHeadings(exportable)) {
const headings = exportable.headings();
const headingRows = Array.isArray(headings[0]) ? headings : [headings];
headingStartRow = currentRow;
for (const headingRow of headingRows as string[][]) {
if (headingRow.length > headingColCount) {
headingColCount = headingRow.length;
}
const row = worksheet.getRow(currentRow);
headingRow.forEach((val, idx) => {
row.getCell(startCol + idx).value = val;
});
row.commit();
currentRow++;
}
}
// --- data rows ----------------------------------------------------
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async function writeTemplateExport( | ||
| exportable: FromTemplate & object, | ||
| type: ExcelType, | ||
| options: ExcelModuleOptions, | ||
| ): Promise<Buffer> { | ||
| const templatePath = exportable.templatePath(); | ||
| if (!fs.existsSync(templatePath)) { | ||
| throw new Error(`Template file not found: "${templatePath}"`); | ||
| } | ||
|
|
||
| const workbook = new Workbook(); | ||
| await workbook.xlsx.readFile(templatePath); | ||
|
|
||
| const bindings = exportable.bindings(); | ||
|
|
||
| // --- replace placeholders in all sheets --------------------------- | ||
| for (const worksheet of workbook.worksheets) { | ||
| worksheet.eachRow((row) => { | ||
| row.eachCell((cell) => { | ||
| if (typeof cell.value === "string") { | ||
| let value: string = cell.value; | ||
| for (const [placeholder, replacement] of Object.entries(bindings)) { | ||
| if (value.includes(placeholder)) { | ||
| value = value.split(placeholder).join(String(replacement)); | ||
| } | ||
| } | ||
| // If the entire cell was a single placeholder, use the raw type | ||
| if ( | ||
| Object.keys(bindings).length > 0 && | ||
| Object.keys(bindings).some((k) => cell.value === k) | ||
| ) { | ||
| cell.value = bindings[cell.value as string]; | ||
| } else if (value !== cell.value) { | ||
| cell.value = value; | ||
| } | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| // --- insert repeating row data ------------------------------------ | ||
| if (isWithTemplateData(exportable)) { | ||
| const startRef = parseCellRef(exportable.dataStartCell()); | ||
| const rows = await exportable.templateData(); | ||
| const worksheet = workbook.worksheets[0]; | ||
|
|
||
| for (let i = 0; i < rows.length; i++) { | ||
| const wsRow = worksheet.getRow(startRef.row + i); | ||
| rows[i].forEach((val, colIdx) => { | ||
| wsRow.getCell(startRef.col + colIdx).value = val; | ||
| }); | ||
| wsRow.commit(); | ||
| } | ||
| } | ||
|
|
||
| // --- apply document properties if present ------------------------- | ||
| if (isWithProperties(exportable)) { | ||
| const props = (exportable as any).properties(); | ||
| if (props.creator) workbook.creator = props.creator; | ||
| if (props.lastModifiedBy) workbook.lastModifiedBy = props.lastModifiedBy; | ||
| if (props.title) workbook.title = props.title; | ||
| if (props.subject) workbook.subject = props.subject; | ||
| if (props.description) workbook.description = props.description; | ||
| if (props.keywords) workbook.keywords = props.keywords; | ||
| if (props.category) workbook.category = props.category; | ||
| if (props.company) workbook.company = props.company; | ||
| if (props.manager) workbook.manager = props.manager; | ||
| } | ||
|
|
||
| // --- fire events -------------------------------------------------- | ||
| fireEvent(exportable, ExcelExportEvent.BEFORE_WRITING, { | ||
| exportable, | ||
| workbook, | ||
| }); | ||
|
|
There was a problem hiding this comment.
Template-based exports currently only fire the BEFORE_WRITING event. The normal export path also fires BEFORE_EXPORT and BEFORE_SHEET/AFTER_SHEET, so WithEvents handlers will observe a different lifecycle depending on export type. To keep the event contract consistent (and match the PR description that templating composes with WithEvents), mirror the same event sequence in writeTemplateExport (e.g., BEFORE_EXPORT after loading the workbook, and BEFORE_SHEET/AFTER_SHEET around per-worksheet placeholder/data mutations).
| export function numberToColumnLetter(num: number): string { | ||
| let result = ""; | ||
| let n = num; | ||
| while (n > 0) { | ||
| const remainder = (n - 1) % 26; | ||
| result = String.fromCharCode(65 + remainder) + result; | ||
| n = Math.floor((n - 1) / 26); | ||
| } | ||
| return result; | ||
| } |
There was a problem hiding this comment.
numberToColumnLetter() silently returns an empty string for num <= 0 (and produces potentially surprising results for non-integers). Since this helper is now part of the public helpers API, consider validating input (e.g., require a positive integer) and throwing a clear error when the input is out of range to avoid generating invalid cell/range references downstream.
Corrects auto-filter positioning to apply to the last heading row instead of the first when multiple heading rows exist, ensuring filters appear on the correct data columns. Adds event lifecycle support for template-based exports, firing beforeExport, beforeSheet, afterSheet, and beforeWriting events to enable customization of template workbooks. Enhances column letter conversion helper with validation to prevent invalid input and throw descriptive errors for non-positive integers. Updates documentation with comprehensive examples for auto-filter, frozen rows/columns, and template features including data population and placeholder bindings.
Summary
Implements two v0.1.x issues:
WithAutoFilter (#4)
'A1:D1') or'auto'mode that detects the range from headingsWithCustomStartCellWithFrozenRows / WithFrozenColumns (#4)
WithFrozenRows— freeze N rows from the top (sticky headers when scrolling)WithFrozenColumns— freeze N columns from the leftFromTemplate (#5)
.xlsxtemplate and replace{{placeholder}}patterns with values frombindings()WithTemplateData— insert repeating row data at a specific start cellWithProperties,WithCsvSettings, andWithEventsCoverage
Test plan
🤖 Generated with Claude Code