diff --git a/examples/basic.ts b/examples/basic.ts index 5694008..28d8669 100644 --- a/examples/basic.ts +++ b/examples/basic.ts @@ -1,5 +1,4 @@ import ansis from 'ansis' -import {pathToFileURL} from 'node:url' import terminalLink from 'terminal-link' import {makeTable} from '../src/index.js' @@ -870,9 +869,10 @@ const versions = [ }, ].map((v) => ({ ...v, - location: v.location.startsWith('http') - ? terminalLink(new URL(v.location).pathname.split('/').at(-1) ?? v.location, v.location) - : v.location, + // location: v.location.startsWith('http') + // ? terminalLink(new URL(v.location).pathname.split('/').at(-1) ?? v.location, v.location) + // : v.location, + num: 1, })) const deployResult = [ @@ -1844,7 +1844,7 @@ const deployResult = [ makeTable({ borderStyle: 'headers-only-with-underline', - columns: ['version', 'location', 'channel'], + columns: ['version', 'channel', 'location'], data: versions, // filter: (row) => /^.+$/.test(row.channel), headerOptions: { @@ -1853,15 +1853,26 @@ makeTable({ formatter: 'capitalCase', }, overflow: 'wrap', - // sort: { - // channel: 'desc', - // }, + sort: { + channel(a, b) { + // sort in this order: latest, latest-rc, nightly, qa, undefined + if (!a) return 1 + if (!b) return -1 + + const order = ['latest', 'latest-rc', 'nightly', 'qa'] + return order.indexOf(a) - order.indexOf(b) + }, + num(a, b) { + return a - b + }, + }, }) -// console.log() +console.log() makeTable({ - align: 'left', + // align: 'center', borderStyle: 'headers-only-with-underline', - columns: ['state', 'fullName', 'type'], + // borderStyle: 'all', + columns: ['state', 'fullName', 'type', {key: 'filePath', name: 'Path'}], data: deployResult, filter: (row) => row.state === 'Changed' && row.type.startsWith('A'), headerOptions: { diff --git a/examples/styles.ts b/examples/styles.ts new file mode 100644 index 0000000..900e634 --- /dev/null +++ b/examples/styles.ts @@ -0,0 +1,36 @@ +import ansis from 'ansis' + +import {makeTable} from '../src/index.js' +import {BORDER_STYLES} from '../src/skeletons.js' + +const data = [ + { + age: 20, + id: '36329', + name: 'Alice', + }, + { + age: 21, + id: '49032', + name: 'Bob', + }, + { + age: 22, + id: '51786', + name: 'Charlie', + }, +] + +for (const borderStyle of BORDER_STYLES) { + console.log(ansis.bold(borderStyle)) + makeTable({ + align: 'center', + borderStyle, + columns: ['id', {key: 'name', name: 'First Name'}, 'age'], + data, + headerOptions: { + formatter: 'capitalCase', + }, + }) + console.log() +} diff --git a/src/skeletons.ts b/src/skeletons.ts index e9131d1..320c607 100644 --- a/src/skeletons.ts +++ b/src/skeletons.ts @@ -1,14 +1,17 @@ -export type BorderStyle = - | 'all' - | 'headers-only-with-outline' - | 'headers-only-with-underline' - | 'headers-only' - | 'horizontal-with-outline' - | 'horizontal' - | 'none' - | 'outline' - | 'vertical-with-outline' - | 'vertical' +export const BORDER_STYLES = [ + 'all', + 'headers-only-with-outline', + 'headers-only-with-underline', + 'headers-only', + 'horizontal-with-outline', + 'horizontal', + 'none', + 'outline', + 'vertical-with-outline', + 'vertical', +] as const + +export type BorderStyle = (typeof BORDER_STYLES)[number] type Skeleton = { cross: string diff --git a/src/table.tsx b/src/table.tsx index 787a52e..a256fc2 100644 --- a/src/table.tsx +++ b/src/table.tsx @@ -7,9 +7,29 @@ import React from 'react' import stripAnsi from 'strip-ansi' import {BORDER_SKELETONS} from './skeletons.js' -import {CellProps, Column, Config, Percentage, RowConfig, RowProps, ScalarDict, TableProps} from './types.js'; +import { + CellProps, + Column, + Config, + HeaderOptions, + Percentage, + RowConfig, + RowProps, + ScalarDict, + TableProps +} from './types.js'; import {allKeysInCollection, getColumns, getHeadings, intersperse, sortData, truncate, wrap} from './utils.js'; +/** + * Determines the configured width based on the provided width value. + * If no width is provided, it returns the width of the current terminal. + * If the provided width is a percentage, it calculates the width based on the percentage of the terminal width. + * If the provided width is a number, it returns the provided width. + * If the calculated width is greater than the terminal width, it returns the terminal width. + * + * @param providedWidth - The width value provided. + * @returns The determined configured width. + */ function determineConfiguredWidth(providedWidth: number | Percentage | undefined): number { if (!providedWidth) return process.stdout.columns @@ -37,74 +57,80 @@ function determineWidthToUse(columns: Column[], configuredWidth: number): return tableWidth < configuredWidth ? configuredWidth : tableWidth } -export function Table(props: Pick, 'data'> & Partial>) { +export function Table(props: TableProps) { const { align = 'left', borderStyle = 'all', data, filter, - headerOptions = {bold: true, color: 'blue'}, maxWidth, overflow = 'truncate', padding = 1, sort, } = props + const headerOptions = {bold: true, color: 'blue', ...props.headerOptions} satisfies HeaderOptions + const processedData = sortData(filter ? data.filter((row) => filter(row)) : data, sort) const config: Config = { - align, borderStyle, columns: props.columns ?? allKeysInCollection(data), - data, + data: processedData, headerOptions, maxWidth: determineConfiguredWidth(maxWidth), overflow, padding, } + const columns = getColumns(config) const headings = getHeadings(config) - const processedData = sortData(filter ? data.filter((row) => filter(row)) : data, sort) const dataComponent = row({ + align, cell: Cell, - overflow: config.overflow, - padding: config.padding, + overflow, + padding, skeleton: BORDER_SKELETONS[config.borderStyle].data, }) const footerComponent = row({ + align, cell: Skeleton, - overflow: config.overflow, - padding: config.padding, + overflow, + padding, skeleton: BORDER_SKELETONS[config.borderStyle].footer, }) const headerComponent = row({ + align, cell: Skeleton, - overflow: config.overflow, - padding: config.padding, + overflow, + padding, skeleton: BORDER_SKELETONS[config.borderStyle].header, }) const {headerFooter} = BORDER_SKELETONS[config.borderStyle] const headerFooterComponent = headerFooter ? row({ + align, cell: Skeleton, - overflow: config.overflow, - padding: config.padding, + overflow, + padding, skeleton: headerFooter, }) : () => false const headingComponent = row({ + align, cell: Header, - overflow: config.overflow, - padding: config.padding, + overflow, + padding, props: config.headerOptions, skeleton: BORDER_SKELETONS[config.borderStyle].heading, }) const separatorComponent = row({ + align, cell: Skeleton, - overflow: config.overflow, - padding: config.padding, + overflow, + padding, skeleton: BORDER_SKELETONS[config.borderStyle].separator, }) @@ -135,7 +161,7 @@ export function Table(props: Pick, 'data'> & */ function row(config: RowConfig): (props: RowProps) => React.ReactNode { // This is a component builder. We return a function. - const {overflow, padding, skeleton} = config + const {align, overflow, padding, skeleton} = config return (props) => { const data = props.columns.map((column, colI) => { @@ -164,12 +190,13 @@ function row(config: RowConfig): (props: RowProps) => R overflow === 'wrap' ? column.width - stripAnsi(v).split('\n')[0].trim().length : column.width - stripAnsi(v).length + let marginLeft: number let marginRight: number - if (column.align === 'left') { + if (align === 'left') { marginLeft = padding marginRight = spaces - marginLeft - } else if (column.align === 'center') { + } else if (align === 'center') { marginLeft = Math.floor(spaces / 2) marginRight = Math.ceil(spaces / 2) } else { diff --git a/src/types.ts b/src/types.ts index 5ba5ade..ff13775 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,14 @@ export type HeaderOptions = { formatter?: HeaderFormatter } +type Overflow = 'wrap' | 'truncate' + +type SortOrder = 'asc' | 'desc' | ((valueA: T, valueB: T) => number) + +export type Sort = { + [K in keyof T]?: SortOrder +} + export type TableProps = { /** * List of values (rows). @@ -94,7 +102,7 @@ export type TableProps = { /** * Overflow behavior for cells. Defaults to 'truncate'. */ - overflow?: 'wrap' | 'truncate' + overflow?: Overflow /** * Styling options for the column headers */ @@ -114,7 +122,7 @@ export type TableProps = { /** * Sort the data in the table. * - * Each key in the object should correspond to a column in the table. The value can be 'asc' or 'desc'. + * Each key in the object should correspond to a column in the table. The value can be 'asc', 'desc', or a custom sort function. * * The order of the keys determines the order of the sorting. The first key is the primary sort key, the second key is the secondary sort key, and so on. * @@ -134,18 +142,20 @@ export type TableProps = { * * // sort by name in ascending order and age in descending order * makeTable({data, sort: {name: 'asc', age: 'desc'}}) + * + * // sort by name in ascending order and age in descending order using a custom sort function + * makeTable({data, sort: {name: 'asc', age: (a, b) => b - a}}) * ``` */ - sort?: Partial> + sort?: Sort } export type Config = { - align: ColumnAlignment columns: (keyof T | AllColumnProps)[] data: T[] padding: number maxWidth: number - overflow: 'wrap' | 'truncate' + overflow: Overflow headerOptions: HeaderOptions borderStyle: BorderStyle } @@ -174,8 +184,9 @@ export type RowConfig = { cross: string line: string } - overflow?: 'wrap' | 'truncate' + overflow?: Overflow props?: Record + align?: ColumnAlignment } export type RowProps = { @@ -188,5 +199,4 @@ export type Column = { key: string column: keyof T width: number - align: ColumnAlignment } diff --git a/src/utils.ts b/src/utils.ts index cfca8bb..b5a62fd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,7 +2,7 @@ import {camelCase, capitalCase, constantCase, kebabCase, pascalCase, sentenceCas import {orderBy} from 'natural-orderby' import stripAnsi from 'strip-ansi' -import {Column, ColumnProps, Config, ScalarDict} from './types.js' +import {Column, ColumnProps, Config, ScalarDict, Sort} from './types.js' /** * Intersperses a list of elements with another element. @@ -27,13 +27,10 @@ export function intersperse(intersperser: (index: number) => I, elements: return interspersed } -export function sortData( - data: T[], - sort?: Partial> | undefined, -): T[] { +export function sortData(data: T[], sort?: Sort | undefined): T[] { if (!sort) return data const identifiers = Object.keys(sort) - const orders = identifiers.map((i) => sort[i]) as ['asc' | 'desc'] + const orders = Object.values(sort) return orderBy(data, identifiers, orders) } @@ -72,7 +69,7 @@ export function allKeysInCollection(data: T[]): (keyof T)[ } export function getColumns(config: Config): Column[] { - const {align, columns, maxWidth, padding} = config + const {columns, maxWidth, padding} = config const widths: Column[] = columns.map((propsOrKey) => { const props: ColumnProps = typeof propsOrKey === 'object' ? propsOrKey : {key: propsOrKey} @@ -90,7 +87,6 @@ export function getColumns(config: Config): Column[] const width = Math.max(...data, header) + padding * 2 return { - align: align ?? 'left', column: key, key: String(key), width,