Skip to content

Commit

Permalink
feat: add some polish
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley committed Sep 9, 2024
1 parent 2dc2609 commit a2a1c81
Show file tree
Hide file tree
Showing 6 changed files with 83 additions and 91 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
"@oclif/core": "^4",
"@types/react": "^18.3.3",
"change-case": "^5.4.4",
"cli-truncate": "^4.0.0",
"ink": "^5.0.1",
"natural-orderby": "^3.0.2",
"object-hash": "^3.0.0",
"react": "^18.3.1",
"strip-ansi": "^7.1.0"
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"devDependencies": {
"@commitlint/config-conventional": "^19",
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export {makeTable} from './table.js'
export {makeTable, makeTables} from './table.js'
export type {TableProps} from './types.js'
78 changes: 59 additions & 19 deletions src/table.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@

/* eslint-disable react/prop-types */

import cliTruncate from 'cli-truncate'
import {Box, Text, render} from 'ink'
import {sha1} from 'object-hash'
import React from 'react'
import stripAnsi from 'strip-ansi'
import wrapAnsi from 'wrap-ansi'

import {BORDER_SKELETONS} from './skeletons.js'
import {
CellProps,
Column,
Config,
ContainerProps,
HeaderOptions,
Percentage,
RowConfig,
RowProps,
ScalarDict,
TableProps
} from './types.js';
import {allKeysInCollection, getColumns, getHeadings, intersperse, sortData, truncate, wrap} from './utils.js';
import {allKeysInCollection, getColumns, getHeadings, intersperse, sortData} from './utils.js';

/**
* Determines the configured width based on the provided width value.
Expand All @@ -30,18 +33,18 @@ import {allKeysInCollection, getColumns, getHeadings, intersperse, sortData, tru
* @param providedWidth - The width value provided.
* @returns The determined configured width.
*/
function determineConfiguredWidth(providedWidth: number | Percentage | undefined): number {
if (!providedWidth) return process.stdout.columns
function determineConfiguredWidth(providedWidth: number | Percentage | undefined, columns = process.stdout.columns): number {
if (!providedWidth) return columns

const num =
typeof providedWidth === 'string' && providedWidth.endsWith('%')
? Math.floor((Number.parseInt(providedWidth, 10) / 100) * process.stdout.columns)
? Math.floor((Number.parseInt(providedWidth, 10) / 100) * columns)
: typeof providedWidth === 'string'
? Number.parseInt(providedWidth, 10)
: providedWidth

if (num > process.stdout.columns) {
return process.stdout.columns
if (num > columns) {
return columns
}

return num
Expand All @@ -59,15 +62,16 @@ function determineWidthToUse<T>(columns: Column<T>[], configuredWidth: number):

export function Table<T extends ScalarDict>(props: TableProps<T>) {
const {
align = 'left',
borderStyle = 'all',
data,
filter,
horizontalAlignment = 'left',
maxWidth,
orientation = 'horizontal',
overflow = 'truncate',
padding = 1,
sort,
verticalAlignment = 'top',
} = props

const headerOptions = {bold: true, color: 'blue', ...props.headerOptions} satisfies HeaderOptions
Expand All @@ -86,50 +90,53 @@ export function Table<T extends ScalarDict>(props: TableProps<T>) {
const headings = getHeadings(config)

const dataComponent = row<T>({
align,
cell: Cell,
horizontalAlignment,
overflow,
padding,
props: {
alignItems: verticalAlignment === 'top' ? 'flex-start' : verticalAlignment === 'center' ? 'center' : 'flex-end',
},
skeleton: BORDER_SKELETONS[config.borderStyle].data,
})

const footerComponent = row<T>({
align,
cell: Skeleton,
horizontalAlignment,
overflow,
padding,
skeleton: BORDER_SKELETONS[config.borderStyle].footer,
})

const headerComponent = row<T>({
align,
cell: Skeleton,
horizontalAlignment,
overflow,
padding,
skeleton: BORDER_SKELETONS[config.borderStyle].header,
})

const {headerFooter} = BORDER_SKELETONS[config.borderStyle]
const headerFooterComponent = headerFooter ? row<T>({
align,
cell: Skeleton,
horizontalAlignment,
overflow,
padding,
skeleton: headerFooter,
}) : () => false

const headingComponent = row<T>({
align,
cell: Header,
horizontalAlignment,
overflow,
padding,
props: config.headerOptions,
skeleton: BORDER_SKELETONS[config.borderStyle].heading,
})

const separatorComponent = row<T>({
align,
cell: Skeleton,
horizontalAlignment,
overflow,
padding,
skeleton: BORDER_SKELETONS[config.borderStyle].separator,
Expand Down Expand Up @@ -191,7 +198,7 @@ export function Table<T extends ScalarDict>(props: TableProps<T>) {
*/
function row<T extends ScalarDict>(config: RowConfig): (props: RowProps<T>) => React.ReactNode {
// This is a component builder. We return a function.
const {align, overflow, padding, skeleton} = config
const {horizontalAlignment, overflow, padding, skeleton} = config

return (props) => {
const data = props.columns.map((column, colI) => {
Expand All @@ -212,8 +219,8 @@ function row<T extends ScalarDict>(config: RowConfig): (props: RowProps<T>) => R
// if the visible length of the value is greater than the column width, truncate or wrap
stripAnsi(String(value)).length >= column.width
? overflow === 'wrap'
? wrap(String(value), column.width - padding * 2, padding)
: truncate(String(value), column.width - (3 + padding * 2))
? wrapAnsi(String(value), column.width - padding * 2, {hard: true, trim: true}).replaceAll('\n', `${' '.repeat(padding)}\n${' '.repeat(padding)}`)
: cliTruncate(String(value), column.width - (3 + padding * 2))
: String(value)

const spaces =
Expand All @@ -223,10 +230,10 @@ function row<T extends ScalarDict>(config: RowConfig): (props: RowProps<T>) => R

let marginLeft: number
let marginRight: number
if (align === 'left') {
if (horizontalAlignment === 'left') {
marginLeft = padding
marginRight = spaces - marginLeft
} else if (align === 'center') {
} else if (horizontalAlignment === 'center') {
marginLeft = Math.floor(spaces / 2)
marginRight = Math.ceil(spaces / 2)
} else {
Expand Down Expand Up @@ -274,7 +281,12 @@ export function Header(props: React.PropsWithChildren) {
* Renders a cell in the table.
*/
export function Cell(props: CellProps) {
return <Text>{props.children}</Text>
return (
<Box {...props}>
<Text>{props.children}</Text>
</Box>

)
}

/**
Expand All @@ -299,3 +311,31 @@ export function makeTable<T extends ScalarDict>(options: TableProps<T>): void {
const instance = render(<Table {...options} />)
instance.unmount()
}

function Container(props: ContainerProps) {
return (
<Box columnGap={props.columnGap} alignItems={props.alignItems} rowGap={props.rowGap} margin={props.margin} flexWrap="wrap" flexDirection={props.direction ?? 'row'}>
{props.children}
</Box>
)
}

export function makeTables<T extends ScalarDict[]>(
tables: {[P in keyof T]: TableProps<T[P]>},
options?: Omit<ContainerProps, 'children'>
): void {
const columns = process.stdout.columns - ((options?.margin ?? 0) * 2)

const processed = tables.map((table) => ({
...table,
// adjust maxWidth to account for margin
maxWidth: determineConfiguredWidth(table.maxWidth, columns)
}))

const instance = render(
<Container {...options}>
{processed.map((table) => <Table key={sha1(table)} {...table} />)}
</Container>
)
instance.unmount()
}
21 changes: 18 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export type ScalarDict = {

export type CellProps = React.PropsWithChildren<{readonly column: number}>

export type ColumnAlignment = 'left' | 'right' | 'center'
export type HorizontalAlignment = 'left' | 'right' | 'center'
export type VerticalAlignment = 'top' | 'center' | 'bottom'

export interface ColumnProps<T> {
key: T
Expand Down Expand Up @@ -114,7 +115,7 @@ export type TableProps<T extends ScalarDict> = {
/**
* Align data in columns. Defaults to 'left'. Only applies to horizontal orientation.
*/
align?: ColumnAlignment
horizontalAlignment?: HorizontalAlignment
/**
* Apply a filter to each row in the table.
*/
Expand Down Expand Up @@ -167,6 +168,10 @@ export type TableProps<T extends ScalarDict> = {
* ```
*/
orientation?: 'horizontal' | 'vertical'
/**
* Vertical alignment of cell content. Defaults to 'top'. Only applies to horizontal orientation.
*/
verticalAlignment?: VerticalAlignment
}

export type Config<T> = {
Expand Down Expand Up @@ -205,7 +210,8 @@ export type RowConfig = {
}
overflow?: Overflow
props?: Record<string, unknown>
align?: ColumnAlignment
horizontalAlignment?: HorizontalAlignment
verticalAlignment?: VerticalAlignment
}

export type RowProps<T extends ScalarDict> = {
Expand All @@ -219,3 +225,12 @@ export type Column<T> = {
column: keyof T
width: number
}

export type ContainerProps = {
readonly alignItems?: 'flex-start' | 'flex-end' | 'center'
readonly children: React.ReactNode
readonly columnGap?: number
readonly direction?: 'row' | 'column'
readonly margin?: number
readonly rowGap?: number
}
23 changes: 0 additions & 23 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,29 +34,6 @@ export function sortData<T extends ScalarDict>(data: T[], sort?: Sort<T> | undef
return orderBy(data, identifiers, orders)
}

export const truncate = (value: string, length: number) => `${value.slice(0, length)}...`

// insert new line every x characters
export const wrap = (value: string, position: number, padding: number) => {
const chars = [...value]
const lines = []
let line = ''
let count = 0
for (const char of chars) {
if (count === position) {
lines.push(line)
line = ''
count = 0
}

line += char
count++
}

lines.push(line)
return lines.join(`${' '.repeat(padding)}\n${' '.repeat(padding)}`)
}

export function allKeysInCollection<T extends ScalarDict>(data: T[]): (keyof T)[] {
const keys = new Set<keyof T>()
for (const row of data) {
Expand Down
45 changes: 1 addition & 44 deletions test/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {config, expect} from 'chai'

import {intersperse, sortData, truncate, wrap} from '../src/utils.js'
import {intersperse, sortData} from '../src/utils.js'

config.truncateThreshold = 0

Expand Down Expand Up @@ -64,46 +64,3 @@ describe('sortData', () => {
expect(sortData(data, sort)).to.deep.equal(expected)
})
})

describe('truncate', () => {
it('should truncate string', () => {
const value = 'foobar'
const length = 3
const expected = 'foo...'
expect(truncate(value, length)).to.equal(expected)
})
})

describe('wrap', () => {
it('should wrap string', () => {
const value = 'foobar'
const position = 3
const padding = 0
const expected = 'foo\nbar'
expect(wrap(value, position, padding)).to.deep.equal(expected)
})

it('should wrap string with padding', () => {
const value = 'foobar'
const position = 3
const padding = 1
const expected = 'foo \n bar'
expect(wrap(value, position, padding)).to.deep.equal(expected)
})

it('should wrap string multiple times', () => {
const value = 'foobarbaz'
const position = 3
const padding = 0
const expected = 'foo\nbar\nbaz'
expect(wrap(value, position, padding)).to.deep.equal(expected)
})

it('should wrap string multiple times with padding', () => {
const value = 'foobarbaz'
const position = 3
const padding = 1
const expected = 'foo \n bar \n baz'
expect(wrap(value, position, padding)).to.deep.equal(expected)
})
})

0 comments on commit a2a1c81

Please sign in to comment.