diff --git a/examples/sf-specific/deploy.ts b/examples/sf-specific/deploy.ts index c471176..ada4c90 100644 --- a/examples/sf-specific/deploy.ts +++ b/examples/sf-specific/deploy.ts @@ -941,7 +941,7 @@ const deployResult = [ ] const deploy: TableOptions<(typeof deployResult)[number]> = { - borderStyle: 'vertical', + borderStyle: 'vertical-with-outline', columns: [ 'state', 'fullName', @@ -949,15 +949,15 @@ const deploy: TableOptions<(typeof deployResult)[number]> = { { key: 'filePath', name: 'Path', - overflow: 'truncate', + overflow: 'wrap', }, ], data: deployResult, filter: (row) => row.state === 'Changed' && row.type.startsWith('A'), headerOptions: { - color: 'white', + // color: 'white', formatter: 'capitalCase', - inverse: true, + // inverse: true, }, maxWidth: '100%', overflow: 'truncate', diff --git a/examples/sf-specific/org-list.ts b/examples/sf-specific/org-list.ts new file mode 100644 index 0000000..ffd8e32 --- /dev/null +++ b/examples/sf-specific/org-list.ts @@ -0,0 +1,168 @@ +import {printTable} from '../../src' +const data = [ + { + alias: 'devhub', + connectedStatus: 'Connected', + defaultMarker: undefined, + instanceApiVersion: '62.0', + instanceApiVersionLastRetrieved: '10/9/2024, 12:44:28 PM', + instanceUrl: 'https://su0503.my.salesforce.com', + isDefaultDevHubUsername: false, + isDefaultUsername: false, + isDevHub: true, + lastUsed: '2024-10-09T18:44:31.494Z', + loginUrl: 'https://login.salesforce.com', + orgId: '00DB0000000Ih65MAC', + timestamp: '2022-05-10T19:26:45.436Z', + type: 'DevHub', + username: 'md@su-blitz.org', + }, + { + alias: 'na40devhub', + connectedStatus: 'Connected', + defaultMarker: '🌳', + instanceApiVersion: '61.0', + instanceApiVersionLastRetrieved: '10/9/2024, 12:44:29 PM', + instanceUrl: 'https://na40-dev-hub.my.salesforce.com', + isDefaultDevHubUsername: true, + isDefaultUsername: false, + isDevHub: true, + lastUsed: '2024-10-09T18:44:31.495Z', + loginUrl: 'https://login.salesforce.com', + orgId: '00D460000019MkyEAE', + privateKey: '/Users/mdonnalley/secrets/jwt/na40.key', + type: 'DevHub', + username: 'admin@integrationtesthubna40.org', + }, + { + alias: undefined, + connectedStatus: + 'Unable to refresh session due to: Error authenticating with the refresh token due to: expired access/refresh token', + defaultMarker: undefined, + instanceApiVersion: '62.0', + instanceApiVersionLastRetrieved: '10/9/2024, 12:44:28 PM', + instanceUrl: 'https://cristianalexisdominguez-devhub.my.salesforce.com', + isDefaultDevHubUsername: false, + isDefaultUsername: false, + isDevHub: true, + lastUsed: '2024-10-09T18:44:29.234Z', + loginUrl: 'https://cristianalexisdominguez-devhub.my.salesforce.com/', + orgId: '00DB00000006Mq3MAE', + type: 'DevHub', + username: 'cdominguez@gs0-dev-hub-salesforce.com', + }, + { + alias: undefined, + connectedStatus: + 'Unable to refresh session due to: Error authenticating with the refresh token due to: expired access/refresh token', + defaultMarker: undefined, + instanceApiVersion: '62.0', + instanceApiVersionLastRetrieved: '10/9/2024, 12:44:28 PM', + instanceUrl: 'https://su0503.my.salesforce.com', + isDefaultDevHubUsername: false, + isDefaultUsername: false, + isDevHub: true, + lastUsed: '2024-10-09T18:44:29.986Z', + loginUrl: 'https://login.salesforce.com', + orgId: '00DB0000000Ih65MAC', + type: 'DevHub', + username: 'shetzel@gs0.org', + }, + { + alias: undefined, + connectedStatus: 'Connected', + defaultMarker: undefined, + instanceApiVersion: '62.0', + instanceApiVersionLastRetrieved: '10/9/2024, 12:44:28 PM', + instanceUrl: 'https://su0503--sbxgs01.sandbox.my.salesforce.com', + isDefaultDevHubUsername: false, + isDefaultUsername: false, + isDevHub: false, + isSandbox: true, + isScratch: false, + lastUsed: '2024-10-09T18:47:21.186Z', + loginUrl: 'https://test.salesforce.com', + orgId: '00D3I0000008poXUAQ', + tracksSource: false, + type: 'Sandbox', + username: 'shetzel@gs0.org.sbxgs01', + }, + { + alias: 'ink', + connectedStatus: 'Active', + created: '1728401061000', + createdBy: 'md@su-blitz.org', + createdDate: '2024-10-08T15:24:21.000+0000', + createdOrgInstance: 'USA256S', + defaultMarker: '🍁', + devHubId: '00DB0000000Ih65MAC', + devHubOrgId: '00DB0000000Ih65MAC', + devHubUsername: 'md@su-blitz.org', + edition: 'Developer', + expirationDate: '2024-10-09', + instanceApiVersion: '62.0', + instanceApiVersionLastRetrieved: '10/9/2024, 12:23:19 PM', + instanceUrl: 'https://nosoftware-platform-8292-dev-ed.scratch.my.salesforce.com', + isDefaultDevHubUsername: false, + isDefaultUsername: true, + isDevHub: false, + isExpired: false, + isSandbox: false, + isScratch: true, + lastUsed: '2024-10-09T18:44:31.494Z', + loginUrl: 'https://nosoftware-platform-8292-dev-ed.scratch.my.salesforce.com', + namespace: null, + orgId: '00DO2000004SuOLMA0', + orgName: 'Company', + signupUsername: 'test-1yomelh1c0ha@example.com', + status: 'Active', + tracksSource: true, + type: 'Scratch', + username: 'test-1yomelh1c0ha@example.com', + }, +] + +printTable({ + borderStyle: 'vertical-with-outline', + columns: [ + { + key: 'defaultMarker', + name: ' ', + }, + 'type', + 'alias', + 'username', + { + key: 'instanceUrl', + name: 'Instance URL', + }, + { + key: 'orgId', + name: 'Org ID', + }, + { + key: 'connectedStatus', + name: 'Status', + }, + 'namespace', + { + key: 'devHubId', + name: 'Devhub ID', + }, + { + key: 'createdDate', + name: 'Created', + }, + { + key: 'expirationDate', + name: 'Expires', + }, + ], + data, + headerOptions: { + formatter: 'capitalCase', + }, + maxWidth: '100%', + overflow: 'wrap', + verticalAlignment: 'center', +}) diff --git a/examples/very-tall.ts b/examples/very-tall.ts new file mode 100644 index 0000000..5c27174 --- /dev/null +++ b/examples/very-tall.ts @@ -0,0 +1,15 @@ +import {printTable} from '../src/index.js' + +const height = 100_000 +const data = Array.from({length: height}, (_, i) => ({age: i, name: `Foo ${i}`})) + +printTable({ + columns: ['name', 'age'], + data, + headerOptions: { + formatter: 'capitalCase', + }, + horizontalAlignment: 'center', + title: 'Very Tall', + titleOptions: {bold: true}, +}) diff --git a/src/table.tsx b/src/table.tsx index 0d54e32..292bdbf 100644 --- a/src/table.tsx +++ b/src/table.tsx @@ -184,7 +184,7 @@ export function formatTextWithMargins({ } } -export function Table>(props: TableOptions) { +function setup>(props: TableOptions) { const { data, filter, @@ -219,6 +219,12 @@ export function Table>(props: TableOptions) const headings = getHeadings(config) const columns = getColumns(config, headings) + // check for duplicate columns + const columnKeys = columns.map((c) => c.key) + const duplicates = columnKeys.filter((c, i) => columnKeys.indexOf(c) !== i) + if (duplicates.length > 0) { + throw new Error(`Duplicate columns found: ${duplicates.join(', ')}`) + } const dataComponent = row({ borderProps, @@ -264,6 +270,38 @@ export function Table>(props: TableOptions) skeleton: BORDER_SKELETONS[config.borderStyle].separator, }) + return { + columns, + config, + dataComponent, + footerComponent, + headerComponent, + headerFooterComponent, + headingComponent, + headings, + processedData, + separatorComponent, + title, + titleOptions, + } +} + +export function Table>(props: TableOptions) { + const { + columns, + config, + dataComponent, + footerComponent, + headerComponent, + headerFooterComponent, + headingComponent, + headings, + processedData, + separatorComponent, + title, + titleOptions, + } = setup(props) + return ( {title && {title}} @@ -395,7 +433,12 @@ class Stream extends WriteStream { private frames: string[] = [] public lastFrame(): string | undefined { - return this.frames.filter((f) => stripAnsi(f) !== '').at(-1) + return this.frames + .filter((f) => { + const stripped = stripAnsi(f) + return stripped !== '' && stripped !== '\n' + }) + .at(-1) } write(data: string): boolean { @@ -413,18 +456,93 @@ class Output { public maybePrintLastFrame() { if (this.stream instanceof Stream) { - process.stdout.write(`${this.stream.lastFrame()}\n`) + process.stdout.write(`${this.stream.lastFrame()}`) } else { process.stdout.write('\n') } } } +function chunk(array: T[], size: number): T[][] { + return array.reduce((acc, _, i) => { + if (i % size === 0) acc.push(array.slice(i, i + size)) + return acc + }, [] as T[][]) +} + +function renderTableInChunks>(props: TableOptions): void { + const { + columns, + config, + dataComponent, + footerComponent, + headerComponent, + headerFooterComponent, + headingComponent, + headings, + processedData, + separatorComponent, + title, + titleOptions, + } = setup(props) + + const headerOutput = new Output() + const headerInstance = render( + + {title && {title}} + {headerComponent({columns, data: {}, key: 'header'})} + {headingComponent({columns, data: headings, key: 'heading'})} + {headerFooterComponent({columns, data: {}, key: 'footer'})} + , + {stdout: headerOutput.stream}, + ) + headerInstance.unmount() + headerOutput.maybePrintLastFrame() + + const chunks = chunk(processedData, Math.max(1, Math.floor(process.stdout.rows / 2))) + for (const chunk of chunks) { + const chunkOutput = new Output() + const instance = render( + + {chunk.map((row, index) => { + // Calculate the hash of the row based on its value and position + const key = `row-${sha1(row)}-${index}` + // Construct a row. + return ( + + {separatorComponent({columns, data: {}, key: `separator-${key}`})} + {dataComponent({columns, data: row, key: `data-${key}`})} + + ) + })} + , + {stdout: chunkOutput.stream}, + ) + instance.unmount() + chunkOutput.maybePrintLastFrame() + } + + const footerOutput = new Output() + const footerInstance = render( + + {footerComponent({columns, data: {}, key: 'footer'})} + , + {stdout: footerOutput.stream}, + ) + footerInstance.unmount() + footerOutput.maybePrintLastFrame() +} + /** * Renders a table with the given data. * @param options see {@link TableOptions} */ export function printTable>(options: TableOptions): void { + if (options.data.length > 50_000) { + renderTableInChunks(options) + return + } + const output = new Output() const instance = render(, {stdout: output.stream}) instance.unmount() @@ -443,6 +561,10 @@ export function printTables[]>( tables: {[P in keyof T]: TableOptions}, options?: Omit, ): void { + if (tables.reduce((acc, table) => acc + table.data.length, 0) > 30_000) { + throw new Error('The data is too large to print multiple tables. Please use `printTable` instead.') + } + const output = new Output() const leftMargin = options?.marginLeft ?? options?.margin ?? 0 const rightMargin = options?.marginRight ?? options?.margin ?? 0 diff --git a/src/utils.ts b/src/utils.ts index cfdd062..b431b18 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -83,7 +83,7 @@ export function getColumns>(config: Config, const numberOfBorders = widths.length + 1 const calculateTableWidth = (widths: Column[]) => - widths.map((w) => w.width).reduce((a, b) => a + b) + numberOfBorders + widths.map((w) => w.width + w.padding * 2).reduce((a, b) => a + b) + numberOfBorders // If the table is too wide, reduce the width of the largest column as little as possible to fit the table. // At most, it will reduce the width to the length of the column's header plus padding. @@ -91,7 +91,9 @@ export function getColumns>(config: Config, let tableWidth = calculateTableWidth(widths) const seen = new Set() while (tableWidth > maxWidth) { - const largestColumn = widths.reduce((a, b) => (a.width > b.width ? a : b)) + const largestColumn = widths.filter((w) => !seen.has(w.key)).sort((a, b) => b.width - a.width)[0] + if (!largestColumn) break + if (seen.has(largestColumn.key)) break const header = String(headings[largestColumn.key]).length // The minimum width of a column is the width of the header plus padding on both sides const minWidth = header + largestColumn.padding * 2 @@ -99,7 +101,6 @@ export function getColumns>(config: Config, const newWidth = largestColumn.width - difference < minWidth ? minWidth : largestColumn.width - difference largestColumn.width = newWidth tableWidth = calculateTableWidth(widths) - if (seen.has(largestColumn.key)) break seen.add(largestColumn.key) } diff --git a/test/table.test.tsx b/test/table.test.tsx index d5aeec8..d063251 100644 --- a/test/table.test.tsx +++ b/test/table.test.tsx @@ -374,49 +374,49 @@ describe('Table', () => { {skeleton('┌')} {skeleton('──────')} {skeleton('┬')} - {skeleton('─────────────────────')} + {skeleton('─────────────────')} {skeleton('┐')} {skeleton('│')} {header(' name ')} {skeleton('│')} - {header(' id ')} + {header(' id ')} {skeleton('│')} {skeleton('├')} {skeleton('──────')} {skeleton('┼')} - {skeleton('─────────────────────')} + {skeleton('─────────────────')} {skeleton('┤')} {skeleton('│')} {cell(' Foo ')} {skeleton('│')} - {cell(' iiiiiiiiiiiiiiiiii… ')} + {cell(' iiiiiiiiiiiiii… ')} {skeleton('│')} {skeleton('├')} {skeleton('──────')} {skeleton('┼')} - {skeleton('─────────────────────')} + {skeleton('─────────────────')} {skeleton('┤')} {skeleton('│')} {cell(' Bar ')} {skeleton('│')} - {cell(' iiiiiiiiiiiiiiiiii… ')} + {cell(' iiiiiiiiiiiiii… ')} {skeleton('│')} {skeleton('└')} {skeleton('──────')} {skeleton('┴')} - {skeleton('─────────────────────')} + {skeleton('─────────────────')} {skeleton('┘')} , @@ -439,91 +439,105 @@ describe('Table', () => { {skeleton('┌')} {skeleton('──────')} {skeleton('┬')} - {skeleton('─────────────────────')} + {skeleton('─────────────────')} {skeleton('┐')} {skeleton('│')} {header(' name ')} {skeleton('│')} - {header(' id ')} + {header(' id ')} {skeleton('│')} {skeleton('├')} {skeleton('──────')} {skeleton('┼')} - {skeleton('─────────────────────')} + {skeleton('─────────────────')} {skeleton('┤')} {skeleton('│')} {cell(' Foo ')} {skeleton('│')} - {cell(' iiiiiiiiiiiiiiiiiii ')} + {cell(' iiiiiiiiiiiiiii ')} {skeleton('│')} {skeleton('│')} {cell(' ')} {skeleton('│')} - {cell(' iiiiiiiiiiiiiiiiiii ')} + {cell(' iiiiiiiiiiiiiii ')} {skeleton('│')} {skeleton('│')} {cell(' ')} {skeleton('│')} - {cell(' iiiiiiiiiiiiiiiiiii ')} + {cell(' iiiiiiiiiiiiiii ')} {skeleton('│')} {skeleton('│')} {cell(' ')} {skeleton('│')} - {cell(' iiiiiiiiiiiii ')} + {cell(' iiiiiiiiiiiiiii ')} + {skeleton('│')} + + + {skeleton('│')} + {cell(' ')} + {skeleton('│')} + {cell(' iiiiiiiiii ')} {skeleton('│')} {skeleton('├')} {skeleton('──────')} {skeleton('┼')} - {skeleton('─────────────────────')} + {skeleton('─────────────────')} {skeleton('┤')} {skeleton('│')} {cell(' Bar ')} {skeleton('│')} - {cell(' iiiiiiiiiiiiiiiiiii ')} + {cell(' iiiiiiiiiiiiiii ')} + {skeleton('│')} + + + {skeleton('│')} + {cell(' ')} + {skeleton('│')} + {cell(' iiiiiiiiiiiiiii ')} {skeleton('│')} {skeleton('│')} {cell(' ')} {skeleton('│')} - {cell(' iiiiiiiiiiiiiiiiiii ')} + {cell(' iiiiiiiiiiiiiii ')} {skeleton('│')} {skeleton('│')} {cell(' ')} {skeleton('│')} - {cell(' iiiiiiiiiiiiiiiiiii ')} + {cell(' iiiiiiiiiiiiiii ')} {skeleton('│')} {skeleton('│')} {cell(' ')} {skeleton('│')} - {cell(' iiiiiiiiiiiii ')} + {cell(' iiiiiiiiii ')} {skeleton('│')} {skeleton('└')} {skeleton('──────')} {skeleton('┴')} - {skeleton('─────────────────────')} + {skeleton('─────────────────')} {skeleton('┘')} ,