diff --git a/examples/basic.ts b/examples/basic.ts index a80408f..67a09ba 100644 --- a/examples/basic.ts +++ b/examples/basic.ts @@ -6,7 +6,7 @@ import {makeTable} from '../src/index.js' const data = [ { age: 20, - bigData: 'a'.repeat(98), + bigData: 'a'.repeat(200), employed: ansis.bold('true'), evenMoreBigData: 'a'.repeat(130), id: terminalLink('36329', 'https://example.com/alice'), @@ -35,12 +35,20 @@ makeTable( data, [ 'id', - 'name', + {align: 'center', key: 'name', name: 'First Name'}, 'age', 'employed', 'bigData', - 'moreBigData', + // 'moreBigData', // 'evenMoreBigData', ], - {maxWidth: '50%'}, + { + borderStyle: 'outline', + headerFormatter: 'capitalCase', + headerOptions: { + bold: true, + color: '#905de8', + }, + overflow: 'wrap', + }, ) diff --git a/package.json b/package.json index 088fa0c..b98609c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dependencies": { "@oclif/core": "^4", "@types/react": "^18.3.3", + "change-case": "^5.4.4", "ink": "^5.0.1", "object-hash": "^3.0.0", "react": "^18.3.1", diff --git a/src/skeletons.ts b/src/skeletons.ts new file mode 100644 index 0000000..e34685e --- /dev/null +++ b/src/skeletons.ts @@ -0,0 +1,251 @@ +export type BorderStyle = + | 'all' + | 'vertical' + | 'horizontal' + | 'none' + | 'outline' + | 'vertical-with-outline' + | 'horizontal-with-outline' + +type Skeleton = { + cross: string + left: string + line: string + right: string +} + +export const BORDER_SKELETONS: Record< + BorderStyle, + { + data: Skeleton + footer: Skeleton + header: Skeleton + heading: Skeleton + separator: Skeleton + } +> = { + all: { + data: { + cross: '│', + left: '│', + line: ' ', + right: '│', + }, + footer: { + cross: '┴', + left: '└', + line: '─', + right: '┘', + }, + header: { + cross: '┬', + left: '┌', + line: '─', + right: '┐', + }, + heading: { + cross: '│', + left: '│', + line: ' ', + right: '│', + }, + separator: { + cross: '┼', + left: '├', + line: '─', + right: '┤', + }, + }, + horizontal: { + data: { + cross: ' ', + left: ' ', + line: ' ', + right: ' ', + }, + footer: { + cross: '─', + left: '─', + line: '─', + right: '─', + }, + header: { + cross: ' ', + left: ' ', + line: ' ', + right: ' ', + }, + heading: { + cross: ' ', + left: ' ', + line: ' ', + right: ' ', + }, + separator: { + cross: '─', + left: '─', + line: '─', + right: '─', + }, + }, + 'horizontal-with-outline': { + data: { + cross: ' ', + left: '│', + line: ' ', + right: '│', + }, + footer: { + cross: '─', + left: '└', + line: '─', + right: '┘', + }, + header: { + cross: '─', + left: '┌', + line: '─', + right: '┐', + }, + heading: { + cross: ' ', + left: '│', + line: ' ', + right: '│', + }, + separator: { + cross: '─', + left: '├', + line: '─', + right: '┤', + }, + }, + none: { + data: { + cross: ' ', + left: ' ', + line: ' ', + right: ' ', + }, + footer: { + cross: ' ', + left: ' ', + line: ' ', + right: ' ', + }, + header: { + cross: ' ', + left: ' ', + line: ' ', + right: ' ', + }, + heading: { + cross: ' ', + left: ' ', + line: ' ', + right: ' ', + }, + separator: { + cross: ' ', + left: ' ', + line: ' ', + right: ' ', + }, + }, + outline: { + data: { + cross: ' ', + left: '│', + line: ' ', + right: '│', + }, + footer: { + cross: '─', + left: '└', + line: '─', + right: '┘', + }, + header: { + cross: '─', + left: '┌', + line: '─', + right: '┐', + }, + heading: { + cross: ' ', + left: '│', + line: ' ', + right: '│', + }, + separator: { + cross: ' ', + left: '│', + line: ' ', + right: '│', + }, + }, + vertical: { + data: { + cross: '│', + left: '│', + line: ' ', + right: '│', + }, + footer: { + cross: '│', + left: '│', + line: ' ', + right: '│', + }, + header: { + cross: ' ', + left: ' ', + line: ' ', + right: ' ', + }, + heading: { + cross: ' ', + left: ' ', + line: ' ', + right: ' ', + }, + separator: { + cross: '│', + left: '│', + line: ' ', + right: '│', + }, + }, + 'vertical-with-outline': { + data: { + cross: '│', + left: '│', + line: ' ', + right: '│', + }, + footer: { + cross: '┴', + left: '└', + line: '─', + right: '┘', + }, + header: { + cross: '┬', + left: '┌', + line: '─', + right: '┐', + }, + heading: { + cross: '│', + left: '│', + line: ' ', + right: '│', + }, + separator: { + cross: '│', + left: '│', + line: ' ', + right: '│', + }, + }, +} diff --git a/src/table.tsx b/src/table.tsx index dfb227f..55efbb9 100644 --- a/src/table.tsx +++ b/src/table.tsx @@ -1,25 +1,31 @@ /* eslint-disable react/no-unused-prop-types */ /* eslint-disable react/prop-types */ +import {camelCase, capitalCase, constantCase, kebabCase, pascalCase, sentenceCase, snakeCase} from 'change-case' import {Box, Text, render} from 'ink' import {sha1} from 'object-hash' import React from 'react' import stripAnsi from 'strip-ansi' -/* Table */ +import { BORDER_SKELETONS, BorderStyle } from './skeletons.js' + /** * Features to implement: * - [x] properly render cells with ascii characters * - [x] refactor * - [x] if table exceeds the width of the terminal, minimally truncate the largest column to fit + * - [x] wrap or truncate overflow + * - [x] builtin column header formatters (e.g. capitalize, uppercase, etc.) + * - [x] make column headers customizable + * - [x] make borders removable + * - [ ] add tests (variable height, header formatter, header options, border styles) + * + * Features to consider: * - [ ] options for sorting, filtering, showing extended columns - * - [ ] add tests - * - [ ] make colors customizable - * - [ ] make borders customizable - * - [ ] make column headers customizable - * - [ ] title - * - [ ] newline overflow - * - [ ] side by side tables??? - * - [ ] alt text for truncated cells??? + * - [ ] alt text for truncated cells + * - [ ] side by side tables + * + * NOPE + * - [ ] title - Can implement this in sf-plugins-core */ type Scalar = string | number | boolean | null | undefined @@ -35,11 +41,49 @@ export type ColumnAlignment = 'left' | 'right' | 'center' export interface ColumnProps { align?: ColumnAlignment key: T + name?: string; } export type AllColumnProps = {[K in keyof T]: ColumnProps}[keyof T] type Percentage = `${number}%`; +type HeaderFormatter = ((header: string) => string) | 'pascalCase' | 'capitalCase' | 'camelCase' | 'snakeCase' | 'kebabCase' | 'constantCase' | 'sentenceCase' + +type SupportedColors = + 'black' | + 'red' | + 'green' | + 'yellow' | + 'blue' | + 'magenta' | + 'cyan' | + 'white' | + 'gray' | + 'grey' | + 'blackBright' | + 'redBright' | + 'greenBright' | + 'yellowBright' | + 'blueBright' | + 'magentaBright' | + 'cyanBright' | + 'whiteBright' | + `#${string}` | + `rgb(${number},${number},${number})` + +type HeaderOptions = { + color?: SupportedColors + backgroundColor?: SupportedColors + bold?: boolean + dimColor?: boolean + italic?: boolean + underline?: boolean + strikethrough?: boolean + inverse?: boolean +} + + + export type TableProps = { /** * List of values (rows). @@ -63,6 +107,24 @@ export type TableProps = { * If you provide a number or percentage that is too small to fit the table, it will default to the width of the table. */ maxWidth?: Percentage | number + /** + * Overflow behavior for cells. Defaults to 'truncate'. + */ + overflow?: 'wrap' | 'truncate' + /** + * Column header formatter. Can either be a function or a method name on the `change-case` library. + * + * See https://www.npmjs.com/package/change-case for more information. + */ + headerFormatter?: HeaderFormatter + /** + * Styling options for the column headers + */ + headerOptions?: HeaderOptions + /** + * Border style for the table. Defaults to 'all'. + */ + borderStyle?: BorderStyle } type Config = { @@ -70,12 +132,15 @@ type Config = { data: T[] padding: number maxWidth: number + overflow: 'wrap' | 'truncate' + headerFormatter: HeaderFormatter | undefined + headerOptions: HeaderOptions + borderStyle: BorderStyle } const getDataKeys = (data: T[]): (keyof T)[] => { const keys = new Set() - // Collect all the keys. for (const row of data) { for (const key in row) { if (key in row) { @@ -92,10 +157,10 @@ const getColumns = (config: Config): Column[] => { const widths: Column[] = columns.map((propsOrKey) => { const props: ColumnProps = typeof propsOrKey === 'object' ? propsOrKey : {align: 'left', key: propsOrKey} - const {align, key} = props + const {align, key, name} = props - const header = String(key).length - /* Get the width of each cell in the column */ + const header = String(name ?? key).length + // Get the width of each cell in the column const data = config.data.map((data) => { const value = data[key] @@ -105,7 +170,6 @@ const getColumns = (config: Config): Column[] => { const width = Math.max(...data, header) + padding * 2 - /* Construct a cell */ return { align: align ?? 'left', column: key, @@ -139,80 +203,103 @@ const getColumns = (config: Config): Column[] => { } const getHeadings = (config: Config): Partial => { - const columns = config.columns.map((c) => (typeof c === 'object' ? c.key : c)) - return Object.fromEntries(columns.map((column) => [column, column])) as Partial + const {columns, headerFormatter} = config + const format = (header: string | number | symbol) => { + if (typeof header !== 'string') return header + if (!headerFormatter) return header + + if (typeof headerFormatter === 'function') return headerFormatter(header) + + switch (headerFormatter) { + case 'pascalCase': { + return pascalCase(header) + } + + case 'capitalCase': { + return capitalCase(header) + } + + case 'camelCase': { + return camelCase(header) + } + + case 'snakeCase': { + return snakeCase(header) + } + + case 'kebabCase': { + return kebabCase(header) + } + + case 'constantCase': { + return constantCase(header) + } + + case 'sentenceCase': { + return sentenceCase(header) + } + + default: { + return header + } + } + } + + return Object.fromEntries(columns.map((c) => { + const key = typeof c === 'object' ? c.key : c + const name = typeof c === 'object' ? c.name ?? key : c + return [key, format(name)] + })) as Partial } + + class Builder { constructor(private readonly config: Config) {} public data(props: RowProps): React.ReactNode { return row({ cell: Cell, + overflow: this.config.overflow, padding: this.config.padding, - skeleton: { - component: Skeleton, - cross: '│', - left: '│', - line: ' ', - right: '│', - }, + skeleton: BORDER_SKELETONS[this.config.borderStyle].data, })(props) } public footer(props: RowProps): React.ReactNode { return row({ cell: Skeleton, + overflow: this.config.overflow, padding: this.config.padding, - skeleton: { - component: Skeleton, - cross: '┴', - left: '└', - line: '─', - right: '┘', - }, + skeleton: BORDER_SKELETONS[this.config.borderStyle].footer, })(props) } public header(props: RowProps): React.ReactNode { return row({ cell: Skeleton, + overflow: this.config.overflow, padding: this.config.padding, - skeleton: { - component: Skeleton, - cross: '┬', - left: '┌', - line: '─', - right: '┐', - }, + skeleton: BORDER_SKELETONS[this.config.borderStyle].header, })(props) } public heading(props: RowProps): React.ReactNode { return row({ cell: Header, + overflow: this.config.overflow, padding: this.config.padding, - skeleton: { - component: Skeleton, - cross: '│', - left: '│', - line: ' ', - right: '│', - }, + props: this.config.headerOptions, + skeleton: BORDER_SKELETONS[this.config.borderStyle].heading, })(props) } public separator(props: RowProps): React.ReactNode { return row({ cell: Skeleton, + overflow: this.config.overflow, padding: this.config.padding, - skeleton: { - component: Skeleton, - cross: '┼', - left: '├', - line: '─', - right: '┤', - }, + skeleton: BORDER_SKELETONS[this.config.borderStyle].separator, })(props) } } @@ -246,9 +333,16 @@ function determineWidthToUse(columns: Column[], configuredWidth: number): export function Table(props: Pick, 'data'> & Partial>) { const config: Config = { + borderStyle: props.borderStyle || 'all', columns: props.columns || getDataKeys(props.data), data: props.data, + headerFormatter: props.headerFormatter, + headerOptions: props.headerOptions || { + bold: true, + color: 'blue' + }, maxWidth: determineConfiguredWidth(props.maxWidth), + overflow: props.overflow || 'truncate', padding: props.padding || 1, } const columns = getColumns(config) @@ -276,8 +370,6 @@ export function Table(props: Pick, 'data'> & ) } -/* Helper components */ - type RowConfig = { /** * Component used to render cells. @@ -291,7 +383,6 @@ type RowConfig = { * Component used to render skeleton in the row. */ skeleton: { - component: (props: React.PropsWithChildren) => React.ReactNode /** * Characters used in skeleton. * | | @@ -303,12 +394,14 @@ type RowConfig = { cross: string line: string } + overflow?: 'wrap' | 'truncate' + props?: Record } type RowProps = { - key: string - data: Partial - columns: Column[] + readonly key: string + readonly data: Partial + readonly columns: Column[] } type Column = { @@ -318,83 +411,114 @@ type Column = { align: ColumnAlignment } +const truncate = (value: string, length: number) => `${value.slice(0, length)}...` + +// insert new line every x characters +const wrap = (value: string, position: number, padding: number) => { + const chars = [...value] + const lines = [] + let line = '' + let count = 0 + for (const char of chars) { + if (count === position) { + lines.push(line) + line = '' + count = 0 + } + + line += char + count++ + } + + lines.push(line) + return lines.join(`${' '.repeat(padding)}\n${' '.repeat(padding)}`) +} + /** * Constructs a Row element from the configuration. */ function row(config: RowConfig): (props: RowProps) => React.ReactNode { - /* This is a component builder. We return a function. */ - const {padding, skeleton} = config - - return (props) => ( - - {/* Left */} - {skeleton.left} - {/* Data */} - {...intersperse( - (i) => { - const key = `${props.key}-hseparator-${i}` - - // The horizontal separator. - return {skeleton.cross} - }, - - props.columns.map((column, colI) => { - // content - const value = props.data[column.column] - - if (value === undefined || value === null) { - const key = `${props.key}-empty-${column.key}` - - return ( - - {skeleton.line.repeat(column.width)} - - ) - } - - const key = `${props.key}-cell-${column.key}` - - const v = - stripAnsi(String(value)).length >= column.width - ? `${String(value).slice(0, column.width - (3 + padding * 2))}...` - : String(value) - - // margins - const spaces = column.width - stripAnsi(v).length - - let marginLeft: number - let marginRight: number - if (column.align === 'left') { - marginLeft = padding - marginRight = spaces - marginLeft - } else if (column.align === 'center') { - marginLeft = Math.floor(spaces / 2) - marginRight = Math.ceil(spaces / 2) - } else { - marginRight = padding - marginLeft = spaces - marginRight - } - - return ( - - {`${skeleton.line.repeat(marginLeft)}${v}${skeleton.line.repeat(marginRight)}`} - - ) - }), - )} - {/* Right */} - {skeleton.right} - - ) + // This is a component builder. We return a function. + const {overflow, padding, skeleton} = config + + return (props) => { + + const data = props.columns.map((column, colI) => { + // content + const value = props.data[column.column] + + if (value === undefined || value === null) { + const key = `${props.key}-empty-${column.key}` + + return ( + + {skeleton.line.repeat(column.width)} + + ) + } + + const key = `${props.key}-cell-${column.key}` + const v = + stripAnsi(String(value)).length >= column.width + ? overflow === 'wrap' + ? wrap(String(value), column.width - (padding * 2), padding) + : truncate(String(value), column.width - (3 + padding * 2)) + : String(value) + + + // margins + const spaces = overflow === 'wrap' ? column.width - stripAnsi(v).split('\n')[0].length : column.width - stripAnsi(v).length + let marginLeft: number + let marginRight: number + if (column.align === 'left') { + marginLeft = padding + marginRight = spaces - marginLeft + } else if (column.align === 'center') { + marginLeft = Math.floor(spaces / 2) + marginRight = Math.ceil(spaces / 2) + } else { + marginRight = padding + marginLeft = spaces - marginRight + } + + return ( + + {`${skeleton.line.repeat(marginLeft)}${v}${skeleton.line.repeat(marginRight)}`} + + ) + }) + + const height = data.map((d) => d.props.children.split('\n').length).reduce((a, b) => Math.max(a, b), 0) + + return ( + + {/* Left */} + {skeleton.left} + {/* Data */} + {...intersperse( + (i) => { + const key = `${props.key}-hseparator-${i}` + + // The horizontal separator. + return {skeleton.cross} + }, + data + )} + {/* Right */} + {skeleton.right} + + ) + } } /** * Renders the header of a table. */ export function Header(props: React.PropsWithChildren) { + const {children, ...rest} = props return ( - - {props.children} + + {children} ) } @@ -409,8 +533,17 @@ export function Cell(props: CellProps) { /** * Renders the scaffold of the table. */ -export function Skeleton(props: React.PropsWithChildren) { - return {props.children} +export function Skeleton(props: React.PropsWithChildren & {readonly height?: number}) { + const {children, ...rest} = props + // repeat Text component height times + const texts = Array.from({length: props.height ?? 1}, (_, i) => ( + {children} + )) + return ( + + {texts} + + ) } /* Utility functions */ diff --git a/yarn.lock b/yarn.lock index f59adfa..6406d04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -935,6 +935,11 @@ chalk@^5.3.0, chalk@~5.3.0: resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== +change-case@^5.4.4: + version "5.4.4" + resolved "https://registry.yarnpkg.com/change-case/-/change-case-5.4.4.tgz#0d52b507d8fb8f204343432381d1a6d7bff97a02" + integrity sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w== + check-error@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694"