From 0acb73b68386dd2cab19b09e6c5c4a3f0862b3f3 Mon Sep 17 00:00:00 2001 From: Christopher Harrison Date: Thu, 12 Feb 2026 14:03:09 -0500 Subject: [PATCH 1/5] fix(export-workbook): export columns in blueprint field order The export plugin previously derived column order from Object.keys() on the API record values object, which depends on the arbitrary key order returned by the platform's internal schema query (no ORDER BY). This caused exported files to have columns in a different order than the review screen, which uses the blueprint's sheet.config.fields as its canonical order. - Iterate sheet.config.fields (blueprint order) instead of Object.keys(record.values) when building each row - Pass an explicit header array to XLSX.utils.json_to_sheet so SheetJS writes columns in blueprint order - Deduplicate the header array to prevent ghost columns when a columnNameTransformer maps multiple field keys to the same label - Preserve non-blueprint fields (allowAdditionalFields) by appending them after the blueprint columns --- plugins/export-workbook/src/plugin.ts | 114 +++++++++++++++++++------- 1 file changed, 85 insertions(+), 29 deletions(-) diff --git a/plugins/export-workbook/src/plugin.ts b/plugins/export-workbook/src/plugin.ts index f5ed3303..a9809541 100644 --- a/plugins/export-workbook/src/plugin.ts +++ b/plugins/export-workbook/src/plugin.ts @@ -66,6 +66,36 @@ export const exportRecords = async ( } : async (name: string) => name + // Build the ordered list of blueprint field keys, excluding any + // fields the caller wants omitted. This is the canonical column + // order shown in the review screen. + const blueprintFields = sheet.config.fields.filter( + (f) => !options.excludeFields?.includes(f.key) + ) + + // Pre-compute the transformed header names in blueprint order so + // we can pass them to json_to_sheet as an explicit `header` array. + // When a columnNameTransformer produces duplicate names (e.g. two + // different field keys both labelled "Employee ID"), we must keep + // only the *first* occurrence. Object.fromEntries used below to + // build each row already collapses duplicate keys (last-wins), so + // a second header entry with no matching data would create a ghost + // column. Deduplicating here keeps the header in sync with the + // row objects. + const blueprintHeaderRaw = await Promise.all( + blueprintFields.map((f) => columnNameTransformer(f.key, event)) + ) + const seenHeaders = new Set() + const blueprintHeader = blueprintHeaderRaw.filter((name) => { + if (seenHeaders.has(name)) { + return false + } + seenHeaders.add(name) + return true + }) + + const blueprintKeySet = new Set(blueprintFields.map((f) => f.key)) + try { let results = await processRecords( sheet.id, @@ -73,42 +103,65 @@ export const exportRecords = async ( const processedRecords = await Promise.all( records.map(async (record: Flatfile.RecordWithLinks) => { const { id: recordId, values: row } = record + + const formatCell = (cellValue: Flatfile.CellValue) => { + const { value, messages } = cellValue + const cell: XLSX.CellObject = { + t: 's', + v: Array.isArray(value) ? value.join(', ') : value, + c: [], + } + if (options.excludeMessages) { + cell.c = [] + } else if (messages.length > 0) { + cell.c = messages.map((m) => ({ + a: 'Flatfile', + t: `[${m.type.toUpperCase()}]: ${m.message}`, + T: true, + })) + cell.c.hidden = true + } + + return cell + } + + // Iterate fields in blueprint order so the resulting + // object key order matches the review screen. const rowEntries = await Promise.all( - Object.keys(row).map(async (colName: string) => { - if (options.excludeFields?.includes(colName)) { + blueprintFields.map(async (field) => { + const colName = field.key + const cellValue = row[colName] + if (!cellValue) { return null } - const formatCell = (cellValue: Flatfile.CellValue) => { - const { value, messages } = cellValue - const cell: XLSX.CellObject = { - t: 's', - v: Array.isArray(value) ? value.join(', ') : value, - c: [], - } - if (options.excludeMessages) { - cell.c = [] - } else if (messages.length > 0) { - cell.c = messages.map((m) => ({ - a: 'Flatfile', - t: `[${m.type.toUpperCase()}]: ${m.message}`, - T: true, - })) - cell.c.hidden = true - } - - return cell - } - const transformedColName = await columnNameTransformer( colName, event ) - return [transformedColName, formatCell(row[colName])] + return [transformedColName, formatCell(cellValue)] }) ) + // Append any extra columns present in the record that + // are NOT in the blueprint (e.g. user-added fields when + // allowAdditionalFields is true). + const extraEntries = await Promise.all( + Object.keys(row) + .filter((k) => !blueprintKeySet.has(k)) + .filter((k) => !options.excludeFields?.includes(k)) + .map(async (colName) => { + const transformedColName = await columnNameTransformer( + colName, + event + ) + return [transformedColName, formatCell(row[colName])] + }) + ) + const rowValue = Object.fromEntries( - rowEntries.filter((entry) => entry !== null) + [...rowEntries, ...extraEntries].filter( + (entry) => entry !== null + ) ) return options?.includeRecordIds @@ -143,10 +196,13 @@ export const exportRecords = async ( } const rows = results.flat() - const worksheet = XLSX.utils.json_to_sheet( - rows, - createXLSXSheetOptions(options.sheetOptions?.[sheet.config.slug]) - ) + const worksheet = XLSX.utils.json_to_sheet(rows, { + ...createXLSXSheetOptions(options.sheetOptions?.[sheet.config.slug]), + header: [ + ...(options?.includeRecordIds ? ['recordId'] : []), + ...blueprintHeader, + ], + }) XLSX.utils.book_append_sheet( xlsxWorkbook, From 1414b2ed5f9ad2cf022f2d8a59250a38f996db5b Mon Sep 17 00:00:00 2001 From: Christopher Harrison Date: Thu, 12 Feb 2026 14:12:06 -0500 Subject: [PATCH 2/5] test(export-workbook): add column ordering tests and fix messages default - Add 11 tests covering blueprint ordering, columnNameTransformer, duplicate header deduplication, extra fields, excludeFields, includeRecordIds, missing messages, absent fields, null values, array values, and excluded extra fields - Default messages to [] in formatCell destructuring to prevent crash when API returns CellValue without a messages property --- plugins/export-workbook/src/plugin.spec.ts | 471 +++++++++++++++++++++ plugins/export-workbook/src/plugin.ts | 2 +- 2 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 plugins/export-workbook/src/plugin.spec.ts diff --git a/plugins/export-workbook/src/plugin.spec.ts b/plugins/export-workbook/src/plugin.spec.ts new file mode 100644 index 00000000..5b06cc34 --- /dev/null +++ b/plugins/export-workbook/src/plugin.spec.ts @@ -0,0 +1,471 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Flatfile } from '@flatfile/api' +import type { FlatfileEvent } from '@flatfile/listener' + +// --------------------------------------------------------------------------- +// Mocks — declared before any imports that trigger plugin.ts resolution +// --------------------------------------------------------------------------- + +let mockRecords: any[] = [] + +vi.mock('@flatfile/util-common', () => ({ + processRecords: vi.fn(async (_sheetId: string, callback: Function) => { + const result = await callback(mockRecords) + return [result] + }), + logError: vi.fn(), + logInfo: vi.fn(), +})) + +vi.mock('@flatfile/api', async () => { + const actual = await vi.importActual('@flatfile/api') + return { + ...actual, + FlatfileClient: vi.fn().mockImplementation(() => ({ + workbooks: { + get: vi.fn().mockResolvedValue({ data: { name: 'Test Workbook' } }), + }, + sheets: { + list: vi.fn().mockImplementation(() => + Promise.resolve({ data: mockSheets }) + ), + }, + files: { + upload: vi.fn().mockResolvedValue({ data: { id: 'us_fl_test' } }), + }, + })), + } +}) + +vi.mock('xlsx', async () => { + const actualXLSX = await vi.importActual('xlsx') + return { + ...actualXLSX, + default: { + ...actualXLSX, + utils: { + ...actualXLSX.utils, + json_to_sheet: vi.fn((...args: any[]) => { + jsonToSheetCalls.push({ rows: args[0], opts: args[1] }) + return actualXLSX.utils.json_to_sheet(...args) + }), + book_new: vi.fn(() => ({ SheetNames: ['s'], Sheets: { s: {} } })), + book_append_sheet: vi.fn(), + }, + set_fs: vi.fn(), + writeFile: vi.fn(), + }, + utils: { + ...actualXLSX.utils, + json_to_sheet: vi.fn((...args: any[]) => { + jsonToSheetCalls.push({ rows: args[0], opts: args[1] }) + return actualXLSX.utils.json_to_sheet(...args) + }), + book_new: vi.fn(() => ({ SheetNames: ['s'], Sheets: { s: {} } })), + book_append_sheet: vi.fn(), + }, + set_fs: vi.fn(), + writeFile: vi.fn(), + } +}) + +vi.mock('node:fs', () => ({ + default: { + createReadStream: vi.fn().mockReturnValue({ close: vi.fn() }), + promises: { unlink: vi.fn().mockResolvedValue(undefined) }, + }, + createReadStream: vi.fn().mockReturnValue({ close: vi.fn() }), + promises: { unlink: vi.fn().mockResolvedValue(undefined) }, +})) + +// --------------------------------------------------------------------------- +// Now import the module under test (mocks are already in place) +// --------------------------------------------------------------------------- + +import { exportRecords } from './plugin' +import type { PluginOptions } from './options' + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- + +const jsonToSheetCalls: { rows: any[]; opts?: any }[] = [] +const mockSheets: any[] = [] + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeEvent(): FlatfileEvent { + return { + context: { + environmentId: 'us_env_test', + spaceId: 'us_sp_test', + workbookId: 'us_wb_test', + }, + } as unknown as FlatfileEvent +} + +function cell( + value: any, + messages: any[] = [] +): Flatfile.CellValue { + return { value, messages, valid: true } +} + +function record( + id: string, + values: Record +): Flatfile.RecordWithLinks { + return { id, values, valid: true } as Flatfile.RecordWithLinks +} + +const tick = vi.fn().mockResolvedValue(undefined) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('exportRecords — column ordering', () => { + beforeEach(() => { + jsonToSheetCalls.length = 0 + mockSheets.length = 0 + mockRecords = [] + vi.clearAllMocks() + }) + + it('exports columns in blueprint field order, not API key order', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [ + { key: 'A', type: 'string', label: 'A' }, + { key: 'B', type: 'string', label: 'B' }, + { key: 'C', type: 'string', label: 'C' }, + ], + }, + }) + + // API returns keys in a different order: C, A, B + mockRecords = [ + record('rec_1', { + C: cell('c1'), + A: cell('a1'), + B: cell('b1'), + }), + ] + + await exportRecords(makeEvent(), {}, tick) + + expect(jsonToSheetCalls).toHaveLength(1) + const { rows, opts } = jsonToSheetCalls[0] + + expect(opts?.header).toEqual(['A', 'B', 'C']) + expect(Object.keys(rows[0])).toEqual(['A', 'B', 'C']) + }) + + it('applies columnNameTransformer and preserves blueprint order in header', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [ + { key: 'first_name', type: 'string', label: 'First Name' }, + { key: 'last_name', type: 'string', label: 'Last Name' }, + { key: 'email', type: 'string', label: 'Email' }, + ], + }, + }) + + mockRecords = [ + record('rec_1', { + email: cell('bob@test.com'), + first_name: cell('Bob'), + last_name: cell('Smith'), + }), + ] + + const transformer: PluginOptions['columnNameTransformer'] = async (name) => + name.toUpperCase() + + await exportRecords( + makeEvent(), + { columnNameTransformer: transformer }, + tick + ) + + const { opts, rows } = jsonToSheetCalls[0] + expect(opts?.header).toEqual(['FIRST_NAME', 'LAST_NAME', 'EMAIL']) + expect(Object.keys(rows[0])).toEqual(['FIRST_NAME', 'LAST_NAME', 'EMAIL']) + }) + + it('deduplicates header when transformer produces duplicate names', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [ + { key: 'id', type: 'string', label: 'Employee ID' }, + { key: 'name', type: 'string', label: 'Name' }, + { key: 'bank.id', type: 'string', label: 'Employee ID' }, + ], + }, + }) + + mockRecords = [ + record('rec_1', { + id: cell('EMP_01'), + name: cell('Bob'), + 'bank.id': cell('BANK_01'), + }), + ] + + const labels: Record = { + id: 'Employee ID', + name: 'Name', + 'bank.id': 'Employee ID', + } + const transformer: PluginOptions['columnNameTransformer'] = async (key) => + labels[key] ?? key + + await exportRecords( + makeEvent(), + { columnNameTransformer: transformer }, + tick + ) + + const { opts } = jsonToSheetCalls[0] + expect(opts?.header).toEqual(['Employee ID', 'Name']) + }) + + it('appends non-blueprint fields after blueprint columns', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [ + { key: 'A', type: 'string', label: 'A' }, + { key: 'B', type: 'string', label: 'B' }, + ], + }, + }) + + mockRecords = [ + record('rec_1', { + EXTRA: cell('extra_val'), + A: cell('a1'), + B: cell('b1'), + }), + ] + + await exportRecords(makeEvent(), {}, tick) + + const { rows, opts } = jsonToSheetCalls[0] + // Note: SheetJS mutates the header array in-place, appending extra keys + // found in the row objects. So after the call the header contains all + // columns. What matters is that blueprint fields come first. + expect(opts?.header?.slice(0, 2)).toEqual(['A', 'B']) + expect(opts?.header).toContain('EXTRA') + // Row keys: blueprint order first, then extras + expect(Object.keys(rows[0])).toEqual(['A', 'B', 'EXTRA']) + }) + + it('excludes fields listed in excludeFields from rows and header', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [ + { key: 'A', type: 'string', label: 'A' }, + { key: 'secret', type: 'string', label: 'Secret' }, + { key: 'B', type: 'string', label: 'B' }, + ], + }, + }) + + mockRecords = [ + record('rec_1', { + A: cell('a1'), + secret: cell('hidden'), + B: cell('b1'), + }), + ] + + await exportRecords(makeEvent(), { excludeFields: ['secret'] }, tick) + + const { opts, rows } = jsonToSheetCalls[0] + expect(opts?.header).toEqual(['A', 'B']) + expect(Object.keys(rows[0])).toEqual(['A', 'B']) + }) + + it('prepends recordId column when includeRecordIds is true', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [ + { key: 'A', type: 'string', label: 'A' }, + { key: 'B', type: 'string', label: 'B' }, + ], + }, + }) + + mockRecords = [ + record('rec_1', { + B: cell('b1'), + A: cell('a1'), + }), + ] + + await exportRecords(makeEvent(), { includeRecordIds: true }, tick) + + const { opts, rows } = jsonToSheetCalls[0] + expect(opts?.header).toEqual(['recordId', 'A', 'B']) + expect(Object.keys(rows[0])).toEqual(['recordId', 'A', 'B']) + expect(rows[0].recordId).toBe('rec_1') + }) + + it('handles CellValue with missing messages property', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [{ key: 'A', type: 'string', label: 'A' }], + }, + }) + + // CellValue without messages — valid per Flatfile.CellValue type + mockRecords = [ + record('rec_1', { + A: { value: 'hello', valid: true } as Flatfile.CellValue, + }), + ] + + await expect(exportRecords(makeEvent(), {}, tick)).resolves.not.toThrow() + + const { rows } = jsonToSheetCalls[0] + expect(rows[0].A.v).toBe('hello') + }) + + it('handles blueprint field absent from record values', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [ + { key: 'A', type: 'string', label: 'A' }, + { key: 'B', type: 'string', label: 'B' }, + { key: 'C', type: 'string', label: 'C' }, + ], + }, + }) + + // B is missing from the record + mockRecords = [ + record('rec_1', { + A: cell('a1'), + C: cell('c1'), + }), + ] + + await exportRecords(makeEvent(), {}, tick) + + const { rows, opts } = jsonToSheetCalls[0] + // Header still lists all three + expect(opts?.header).toEqual(['A', 'B', 'C']) + // Row only has A and C (B was absent) + expect(Object.keys(rows[0])).toEqual(['A', 'C']) + }) + + it('handles CellValue with null value', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [ + { key: 'A', type: 'string', label: 'A' }, + { key: 'B', type: 'string', label: 'B' }, + ], + }, + }) + + mockRecords = [ + record('rec_1', { + A: cell('a1'), + B: cell(null), + }), + ] + + await exportRecords(makeEvent(), {}, tick) + + const { rows } = jsonToSheetCalls[0] + expect(Object.keys(rows[0])).toEqual(['A', 'B']) + expect(rows[0].B.v).toBeNull() + }) + + it('handles array values (enum-list)', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [{ key: 'tags', type: 'enum-list', label: 'Tags' }], + }, + }) + + mockRecords = [ + record('rec_1', { + tags: cell(['red', 'blue', 'green']), + }), + ] + + await exportRecords(makeEvent(), {}, tick) + + const { rows } = jsonToSheetCalls[0] + expect(rows[0].tags.v).toBe('red, blue, green') + }) + + it('excludes non-blueprint extra fields that are in excludeFields', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [{ key: 'A', type: 'string', label: 'A' }], + }, + }) + + mockRecords = [ + record('rec_1', { + A: cell('a1'), + EXTRA: cell('should_be_excluded'), + }), + ] + + await exportRecords(makeEvent(), { excludeFields: ['EXTRA'] }, tick) + + const { rows } = jsonToSheetCalls[0] + expect(Object.keys(rows[0])).toEqual(['A']) + }) +}) diff --git a/plugins/export-workbook/src/plugin.ts b/plugins/export-workbook/src/plugin.ts index a9809541..e1cf27a8 100644 --- a/plugins/export-workbook/src/plugin.ts +++ b/plugins/export-workbook/src/plugin.ts @@ -105,7 +105,7 @@ export const exportRecords = async ( const { id: recordId, values: row } = record const formatCell = (cellValue: Flatfile.CellValue) => { - const { value, messages } = cellValue + const { value, messages = [] } = cellValue const cell: XLSX.CellObject = { t: 's', v: Array.isArray(value) ? value.join(', ') : value, From 9ac42101c3254556b6fcfb748278a60d6f1a08c7 Mon Sep 17 00:00:00 2001 From: Christopher Harrison Date: Thu, 12 Feb 2026 14:22:37 -0500 Subject: [PATCH 3/5] test(export-workbook): add empty sheet, multi-row, transformer fallback, and message tests - Empty sheet produces header row in blueprint order - All rows maintain blueprint order, not just the first - columnNameTransformer returning null falls back to original key - excludeMessages suppresses cell comments - Validation messages included as cell comments by default --- plugins/export-workbook/src/plugin.spec.ts | 147 +++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/plugins/export-workbook/src/plugin.spec.ts b/plugins/export-workbook/src/plugin.spec.ts index 5b06cc34..d58e3438 100644 --- a/plugins/export-workbook/src/plugin.spec.ts +++ b/plugins/export-workbook/src/plugin.spec.ts @@ -468,4 +468,151 @@ describe('exportRecords — column ordering', () => { const { rows } = jsonToSheetCalls[0] expect(Object.keys(rows[0])).toEqual(['A']) }) + + it('empty sheet uses blueprint order for header row', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [ + { key: 'X', type: 'string', label: 'X' }, + { key: 'Y', type: 'string', label: 'Y' }, + { key: 'Z', type: 'string', label: 'Z' }, + ], + }, + }) + + // No records at all + mockRecords = [] + + await exportRecords(makeEvent(), {}, tick) + + const { rows, opts } = jsonToSheetCalls[0] + // Should still produce one row with empty cells in blueprint order + expect(Object.keys(rows[0])).toEqual(['X', 'Y', 'Z']) + expect(opts?.header).toEqual(['X', 'Y', 'Z']) + }) + + it('all rows maintain blueprint column order, not just the first', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [ + { key: 'A', type: 'string', label: 'A' }, + { key: 'B', type: 'string', label: 'B' }, + { key: 'C', type: 'string', label: 'C' }, + ], + }, + }) + + // Two records with different API key orders + mockRecords = [ + record('rec_1', { + C: cell('c1'), + A: cell('a1'), + B: cell('b1'), + }), + record('rec_2', { + B: cell('b2'), + C: cell('c2'), + A: cell('a2'), + }), + ] + + await exportRecords(makeEvent(), {}, tick) + + const { rows } = jsonToSheetCalls[0] + expect(Object.keys(rows[0])).toEqual(['A', 'B', 'C']) + expect(Object.keys(rows[1])).toEqual(['A', 'B', 'C']) + }) + + it('columnNameTransformer returning null falls back to original key', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [ + { key: 'A', type: 'string', label: 'A' }, + { key: 'B', type: 'string', label: 'B' }, + ], + }, + }) + + mockRecords = [ + record('rec_1', { + A: cell('a1'), + B: cell('b1'), + }), + ] + + // Transformer returns null for 'A', should fall back to 'A' + const transformer: PluginOptions['columnNameTransformer'] = async (key) => + key === 'A' ? null : 'Bravo' + + await exportRecords( + makeEvent(), + { columnNameTransformer: transformer }, + tick + ) + + const { opts } = jsonToSheetCalls[0] + expect(opts?.header).toEqual(['A', 'Bravo']) + }) + + it('excludeMessages suppresses cell comments', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [{ key: 'A', type: 'string', label: 'A' }], + }, + }) + + mockRecords = [ + record('rec_1', { + A: cell('a1', [{ type: 'error', message: 'bad', source: 'custom' }]), + }), + ] + + await exportRecords(makeEvent(), { excludeMessages: true }, tick) + + const { rows } = jsonToSheetCalls[0] + expect(rows[0].A.c).toEqual([]) + }) + + it('validation messages are included as cell comments by default', async () => { + mockSheets.push({ + id: 'us_sh_1', + name: 'Sheet1', + config: { + name: 'Sheet1', + slug: 'sheet1', + fields: [{ key: 'A', type: 'string', label: 'A' }], + }, + }) + + mockRecords = [ + record('rec_1', { + A: cell('a1', [ + { type: 'error', message: 'is required', source: 'custom' }, + ]), + }), + ] + + await exportRecords(makeEvent(), {}, tick) + + const { rows } = jsonToSheetCalls[0] + expect(rows[0].A.c).toHaveLength(1) + expect(rows[0].A.c[0].t).toBe('[ERROR]: is required') + expect(rows[0].A.c.hidden).toBe(true) + }) }) From 8ef8d2522755bcc039c532ee5ea8ffe6cb1a64ed Mon Sep 17 00:00:00 2001 From: Christopher Harrison Date: Thu, 12 Feb 2026 14:23:43 -0500 Subject: [PATCH 4/5] chore: remove customer-specific references from tests and comments --- plugins/export-workbook/src/plugin.spec.ts | 14 +++++++------- plugins/export-workbook/src/plugin.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/export-workbook/src/plugin.spec.ts b/plugins/export-workbook/src/plugin.spec.ts index d58e3438..62240f08 100644 --- a/plugins/export-workbook/src/plugin.spec.ts +++ b/plugins/export-workbook/src/plugin.spec.ts @@ -212,25 +212,25 @@ describe('exportRecords — column ordering', () => { name: 'Sheet1', slug: 'sheet1', fields: [ - { key: 'id', type: 'string', label: 'Employee ID' }, + { key: 'primary_id', type: 'string', label: 'ID' }, { key: 'name', type: 'string', label: 'Name' }, - { key: 'bank.id', type: 'string', label: 'Employee ID' }, + { key: 'secondary_id', type: 'string', label: 'ID' }, ], }, }) mockRecords = [ record('rec_1', { - id: cell('EMP_01'), + primary_id: cell('P1'), name: cell('Bob'), - 'bank.id': cell('BANK_01'), + secondary_id: cell('S1'), }), ] const labels: Record = { - id: 'Employee ID', + primary_id: 'ID', name: 'Name', - 'bank.id': 'Employee ID', + secondary_id: 'ID', } const transformer: PluginOptions['columnNameTransformer'] = async (key) => labels[key] ?? key @@ -242,7 +242,7 @@ describe('exportRecords — column ordering', () => { ) const { opts } = jsonToSheetCalls[0] - expect(opts?.header).toEqual(['Employee ID', 'Name']) + expect(opts?.header).toEqual(['ID', 'Name']) }) it('appends non-blueprint fields after blueprint columns', async () => { diff --git a/plugins/export-workbook/src/plugin.ts b/plugins/export-workbook/src/plugin.ts index e1cf27a8..bb76589d 100644 --- a/plugins/export-workbook/src/plugin.ts +++ b/plugins/export-workbook/src/plugin.ts @@ -76,7 +76,7 @@ export const exportRecords = async ( // Pre-compute the transformed header names in blueprint order so // we can pass them to json_to_sheet as an explicit `header` array. // When a columnNameTransformer produces duplicate names (e.g. two - // different field keys both labelled "Employee ID"), we must keep + // different field keys that map to the same label), we must keep // only the *first* occurrence. Object.fromEntries used below to // build each row already collapses duplicate keys (last-wins), so // a second header entry with no matching data would create a ghost From 69453bd1b33d4beb965ce9d4d1ee9a34119ebd56 Mon Sep 17 00:00:00 2001 From: Christopher Harrison Date: Thu, 12 Feb 2026 14:37:48 -0500 Subject: [PATCH 5/5] style: fix prettier formatting in plugin.spec.ts --- plugins/export-workbook/src/plugin.spec.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/plugins/export-workbook/src/plugin.spec.ts b/plugins/export-workbook/src/plugin.spec.ts index 62240f08..170837c9 100644 --- a/plugins/export-workbook/src/plugin.spec.ts +++ b/plugins/export-workbook/src/plugin.spec.ts @@ -26,9 +26,9 @@ vi.mock('@flatfile/api', async () => { get: vi.fn().mockResolvedValue({ data: { name: 'Test Workbook' } }), }, sheets: { - list: vi.fn().mockImplementation(() => - Promise.resolve({ data: mockSheets }) - ), + list: vi + .fn() + .mockImplementation(() => Promise.resolve({ data: mockSheets })), }, files: { upload: vi.fn().mockResolvedValue({ data: { id: 'us_fl_test' } }), @@ -106,10 +106,7 @@ function makeEvent(): FlatfileEvent { } as unknown as FlatfileEvent } -function cell( - value: any, - messages: any[] = [] -): Flatfile.CellValue { +function cell(value: any, messages: any[] = []): Flatfile.CellValue { return { value, messages, valid: true } }