diff --git a/command/upgrade/provider.ts b/command/upgrade/provider.ts index 38e09d4f..e7f09192 100644 --- a/command/upgrade/provider.ts +++ b/command/upgrade/provider.ts @@ -154,7 +154,7 @@ export abstract class Provider { ); if (versions.length > this.maxListSize) { - const table = new Table().indent(indent); + const table = new Table>().indent(indent); const rowSize = Math.ceil(versions.length / maxCols); const colSize = Math.min(versions.length, maxCols); let versionIndex = 0; diff --git a/dev_deps.ts b/dev_deps.ts index baacb849..ed17860e 100644 --- a/dev_deps.ts +++ b/dev_deps.ts @@ -31,3 +31,9 @@ export { copy } from "https://deno.land/std@0.170.0/streams/conversion.ts"; /* 3rd party */ export { gt, lt } from "https://deno.land/x/semver@v1.4.1/mod.ts"; export { default as sinon } from "https://cdn.skypack.dev/sinon@v13.0.2?dts"; + +export { + assert as assertType, + type IsAny, + type IsExact, +} from "https://deno.land/x/conditional_type_checks@1.0.6/mod.ts"; diff --git a/examples/table/datatable.ts b/examples/table/datatable.ts new file mode 100755 index 00000000..013ae507 --- /dev/null +++ b/examples/table/datatable.ts @@ -0,0 +1,45 @@ +#!/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(); diff --git a/table/cell.ts b/table/cell.ts index fd4820eb..662b2d09 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 | void; + +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 8cc63b93..329a486c 100644 --- a/table/column.ts +++ b/table/column.ts @@ -1,69 +1,197 @@ -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 { + [ + // deno-lint-ignore ban-types + 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 47c6cd50..e4f7af77 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,19 @@ 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()), + ); + this.table.header(header as THeaderRow); + } + 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 +133,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 +157,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 +167,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 +193,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 +206,7 @@ export class TableLayout { const cell = row[colIndex] = this.createCell( row[colIndex] || null, row, + rowIndex, colIndex, ); @@ -183,40 +219,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 +460,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 +478,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 +493,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 +514,7 @@ export class TableLayout { return { current, - next: cell.clone(next), + next, }; } diff --git a/table/row.ts b/table/row.ts index 381ebe26..78eac9da 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 c33b2e86..59f00cbb 100644 --- a/table/table.ts +++ b/table/table.ts @@ -1,56 +1,212 @@ 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"; - -/** Border characters settings. */ -export type IBorderOptions = Partial; +import { GetRowValue, JsonData, Row, RowOrValue } from "./row.ts"; /** 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; +/** Border characters settings. */ +export type BorderOptions = Partial; + +// type AnyToTuple = 0 extends T & 1 ? [] : T; +// type UnknownToTuple = T extends unknown ? [] : T; +// +// type Foo = keyof unknown extends keyof unknown ? 1 : 2; + +// export type Columns< +// TRow, +// THeaderRow, +// > = number extends keyof THeaderRow ? { +// [Key in keyof THeaderRow]: ColumnOptions< +// Key extends keyof TRow ? TRow[Key] : unknown, +// Key extends keyof THeaderRow ? THeaderRow[Key] : unknown +// >; +// } : number extends keyof TRow ? { +// [Key in keyof TRow]: ColumnOptions< +// Key extends keyof TRow ? TRow[Key] : unknown, +// Key extends keyof THeaderRow ? THeaderRow[Key] : unknown +// >; +// } :Array>; + +// export type Columns< +// TRow, +// THeaderRow, +// > = keyof TRow | keyof THeaderRow extends infer Keys ? Keys extends number ? { +// [Key in Keys]: ColumnOptions< +// Key extends keyof TRow ? TRow[Key] : unknown, +// Key extends keyof THeaderRow ? THeaderRow[Key] : unknown +// >; +// } +// : Array> +// : never; + +type MakeTuple = T extends ReadonlyArray ? T : [T]; +type MakeArray = T extends ReadonlyArray ? T : ReadonlyArray; + +export type Columns< + TRow, + THeaderRow, +> = ColumnsDef; +// > = ColumnsDef, MakeArray>; + +// export type ColumnsDef< +// TRow, +// THeaderRow, +// > = unknown extends THeaderRow +// ? (Array>) +// : keyof TRow extends keyof THeaderRow +// ? ColumnsMap +// : keyof THeaderRow extends keyof TRow ? ColumnsMap +// : Array>; + +type IsArrayAndNotArray = T extends ReadonlyArray + ? (V extends ReadonlyArray ? false : true) + : false; + +type IsArrayAndArray = T extends ReadonlyArray + ? (V extends ReadonlyArray ? true : false) + : false; + +export type ColumnsDef< + TRow, + THeaderRow, +> = IsArrayAndNotArray extends true + ? ColumnsMap> + : IsArrayAndNotArray extends true + ? ColumnsMap, THeaderRow> + : IsArrayAndArray extends true + ? keyof TRow extends keyof THeaderRow + ? ColumnsMap + : keyof THeaderRow extends keyof TRow ? ColumnsMap + : ReadonlyArray> + : ReadonlyArray>; + +// export type ColumnsDef< +// TRow, +// THeaderRow, +// > = unknown extends TRow & THeaderRow +// ? Array> +// : unknown extends THeaderRow +// ? (TRow extends ReadonlyArray ? ColumnsMap : Array>) +// : keyof TRow extends keyof THeaderRow +// ? ColumnsMap +// : keyof THeaderRow extends keyof TRow ? ColumnsMap +// : Array>; + +// export type ColumnsDef< +// TRow, +// THeaderRow, +// > = unknown extends THeaderRow +// ? (keyof TRow extends number ? ColumnsMap +// : Array>) +// : keyof TRow extends keyof THeaderRow +// ? ColumnsMap +// : keyof THeaderRow extends keyof TRow ? ColumnsMap +// : Array>; + +// export type Columns< +// TRow, +// THeaderRow, +// > = keyof THeaderRow extends never ? (keyof THeaderRow extends never ? never +// : keyof THeaderRow extends keyof TRow +// ? ColumnsMap +// : Array>) +// : keyof TRow extends keyof THeaderRow +// ? ColumnsMap +// : keyof THeaderRow extends keyof TRow +// ? ColumnsMap +// : Array>; + +// export type Columns3< +// TRow, +// THeaderRow, +// > = +// keyof TRow extends keyof THeaderRow +// ? ColumnsMap +// : keyof THeaderRow extends keyof TRow +// ? ColumnsMap +// : Array>; + +// export type Columns< +// TRows, +// THeaderRows, +// > = +// // unknown extends TRows & THeaderRows +// // ? Array, Array<"unknown">>> +// // : unknown extends TRows +// // ? ColumnsMap, THeaderRows> +// // : unknown extends THeaderRows +// // ? ColumnsMap> +// // : +// keyof TRows extends keyof THeaderRows +// ? ColumnsMap +// : keyof THeaderRows extends keyof TRows +// ? ColumnsMap +// : Array>; + +type ColumnsMap = { + [Key in keyof TBaseRows]?: ColumnOptions< + Key extends keyof TRows ? TRows[Key] + : TRows extends ReadonlyArray ? TRows[0] + : unknown, + Key extends keyof THeaderRows ? THeaderRows[Key] + : THeaderRows extends ReadonlyArray ? THeaderRows[0] + : unknown + >; +}; /** Table representation. */ -export class Table extends Array { +export class Table< + TRow extends RowOrValue = unknown, + THeaderRow extends RowOrValue = unknown, +> 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>; /** * 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 +217,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, Array> { return new this().fromJson(rows); } @@ -69,7 +227,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 +236,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 +250,38 @@ 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, Array> { + return (this as unknown as Table, Array>) + .header(Object.keys(rows[0])) + .body(rows.map((row) => Object.values(row))); } - public columns(columns: Array): this { - this.options.columns = columns.map((column) => + /** + * Set column definitions. + * @param columns Array of columns or column options. + */ + public columns( + columns: Columns, + ): this { + const cols = columns as Array>; + this.options.columns = cols.map((column) => column instanceof Column ? column : Column.from(column) - ); + ) as Array, GetRowValue>>; 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,28 +297,69 @@ 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; + // } + + public header( + header: THeader, + ): Table { + const table = this as Table; + table.headerRow = header instanceof Row + ? header + : Row.from(header) as Row>; + 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 { + return this.fillRows().body(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; @@ -232,22 +452,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 +524,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 +542,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 +554,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 00000000..0c53444f --- /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 b0f2fce7..1576b048 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 50342149..50809fc3 100644 --- a/table/test/column_test.ts +++ b/table/test/column_test.ts @@ -1,5 +1,11 @@ +import { colors } from "../../ansi/colors.ts"; import { Table } from "../table.ts"; -import { assertEquals } from "../../dev_deps.ts"; +import { + assertEquals, + assertSnapshot, + assertType, + IsExact, +} from "../../dev_deps.ts"; const createTable = () => new Table() @@ -117,6 +123,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); @@ -146,3 +177,248 @@ Deno.test("[table] should set column options with column method", () => { .slice(1), ); }); + +/** Generic type tests */ + +Deno.test("[table] should have correct headerValue argument types", () => { + new Table() + .header([1, "2", new Date(), new RegExp(""), { foo: "bar" }] as const) + .columns([{ + headerValue: (...args) => { + assertType>(true); + return 1; + }, + }, { + headerValue: (...args) => { + assertType>(true); + return 1; + }, + }, { + headerValue: (...args) => { + assertType>(true); + return 1; + }, + }, { + headerValue: (...args) => { + assertType>(true); + return 1; + }, + }, { + headerValue: (...args) => { + assertType>(true); + return 1; + }, + }]); +}); + +Deno.test("[table] should have correct headerValue and cellValue argument types", () => { + new Table() + .header([1, "2", new Date(), new RegExp(""), { foo: "bar" }] as const) + .body([ + ["1", 2, 3, { beep: true }, [1]] as const, + ["1", 2, "3", { beep: true }, [1]] as const, + ["1", 2, 3, { beep: true }, [1]] as const, + ]) + .columns([{ + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }]); +}); + +Deno.test("[table] should have correct cellValue argument types", () => { + new Table() + .body([ + ["1", 2, 3, { beep: true }, [1]] as const, + ["1", 2, "3", { beep: true }, [1]] as const, + ["1", 2, 3, { beep: true }, [1]] as const, + ]) + .columns([{ + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }]); +}); + +Deno.test("[table] should have correct cellValue argument types for data table", () => { + type Data = { + readonly firstName: string; + readonly lastName: string; + readonly age: number; + readonly email: string; + }; + + new Table() + .body([ + { + 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", + }, + ]) + .columns([{ + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }]); +}); + +Deno.test("[table] should have correct cellValue argument types for data table with header", () => { + type Data = { + readonly firstName: string; + readonly lastName: string; + readonly age: number; + readonly email: string; + }; + + new Table() + .header([1, "2", 3] as const) + .body([ + { + 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", + }, + ]) + .columns([{ + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }, { + headerValue: (...args) => { + assertType>(true); + }, + cellValue: (...args) => { + assertType>(true); + }, + }]); +}); diff --git a/table/utils.ts b/table/utils.ts index 8d5aacab..110c0b22 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) => {