import { Box, Text } from 'ink' import { sha1 } from 'object-hash' // Copied from ink-table https://github.com/maticzav/ink-table/blob/master/src/index.tsx import React from 'react' /* Table */ type Scalar = string | number | boolean | null | undefined type ScalarDict = { [key: string]: Scalar } export type CellProps = React.PropsWithChildren<{ column: number }> export type TableProps = { /** * List of values (rows). */ data: T[] /** * Columns that we should display in the table. */ columns: (keyof T)[] /** * Cell padding. */ padding: number /** * Header component. */ header: (props: React.PropsWithChildren<{}>) => JSX.Element /** * Component used to render a cell in the table. */ cell: (props: CellProps) => JSX.Element /** * Component used to render the skeleton of the table. */ skeleton: (props: React.PropsWithChildren<{}>) => JSX.Element } /* Table */ export default class Table extends React.Component< Pick, 'data'> & Partial> > { /* Config */ /** * Merges provided configuration with defaults. */ getConfig(): TableProps { return { data: this.props.data, columns: this.props.columns || this.getDataKeys(), padding: this.props.padding || 1, header: this.props.header || Header, cell: this.props.cell || Cell, skeleton: this.props.skeleton || Skeleton, } } /** * Gets all keyes used in data by traversing through the data. */ getDataKeys(): (keyof T)[] { const keys = new Set() // Collect all the keys. for (const data of this.props.data) { for (const key in data) { keys.add(key) } } return Array.from(keys) } /** * Calculates the width of each column by finding * the longest value in a cell of a particular column. * * Returns a list of column names and their widths. */ getColumns(): Column[] { const { columns, padding } = this.getConfig() const widths: Column[] = columns.map((key) => { const header = String(key).length /* Get the width of each cell in the column */ const data = this.props.data.map((data) => { const value = data[key] if (value === undefined || value == null) return 0 return String(value).length }) const width = Math.max(...data, header) + padding * 2 /* Construct a cell */ return { column: key, width: width, key: String(key), } }) return widths } /** * Returns a (data) row representing the headings. */ getHeadings(): Partial { const { columns } = this.getConfig() const headings: Partial = columns.reduce( (acc, column) => ({ ...acc, [column]: column }), {}, ) return headings } /* Rendering utilities */ // The top most line in the table. header = row({ cell: this.getConfig().skeleton, padding: this.getConfig().padding, skeleton: { component: this.getConfig().skeleton, // chars line: '─', left: '┌', right: '┐', cross: '┬', }, }) // The line with column names. heading = row({ cell: this.getConfig().header, padding: this.getConfig().padding, skeleton: { component: this.getConfig().skeleton, // chars line: ' ', left: '│', right: '│', cross: '│', }, }) // The line that separates rows. separator = row({ cell: this.getConfig().skeleton, padding: this.getConfig().padding, skeleton: { component: this.getConfig().skeleton, // chars line: '─', left: '├', right: '┤', cross: '┼', }, }) // The row with the data. data = row({ cell: this.getConfig().cell, padding: this.getConfig().padding, skeleton: { component: this.getConfig().skeleton, // chars line: ' ', left: '│', right: '│', cross: '│', }, }) // The bottom most line of the table. footer = row({ cell: this.getConfig().skeleton, padding: this.getConfig().padding, skeleton: { component: this.getConfig().skeleton, // chars line: '─', left: '└', right: '┘', cross: '┴', }, }) override render() { /* Data */ const columns = this.getColumns() const headings = this.getHeadings() /** * Render the table line by line. */ return ( {/* Header */} {this.header({ key: 'header', columns, data: {} })} {this.heading({ key: 'heading', columns, data: headings })} {/* Data */} {this.props.data.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 ( {this.separator({ key: `separator-${key}`, columns, data: {} })} {this.data({ key: `data-${key}`, columns, data: row })} ) })} {/* Footer */} {this.footer({ key: 'footer', columns, data: {} })} ) } } /* Helper components */ type RowConfig = { /** * Component used to render cells. */ cell: (props: CellProps) => JSX.Element /** * Tells the padding of each cell. */ padding: number /** * Component used to render skeleton in the row. */ skeleton: { component: (props: React.PropsWithChildren<{}>) => JSX.Element /** * Characters used in skeleton. * | | * (left)-(line)-(cross)-(line)-(right) * | | */ left: string right: string cross: string line: string } } type RowProps = { key: string data: Partial columns: Column[] } type Column = { key: string column: keyof T width: number } /** * Constructs a Row element from the configuration. */ function row( config: RowConfig, ): (props: RowProps) => JSX.Element { /* This is a component builder. We return a function. */ const skeleton = config.skeleton /* Row */ return (props) => ( {/* Left */} {skeleton.left} {/* Data */} {...intersperse( (i) => { const key = `${props.key}-hseparator-${i}` // The horizontal separator. return ( {skeleton.cross} ) }, // Values. 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)} ) } else { const key = `${props.key}-cell-${column.key}` // margins const ml = config.padding const mr = column.width - String(value).length - config.padding return ( /* prettier-ignore */ {`${skeleton.line.repeat(ml)}${String( value, )}${skeleton.line.repeat(mr)}`} ) } }), )} {/* Right */} {skeleton.right} ) } /** * Renders the header of a table. */ export function Header(props: React.PropsWithChildren<{}>) { return ( {props.children} ) } /** * Renders a cell in the table. */ export function Cell(props: CellProps) { return {props.children} } /** * Redners the scaffold of the table. */ export function Skeleton(props: React.PropsWithChildren<{}>) { return {props.children} } /* Utility functions */ /** * Intersperses a list of elements with another element. */ function intersperse( intersperser: (index: number) => I, elements: T[], ): (T | I)[] { // Intersparse by reducing from left. const interspersed: (T | I)[] = elements.reduce((acc, element, index) => { // Only add element if it's the first one. if (acc.length === 0) return [element] // Add the intersparser as well otherwise. return [...acc, intersperser(index), element] }, [] as (T | I)[]) return interspersed }