diff --git a/examples/table/datatable.ts b/examples/table/datatable.ts new file mode 100755 index 000000000..d29d13197 --- /dev/null +++ b/examples/table/datatable.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env -S deno run + +import { colors } from "../../ansi/colors.ts"; +import { Table } from "../../table/table.ts"; + +new Table() + .data([ + { + firstName: "Gino", + lastName: "Aicheson", + age: 21, + email: "gaicheson0@nydailynews.com", + }, + { + firstName: "Godfry", + lastName: "Pedycan", + age: 33, + email: "gpedycan1@state.gov", + }, + { + firstName: "Loni", + lastName: "Miller", + age: 24, + email: "lmiller2@chron.com", + }, + ]) + .headerRenderer(colors.bold) + .columns([{ + header: "Name", + cellValue: ({ firstName, lastName }) => `${firstName} ${lastName}`, + cellRenderer: colors.brightBlue.bold, + }, { + field: "age", + header: "Age", + align: "right", + cellRenderer: colors.yellow, + }, { + field: "email", + header: "Email", + minWidth: 20, + align: "center", + cellRenderer: colors.cyan.italic, + }]) + .border() + .render(); + +// new Table() +// // .cellValue(value => (value + " 123 123 123 123 123 123 123 123 123 123 123 123 123 123 123 123 123 123 123").trim()) +// // .cellRenderer(value => colors.bold.magenta(value)) +// // .header([1, "12"]) +// .header("123") +// .fillRows() +// // .header(["123", new Cell(""), 3]) +// .body([ +// // foo1, +// // foo2, +// // new Cell("foo"), +// // new Row(new Cell("foo"), new Cell("bar")) +// // { name: "foo", age: 21, email: "foo@example.com" }, +// // ["foo", "ff", "gggg"], +// { name: "bar", age: 33, email: "bar@example.com" }, +// { name: "baz", age: 24, email: "baz@example.com" }, +// // [{ name: "foo", age: 21, email: "foo@example.com" }], +// // [{ name: "bar", age: 33, email: "bar@example.com" }], +// // [{ name: "baz", age: 24, email: "baz@example.com" }], +// // ["foo"], +// // [undefined], +// ]) +// // .fromJson([{foo: 1, bar: "2"}]) +// .columns([{ +// field: "name", +// // header: "Name", +// border: true, +// maxWidth: 10, +// }, { +// // field: "age", +// // header: "Age", +// minWidth: 10, +// maxWidth: 15, +// align: "center", +// // headerValue: (value) => value?.toString().toUpperCase(), +// headerValue: (value) => "value.age" ?? "-", +// cellValue: (value) => value.age ?? "-", +// // headerRenderer: colors.yellow, +// // cellRenderer: colors.blue, +// }, { +// // field: "email", +// // header: "Email", +// border: true, +// maxWidth: 10, +// align: "right", +// // headerValue: (value) => value?.toString().toUpperCase(), +// // cellRenderer: colors.green, +// }]) +// .render(); diff --git a/table/cell.ts b/table/cell.ts index fd4820eb5..68cb739b4 100644 --- a/table/cell.ts +++ b/table/cell.ts @@ -1,20 +1,39 @@ +export type CellValue = unknown; + /** Cell type */ -// deno-lint-ignore ban-types -export type ICell = number | string | String | Cell; +export type CellOrValue = + | TValue + | Cell; + +export type GetCellValue> = TCell extends + infer TCell ? TCell extends Cell ? Value + : TCell + : never; export type Direction = "left" | "right" | "center"; +export type ValueParserResult = string | number | undefined | null; + +export type ValueParser = ( + value: TValue, +) => ValueParserResult; + +export type Renderer = (value: string) => string; + /** Cell options. */ -export interface ICellOptions { +export interface CellOptions { border?: boolean; colSpan?: number; rowSpan?: number; align?: Direction; + // value?: ValueParser; + value?(value: TValue): ValueParserResult; + render?: Renderer; } /** Cell representation. */ -export class Cell { - protected options: ICellOptions = {}; +export class Cell { + protected options: CellOptions = {}; /** Get cell length. */ public get length(): number { @@ -26,31 +45,42 @@ export class Cell { * will be copied to the new cell. * @param value Cell or cell value. */ - public static from(value: ICell): Cell { - const cell = new this(value); + public static from( + value: CellOrValue, + ): Cell { if (value instanceof Cell) { + const cell = new this(value.getValue()); cell.options = { ...value.options }; + return cell; } - return cell; + + return new this(value); } /** * Cell constructor. - * @param value Cell value. + * @param cellValue Cell value. */ - public constructor(private value: ICell) {} + public constructor( + private cellValue?: TValue | undefined | null, + ) {} - /** Get cell value. */ + /** Get cell string value. */ public toString(): string { - return this.value.toString(); + return this.cellValue?.toString() ?? ""; + } + + /** Get cell value. */ + public getValue(): TValue | undefined | null { + return this.cellValue; } /** * Set cell value. * @param value Cell or cell value. */ - public setValue(value: ICell): this { - this.value = value; + public setValue(value: TValue | undefined | null): this { + this.cellValue = value; return this; } @@ -58,9 +88,11 @@ export class Cell { * Clone cell with all options. * @param value Cell or cell value. */ - public clone(value?: ICell): Cell { - const cell = new Cell(value ?? this); - cell.options = { ...this.options }; + public clone( + value: TCloneValue = this.getValue() as TCloneValue, + ): Cell { + const cell = new Cell(value); + cell.options = { ...this.options } as CellOptions; return cell; } @@ -116,13 +148,31 @@ export class Cell { return this; } + /** + * Register cell value parser. + * @param fn Value parser callback function. + */ + public value(fn: ValueParser): this { + this.options.value = fn; + return this; + } + + /** + * Register cell renderer. Will be called once for each line in the cell. + * @param fn Cell renderer callback function. + */ + public renderer(fn: Renderer): this { + this.options.render = fn; + return this; + } + /** * Getter: */ /** Check if cell has border. */ - public getBorder(): boolean { - return this.options.border === true; + public getBorder(): boolean | undefined { + return this.options.border; } /** Get col span. */ @@ -139,8 +189,21 @@ export class Cell { : 1; } - /** Get row span. */ - public getAlign(): Direction { - return this.options.align ?? "left"; + /** Get cell alignment. */ + public getAlign(): Direction | undefined { + return this.options.align; + } + + /** Get value parser. */ + // public getValueParser(): ValueParser | undefined { + public getValueParser(): + | ValueParser + | undefined { + return this.options.value; + } + + /** Get cell renderer. */ + public getRenderer(): Renderer | undefined { + return this.options.render; } } diff --git a/table/column.ts b/table/column.ts index 8cc63b939..4b27f7741 100644 --- a/table/column.ts +++ b/table/column.ts @@ -1,69 +1,194 @@ -import { Direction } from "./cell.ts"; +import { CellValue, Direction, Renderer, ValueParser } from "./cell.ts"; -export interface ColumnOptions { +export interface ColumnOptions< + TValue extends CellValue, + THeaderValue extends CellValue, +> { + field?: FieldNames; + header?: string; border?: boolean; align?: Direction; minWidth?: number; maxWidth?: number; padding?: number; + headerValue?: ValueParser; + cellValue?: ValueParser; + headerRenderer?: Renderer; + cellRenderer?: Renderer; } -export class Column { - static from(options: ColumnOptions): Column { - const column = new Column(); - column.opts = { ...options }; +export type FieldNames = Extract< + keyof { + [Key in keyof TValue as TValue[Key] extends Function ? never : Key]: Key; + }, + string +>; + +export class Column< + TValue extends CellValue, + THeaderValue extends CellValue, +> { + /** + * Create column from existing column or column options. + * @param options Column options. + */ + static from< + TValue extends CellValue, + THeaderValue extends CellValue, + >( + options: ColumnOptions | Column, + ): Column { + const column = new Column(); + column.opts = { ...options instanceof Column ? options.opts : options }; return column; } - protected opts: ColumnOptions = {}; + constructor( + protected opts: ColumnOptions = {}, + ) {} - options(options: ColumnOptions): this { + /** + * Set column options. + * @param options Column options. + */ + options(options: ColumnOptions): this { Object.assign(this.opts, options); return this; } + /** Get column alignment. */ + getField(): string | undefined { + return this.opts.field; + } + + /** Get column alignment. */ + getHeader(): string | undefined { + return this.opts.header; + } + + /** + * Set min column width. + * @param width Min column width. + */ minWidth(width: number): this { this.opts.minWidth = width; return this; } + /** + * Set max column width. + * @param width Max column width. + */ maxWidth(width: number): this { this.opts.maxWidth = width; return this; } + /** + * Set column border. + * @param border + */ border(border = true): this { this.opts.border = border; return this; } + /** + * Set column left and right padding. + * @param padding Padding. + */ padding(padding: number): this { this.opts.padding = padding; return this; } + /** + * Set column alignment. + * @param direction Column alignment. + */ align(direction: Direction): this { this.opts.align = direction; return this; } + /** + * Register header value parser. + * @param fn Value parser callback function. + */ + headerValue(fn: ValueParser): this { + this.opts.headerValue = fn; + return this; + } + + /** + * Register cell value parser. + * @param fn Value parser callback function. + */ + cellValue(fn: ValueParser): this { + this.opts.cellValue = fn; + return this; + } + + /** + * Register header cell renderer. Will be called once for each line in the cell. + * @param fn Cell renderer callback function. + */ + headerRenderer(fn: Renderer): this { + this.opts.headerRenderer = fn; + return this; + } + + /** + * Register cell renderer. Will be called once for each line in the cell. + * @param fn Cell renderer callback function. + */ + cellRenderer(fn: Renderer): this { + this.opts.cellRenderer = fn; + return this; + } + + /** Get min column width. */ getMinWidth(): number | undefined { return this.opts.minWidth; } + /** Get max column width. */ getMaxWidth(): number | undefined { return this.opts.maxWidth; } + /** Get column border. */ getBorder(): boolean | undefined { return this.opts.border; } + /** Get column padding. */ getPadding(): number | undefined { return this.opts.padding; } + /** Get column alignment. */ getAlign(): Direction | undefined { return this.opts.align; } + + /** Get header value parser. */ + getHeaderValueParser(): ValueParser | undefined { + return this.opts.headerValue; + } + + /** Get value parser. */ + getCellValueParser(): ValueParser | undefined { + return this.opts.cellValue; + } + + /** Get header renderer. */ + getHeaderRenderer(): Renderer | undefined { + return this.opts.headerRenderer; + } + + /** Get cell renderer. */ + getCellRenderer(): Renderer | undefined { + return this.opts.cellRenderer; + } } diff --git a/table/layout.ts b/table/layout.ts index 47c6cd50e..0945f2b0b 100644 --- a/table/layout.ts +++ b/table/layout.ts @@ -1,7 +1,13 @@ -import { Cell, Direction, type ICell } from "./cell.ts"; +import { + Cell, + type CellOrValue, + type CellValue, + type Direction, + type ValueParser, +} from "./cell.ts"; import type { Column } from "./column.ts"; -import { type IRow, Row } from "./row.ts"; -import type { IBorderOptions, ITableSettings, Table } from "./table.ts"; +import { type GetRowValue, Row, type RowOrValue } from "./row.ts"; +import type { BorderOptions, Table, TableSettings } from "./table.ts"; import { consumeWords, longest, strLength } from "./utils.ts"; /** Layout render settings. */ @@ -16,15 +22,18 @@ interface IRenderSettings { } /** Table layout renderer. */ -export class TableLayout { +export class TableLayout< + TRow extends RowOrValue, + THeaderRow extends RowOrValue, +> { /** * Table layout constructor. * @param table Table instance. * @param options Render options. */ public constructor( - private table: Table, - private options: ITableSettings, + private table: Table, + private options: TableSettings, GetRowValue>, ) {} /** Generate table string. */ @@ -40,8 +49,8 @@ export class TableLayout { */ protected createLayout(): IRenderSettings { Object.keys(this.options.chars).forEach((key: string) => { - if (typeof this.options.chars[key as keyof IBorderOptions] !== "string") { - this.options.chars[key as keyof IBorderOptions] = ""; + if (typeof this.options.chars[key as keyof BorderOptions] !== "string") { + this.options.chars[key as keyof BorderOptions] = ""; } }); @@ -52,13 +61,17 @@ export class TableLayout { const rows = this.#getRows(); - const columns: number = Math.max(...rows.map((row) => row.length)); - for (const row of rows) { + const columns: number = Math.max( + this.options.columns.length, + ...rows.map((row) => row.length), + ); + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + const row = rows[rowIndex]; const length: number = row.length; if (length < columns) { const diff = columns - length; for (let i = 0; i < diff; i++) { - row.push(this.createCell(null, row, length + i)); + row.push(this.createCell(null, row, rowIndex, length + i)); } } } @@ -98,9 +111,20 @@ export class TableLayout { } #getRows(): Array> { - const header: Row | undefined = this.table.getHeader(); + let header: Row> | undefined = this.table + .getHeader(); + + if (!header && this.options.columns.length) { + header = Row.from( + this.options.columns.map((column) => column.getHeader()), + ); + // deno-lint-ignore no-explicit-any + this.table.header(header as Row); + } + const rows = header ? [header, ...this.table] : this.table.slice(); const hasSpan = rows.some((row) => + Array.isArray(row) && row.some((cell) => cell instanceof Cell && (cell.getColSpan() > 1 || cell.getRowSpan() > 1) ) @@ -110,11 +134,22 @@ export class TableLayout { return this.spanRows(rows); } - return rows.map((row) => { - const newRow = this.createRow(row); - for (let colIndex = 0; colIndex < row.length; colIndex++) { - newRow[colIndex] = this.createCell(row[colIndex], newRow, colIndex); + return rows.map((row, rowIndex) => { + const newRow = Row.from(row) as Row; + const dataCell = this.options.isDataTable ? Cell.from(newRow[0]) : null; + const length = this.options.isDataTable + ? this.options.columns.length + : newRow.length; + + for (let colIndex = 0; colIndex < length; colIndex++) { + newRow[colIndex] = this.createCell( + newRow[colIndex] ?? dataCell, + newRow, + rowIndex, + colIndex, + ); } + return newRow; }); } @@ -123,7 +158,7 @@ export class TableLayout { * Fills rows and cols by specified row/col span with a reference of the * original cell. */ - protected spanRows(rows: Array) { + protected spanRows(rows: Array>>) { const rowSpan: Array = []; let colSpan = 1; let rowIndex = -1; @@ -133,7 +168,7 @@ export class TableLayout { if (rowIndex === rows.length && rowSpan.every((span) => span === 1)) { break; } - const row = rows[rowIndex] = this.createRow(rows[rowIndex] || []); + const row = rows[rowIndex] = Row.from(rows[rowIndex] || []); let colIndex = -1; while (true) { @@ -159,10 +194,11 @@ export class TableLayout { if (rowSpan[colIndex] > 1) { rowSpan[colIndex]--; - rows[rowIndex].splice( + const prevRow = rows[rowIndex - 1] as Row>; + row.splice( colIndex, this.getDeleteCount(rows, rowIndex, colIndex), - rows[rowIndex - 1][colIndex], + prevRow[colIndex], ); continue; @@ -171,6 +207,7 @@ export class TableLayout { const cell = row[colIndex] = this.createCell( row[colIndex] || null, row, + rowIndex, colIndex, ); @@ -183,40 +220,91 @@ export class TableLayout { } protected getDeleteCount( - rows: Array>, + rows: Array>>, rowIndex: number, colIndex: number, ) { - return colIndex <= rows[rowIndex].length - 1 && - typeof rows[rowIndex][colIndex] === "undefined" + const row: RowOrValue> = rows[rowIndex]; + return Array.isArray(row) && colIndex <= row.length - 1 && + typeof row[colIndex] === "undefined" ? 1 : 0; } - /** - * Create a new row from existing row or cell array. - * @param row Original row. - */ - protected createRow(row: IRow): Row { - return Row.from(row) - .border(this.table.getBorder(), false) - .align(this.table.getAlign(), false) as Row; - } - - /** - * Create a new cell from existing cell or cell value. - * @param cell Original cell. - * @param row Parent row. - */ + /** Create a new cell from existing cell or cell value. */ protected createCell( - cell: ICell | null | undefined, - row: Row, + value: CellOrValue, + row: Row>, + rowIndex: number, colIndex: number, ): Cell { - const column: Column | undefined = this.options.columns.at(colIndex); - return Cell.from(cell ?? "") - .border(column?.getBorder() ?? row.getBorder(), false) - .align(column?.getAlign() ?? row.getAlign(), false); + const column: + | Column, GetRowValue> + | undefined = this.options.columns + .at(colIndex); + const field = column?.getField(); + const isHeader = rowIndex === 0 && this.table.getHeader() !== undefined; + const cell = Cell.from(value ?? "") as Cell; + + if (typeof cell.getBorder() === "undefined") { + cell.border( + row.getBorder() ?? column?.getBorder() ?? this.table.getBorder() ?? + false, + ); + } + + if (!cell.getAlign()) { + cell.align( + row.getAlign() ?? column?.getAlign() ?? this.table.getAlign() ?? "left", + ); + } + + if (!cell.getRenderer()) { + const cellRenderer = row.getCellRenderer() ?? ( + isHeader ? column?.getHeaderRenderer() : column?.getCellRenderer() + ) ?? ( + isHeader ? this.table.getHeaderRenderer() : this.table.getCellRenderer() + ); + + if (cellRenderer) { + cell.renderer(cellRenderer); + } + } + + const cellValueParser = + (cell.getValueParser() ?? row.getCellValueParser() ?? + ( + isHeader + ? column?.getHeaderValueParser() + : column?.getCellValueParser() + ) ?? + (isHeader + ? this.table.getHeaderValueParser() + : this.table.getCellValueParser())) as ValueParser; + + if (field && !isHeader) { + const data = cell.getValue(); + if (!data || typeof data !== "object") { + throw new Error( + "Invalid data: When the field option is used, the data must be an object.", + ); + } + if (!(field in data)) { + throw new Error( + "Invalid data: Field name does not exist in data.", + ); + } + // deno-lint-ignore no-explicit-any + const dataVal = (data as any)[field]; + cell.setValue(dataVal); + } + + if (cellValueParser) { + cell.value(cellValueParser); + cell.setValue(cellValueParser(cell.getValue())); + } + + return cell; } /** @@ -373,7 +461,7 @@ export class TableLayout { result += " ".repeat(opts.padding[colIndex]); } - result += current; + result += row[colIndex].getRenderer()?.(current) ?? current; if (opts.hasBorder || colIndex < opts.columns - 1) { result += " ".repeat(opts.padding[colIndex]); @@ -391,12 +479,13 @@ export class TableLayout { protected renderCellValue( cell: Cell, maxLength: number, - ): { current: string; next: Cell } { + ): { current: string; next: string } { + const value = cell.toString(); const length: number = Math.min( maxLength, - strLength(cell.toString()), + strLength(value), ); - let words: string = consumeWords(length, cell.toString()); + let words: string = consumeWords(length, value); // break word if word is longer than max length const breakWord = strLength(words) > length; @@ -405,11 +494,11 @@ export class TableLayout { } // get next content and remove leading space if breakWord is not true - const next = cell.toString().slice(words.length + (breakWord ? 0 : 1)); + const next = value.slice(words.length + (breakWord ? 0 : 1)); const fillLength = maxLength - strLength(words); // Align content - const align: Direction = cell.getAlign(); + const align: Direction = cell.getAlign() ?? "left"; let current: string; if (fillLength === 0) { current = words; @@ -426,7 +515,7 @@ export class TableLayout { return { current, - next: cell.clone(next), + next, }; } diff --git a/table/row.ts b/table/row.ts index 381ebe268..78eac9da8 100644 --- a/table/row.ts +++ b/table/row.ts @@ -1,46 +1,76 @@ -import { Cell, Direction, ICell } from "./cell.ts"; +import { + Cell, + CellOrValue, + CellValue, + Direction, + GetCellValue, + Renderer, + ValueParser, +} from "./cell.ts"; /** Row type */ -export type IRow = - | T[] - | Row; +export type RowOrValue> = + | TValue + | Array + | Row; + +export type GetRowValue>> = + TRow extends infer TRow + ? TRow extends Array> + ? GetCellValue + : TRow extends CellOrValue ? GetCellValue + : never + : never; + /** Json row. */ -export type IDataRow = Record; +export type JsonData = Record; /** Row options. */ -export interface IRowOptions { +export interface RowOptions< + TValue extends CellValue, +> { indent?: number; border?: boolean; align?: Direction; + cellValue?: ValueParser; + cellRenderer?: Renderer; } /** * Row representation. */ -export class Row - extends Array { - protected options: IRowOptions = {}; +export class Row< + TCell extends CellOrValue, +> extends Array { + protected options: RowOptions> = {}; /** * Create a new row. If cells is a row, all cells and options of the row will * be copied to the new row. - * @param cells Cells or row. + * @param value Cells or row. */ - public static from( - cells: IRow, - ): Row { - const row = new this(...cells); - if (cells instanceof Row) { - row.options = { ...(cells as Row).options }; + public static from< + TCell extends CellOrValue, + >( + value: RowOrValue, + ): Row { + if (Array.isArray(value)) { + const row = new this(...value); + if (value instanceof Row) { + row.options = { ...(value as Row).options }; + } + return row; } - return row; + + return new this(value); } /** Clone row recursively with all options. */ - public clone(): Row { - const row = new Row( - ...this.map((cell: T) => cell instanceof Cell ? cell.clone() : cell), + public clone(): this { + const cells = this.map((cell) => + cell instanceof Cell ? cell.clone() : cell ); + const row = Row.from(cells) as this; row.options = { ...this.options }; return row; } @@ -73,13 +103,31 @@ export class Row return this; } + /** + * Register cell value parser. + * @param fn Value parser callback function. + */ + public cellValue(fn: ValueParser>): this { + this.options.cellValue = fn; + return this; + } + + /** + * Register cell renderer. Will be called once for each line in the cell. + * @param fn Cell renderer callback function. + */ + public cellRenderer(fn: Renderer): this { + this.options.cellRenderer = fn; + return this; + } + /** * Getter: */ /** Check if row has border. */ - public getBorder(): boolean { - return this.options.border === true; + public getBorder(): boolean | undefined { + return this.options.border; } /** Check if row or any child cell has border. */ @@ -89,7 +137,17 @@ export class Row } /** Get row alignment. */ - public getAlign(): Direction { - return this.options.align ?? "left"; + public getAlign(): Direction | undefined { + return this.options.align; + } + + /** Get value parser. */ + public getCellValueParser(): ValueParser> | undefined { + return this.options.cellValue; + } + + /** Get cell renderer. */ + public getCellRenderer(): Renderer | undefined { + return this.options.cellRenderer; } } diff --git a/table/table.ts b/table/table.ts index c33b2e867..045c7e0be 100644 --- a/table/table.ts +++ b/table/table.ts @@ -1,56 +1,70 @@ import { border, IBorder } from "./border.ts"; -import { Cell, Direction } from "./cell.ts"; +import { Cell, CellValue, Direction, Renderer, ValueParser } from "./cell.ts"; import { Column, ColumnOptions } from "./column.ts"; import { TableLayout } from "./layout.ts"; -import { IDataRow, IRow, Row } from "./row.ts"; +import { GetRowValue, JsonData, Row, RowOrValue } from "./row.ts"; /** Border characters settings. */ -export type IBorderOptions = Partial; +export type BorderOptions = Partial; /** Table options. */ -export interface ITableOptions { - indent?: number; - border?: boolean; +export interface TableSettings< + TValue extends CellValue, + THeaderValue extends CellValue, +> { + indent: number; + border: boolean; align?: Direction; - maxColWidth?: number | number[]; - minColWidth?: number | number[]; - padding?: number | number[]; - chars?: IBorderOptions; -} - -/** Table settings. */ -export interface ITableSettings extends Required> { + maxColWidth: number | number[]; + minColWidth: number | number[]; + padding: number | number[]; chars: IBorder; - align?: Direction; - columns: Array; + columns: Array>; + isDataTable: boolean; + headerValue?: ValueParser; + cellValue?: ValueParser; + headerRenderer?: Renderer; + cellRenderer?: Renderer; } -/** Table type. */ -export type ITable = T[] | Table; - /** Table representation. */ -export class Table extends Array { +export class Table< + TRow extends RowOrValue, + THeaderRow extends RowOrValue = TRow, +> extends Array { protected static _chars: IBorder = { ...border }; - protected options: ITableSettings = { - indent: 0, - border: false, - maxColWidth: Infinity, - minColWidth: 0, - padding: 1, - chars: { ...Table._chars }, - columns: [], - }; - private headerRow?: Row; + protected options: TableSettings, GetRowValue> = + { + indent: 0, + border: false, + maxColWidth: Infinity, + minColWidth: 0, + padding: 1, + chars: { ...Table._chars }, + columns: [], + isDataTable: false, + }; + private headerRow?: Row>; + + /** Generate table string. */ + public toString(): string { + return new TableLayout(this, this.options).toString(); + } /** * Create a new table. If rows is a table, all rows and options of the table * will be copied to the new table. * @param rows */ - public static from(rows: ITable): Table { - const table = new this(...rows); + public static from< + TRow extends RowOrValue, + THeaderRow extends RowOrValue, + >( + rows: Array | Table, + ): Table { + const table = new this(...rows); if (rows instanceof Table) { - table.options = { ...(rows as Table).options }; + table.options = { ...(rows as Table).options }; table.headerRow = rows.headerRow ? Row.from(rows.headerRow) : undefined; } return table; @@ -61,7 +75,9 @@ export class Table extends Array { * row and each property a column. * @param rows Array of objects. */ - public static fromJson(rows: IDataRow[]): Table { + public static fromJson( + rows: Array>, + ): Table, Row> { return new this().fromJson(rows); } @@ -69,7 +85,7 @@ export class Table extends Array { * Set global default border characters. * @param chars Border options. */ - public static chars(chars: IBorderOptions): typeof Table { + public static chars(chars: BorderOptions): typeof Table { Object.assign(this._chars, chars); return this; } @@ -78,7 +94,12 @@ export class Table extends Array { * Write table or rows to stdout. * @param rows Table or rows. */ - public static render(rows: ITable): void { + public static render< + TRow extends RowOrValue, + THeaderRow extends RowOrValue, + >( + rows: Array | Table, + ): void { Table.from(rows).render(); } @@ -87,22 +108,40 @@ export class Table extends Array { * row and each property a column. * @param rows Array of objects. */ - public fromJson(rows: IDataRow[]): this { - this.header(Object.keys(rows[0])); - this.body(rows.map((row) => Object.values(row) as T)); - return this; + public fromJson( + rows: Array>, + ): Table, Row> { + return (this as Table, Row>) + .header(Object.keys(rows[0])) + .body(rows.map((row) => Object.values(row))); } - public columns(columns: Array): this { + /** + * Set column definitions. + * @param columns Array of columns or column options. + */ + public columns( + columns: Array< + | Column, GetRowValue> + | ColumnOptions, GetRowValue> + >, + ): this { this.options.columns = columns.map((column) => column instanceof Column ? column : Column.from(column) ); return this; } + /** + * Set column definitions for a single column. + * @param index Column index. + * @param column Column or column options. + */ public column( index: number, - column: Column | ColumnOptions, + column: + | Column, GetRowValue> + | ColumnOptions, GetRowValue>, ): this { if (column instanceof Column) { this.options.columns[index] = column; @@ -118,38 +157,65 @@ export class Table extends Array { * Set table header. * @param header Header row or cells. */ - public header(header: IRow): this { - this.headerRow = header instanceof Row ? header : Row.from(header); - return this; + public header< + THeader extends THeaderRow, + THeaderValue extends GetRowValue, + >( + header: RowOrValue, + ): Table>> { + const table = this as Table>>; + table.headerRow = header instanceof Row ? header : Row.from(header); + return table; + } + + /** + * Add an array of rows. + * @param rows Table rows. + */ + public rows( + rows: Array, + ): Table { + const table = this as Table, THeaderRow> as Table< + TBodyRow, + THeaderRow + >; + table.push(...rows); + return table; } /** * Set table body. * @param rows Table rows. */ - public body(rows: T[]): this { + public body( + rows: Array, + ): Table { this.length = 0; - this.push(...rows); - return this; + return this.rows(rows); + } + + /** + * Set table data. + * @param rows Table rows. + */ + public data( + rows: Array, + ): Table { + this.length = 0; + return this.fillRows().rows(rows); } /** Clone table recursively with header and options. */ - public clone(): Table { - const table = new Table( - ...this.map((row: T) => - row instanceof Row ? row.clone() : Row.from(row).clone() - ), + public clone(): this { + const rows = this.map((row) => + row instanceof Row ? row.clone() : Row.from(row).clone() ); + const table = Table.from(rows) as this; table.options = { ...this.options }; table.headerRow = this.headerRow?.clone(); return table; } - /** Generate table string. */ - public toString(): string { - return new TableLayout(this, this.options).toString(); - } - /** Write table to stdout. */ public render(): this { console.log(this.toString()); @@ -232,22 +298,58 @@ export class Table extends Array { * Set border characters. * @param chars Border options. */ - public chars(chars: IBorderOptions): this { + public chars(chars: BorderOptions): this { Object.assign(this.options.chars, chars); return this; } + /** + * Register header value parser. + * @param fn Value parser callback function. + */ + public headerValue(fn: ValueParser>): this { + this.options.headerValue = fn; + return this; + } + + /** + * Register cell value parser. + * @param fn Value parser callback function. + */ + public cellValue(fn: ValueParser>): this { + this.options.cellValue = fn; + return this; + } + + /** + * Register header renderer. Will be called once for each line in the cell. + * @param fn Cell renderer callback function. + */ + public headerRenderer(fn: Renderer): this { + this.options.headerRenderer = fn; + return this; + } + + /** + * Register cell renderer. Will be called once for each line in the cell. + * @param fn Cell renderer callback function. + */ + public cellRenderer(fn: Renderer): this { + this.options.cellRenderer = fn; + return this; + } + /** Get table header. */ - public getHeader(): Row | undefined { + public getHeader(): Row> | undefined { return this.headerRow; } /** Get table body. */ - public getBody(): T[] { + public getBody(): TRow[] { return [...this]; } - /** Get mac col widrth. */ + /** Get max col width. */ public getMaxColWidth(): number | number[] { return this.options.maxColWidth; } @@ -268,14 +370,15 @@ export class Table extends Array { } /** Check if table has border. */ - public getBorder(): boolean { - return this.options.border === true; + public getBorder(): boolean | undefined { + return this.options.border; } /** Check if header row has border. */ public hasHeaderBorder(): boolean { const hasBorder = this.headerRow?.hasBorder(); - return hasBorder === true || (this.getBorder() && hasBorder !== false); + return hasBorder === true || + (this.getBorder() === true && hasBorder !== false); } /** Check if table bordy has border. */ @@ -285,7 +388,9 @@ export class Table extends Array { this.some((row) => row instanceof Row ? row.hasBorder() - : row.some((cell) => cell instanceof Cell ? cell.getBorder() : false) + : Array.isArray(row) + ? row.some((cell) => cell instanceof Cell ? cell.getBorder() : false) + : false ); } @@ -295,15 +400,48 @@ export class Table extends Array { } /** Get table alignment. */ - public getAlign(): Direction { - return this.options.align ?? "left"; + public getAlign(): Direction | undefined { + return this.options.align; } - public getColumns(): Array { + /** Get column definitions. */ + public getColumns(): Array< + Column, GetRowValue> + > { return this.options.columns; } - public getColumn(index: number): Column { + /** Get column definition by column index. */ + public getColumn( + index: number, + ): Column, GetRowValue> { return this.options.columns[index] ??= new Column(); } + + /** Get header value parser. */ + public getHeaderValueParser(): + | ValueParser> + | undefined { + return this.options.headerValue; + } + + /** Get value parser. */ + public getCellValueParser(): ValueParser> | undefined { + return this.options.cellValue; + } + + /** Get header renderer. */ + public getHeaderRenderer(): Renderer | undefined { + return this.options.headerRenderer; + } + + /** Get cell renderer. */ + public getCellRenderer(): Renderer | undefined { + return this.options.cellRenderer; + } + + fillRows(enable = true): this { + this.options.isDataTable = enable; + return this; + } } diff --git a/table/test/__snapshots__/column_test.ts.snap b/table/test/__snapshots__/column_test.ts.snap new file mode 100644 index 000000000..0c53444f6 --- /dev/null +++ b/table/test/__snapshots__/column_test.ts.snap @@ -0,0 +1,13 @@ +export const snapshot = {}; + +snapshot[`[table] should call parser and renderer callback methods 1`] = ` +"┌──────────────┬──────────────┬──────────────┐ +│ \\x1b[35mFooa \\x1b[39m │ \\x1b[34mBarb \\x1b[39m │ \\x1b[33mBazc \\x1b[39m │ +├──────────────┼──────────────┼──────────────┤ +│ \\x1b[34mfoo bar baz1\\x1b[39m │ \\x1b[35mbaz2 \\x1b[39m │ \\x1b[32mbeep boop3 \\x1b[39m │ +├──────────────┼──────────────┼──────────────┤ +│ \\x1b[34mbaz1 \\x1b[39m │ \\x1b[35mbeep boop2 \\x1b[39m │ \\x1b[32mfoo bar baz3\\x1b[39m │ +├──────────────┼──────────────┼──────────────┤ +│ \\x1b[34mbeep boop1 \\x1b[39m │ \\x1b[35mfoo bar baz2\\x1b[39m │ \\x1b[32mbaz3 \\x1b[39m │ +└──────────────┴──────────────┴──────────────┘" +`; diff --git a/table/test/align_test.ts b/table/test/align_test.ts index b0f2fce7c..1576b0486 100644 --- a/table/test/align_test.ts +++ b/table/test/align_test.ts @@ -56,11 +56,11 @@ Deno.test("table - align - default direction", () => { const cell = new Cell("foo"); const row = new Row(cell); const table = new Table(row); - assertEquals(cell.getAlign(), "left"); - assertEquals(row.getAlign(), "left"); - assertEquals(table.getAlign(), "left"); - assertEquals(table[0][0].getAlign(), "left"); - assertEquals(table[0].getAlign(), "left"); + assertEquals(cell.getAlign(), undefined); + assertEquals(row.getAlign(), undefined); + assertEquals(table.getAlign(), undefined); + assertEquals(table[0][0].getAlign(), undefined); + assertEquals(table[0].getAlign(), undefined); }); Deno.test("table - align - override direction", () => { @@ -78,9 +78,9 @@ Deno.test("table - align - inherit direction", () => { const cell = new Cell("foo"); const row = new Row(cell); const table = new Table(row).align("right"); - assertEquals(cell.getAlign(), "left"); - assertEquals(row.getAlign(), "left"); + assertEquals(cell.getAlign(), undefined); + assertEquals(row.getAlign(), undefined); assertEquals(table.getAlign(), "right"); - assertEquals(table[0][0].getAlign(), "left"); - assertEquals(table[0].getAlign(), "left"); + assertEquals(table[0][0].getAlign(), undefined); + assertEquals(table[0].getAlign(), undefined); }); diff --git a/table/test/column_test.ts b/table/test/column_test.ts index 503421495..6326ad890 100644 --- a/table/test/column_test.ts +++ b/table/test/column_test.ts @@ -1,5 +1,6 @@ +import { colors } from "../../ansi/colors.ts"; import { Table } from "../table.ts"; -import { assertEquals } from "../../dev_deps.ts"; +import { assertEquals, assertSnapshot } from "../../dev_deps.ts"; const createTable = () => new Table() @@ -117,6 +118,31 @@ Deno.test("[table] should set padding on columns", () => { ); }); +Deno.test("[table] should call parser and renderer callback methods", async (t) => { + await assertSnapshot( + t, + createTable() + .columns([{ + headerValue: (value) => value + "a", + cellValue: (value) => value + "1", + headerRenderer: colors.magenta, + cellRenderer: colors.blue, + }, { + headerValue: (value) => value + "b", + cellValue: (value) => value + "2", + headerRenderer: colors.blue, + cellRenderer: colors.magenta, + }, { + headerValue: (value) => value + "c", + cellValue: (value) => value + "3", + headerRenderer: colors.yellow, + cellRenderer: colors.green, + }]) + .border(true) + .toString(), + ); +}); + Deno.test("[table] should set column options with column method", () => { const table = createTable(); table.getColumn(0)?.padding(5); diff --git a/table/utils.ts b/table/utils.ts index 8d5aacabe..110c0b22c 100644 --- a/table/utils.ts +++ b/table/utils.ts @@ -4,7 +4,7 @@ * @param length Max length of all words. * @param content The text content. */ -import { Cell, ICell } from "./cell.ts"; +import { Cell, CellOrValue, CellValue } from "./cell.ts"; import { stripColor } from "./deps.ts"; export function consumeWords(length: number, content: string): string { @@ -34,7 +34,7 @@ export function consumeWords(length: number, content: string): string { */ export function longest( index: number, - rows: ICell[][], + rows: Array>>, maxWidth?: number, ): number { const cellLengths = rows.map((row) => {