diff --git a/src/classes/IOClient.ts b/src/classes/IOClient.ts index 18df25e..cceb291 100644 --- a/src/classes/IOClient.ts +++ b/src/classes/IOClient.ts @@ -22,7 +22,8 @@ import { } from './IOPromise' import IOError from './IOError' import spreadsheet from '../components/spreadsheet' -import { selectTable, displayTable } from '../components/table' +import displayTable from '../components/displayTable' +import selectTable from '../components/selectTable' import selectSingle from '../components/selectSingle' import search from '../components/search' import selectMultiple from '../components/selectMultiple' diff --git a/src/components/displayTable.ts b/src/components/displayTable.ts new file mode 100644 index 0000000..c982a2b --- /dev/null +++ b/src/components/displayTable.ts @@ -0,0 +1,72 @@ +import { z } from 'zod' +import Logger from '../classes/Logger' +import { tableRow, T_IO_PROPS, menuItem, T_IO_STATE } from '../ioSchema' +import { TableColumn } from '../types' +import { + columnsBuilder, + tableRowSerializer, + filterRows, + sortRows, + TABLE_DATA_BUFFER_SIZE, + missingColumnMessage, +} from '../utils/table' + +type PublicProps = Omit< + T_IO_PROPS<'DISPLAY_TABLE'>, + 'data' | 'columns' | 'totalRecords' +> & { + data: Row[] + columns?: (TableColumn | string)[] + rowMenuItems?: (row: Row) => z.infer[] +} + +export default function displayTable(logger: Logger) { + return function displayTable = any>( + props: PublicProps + ) { + const columns = columnsBuilder(props, column => + logger.error(missingColumnMessage('io.display.table')(column)) + ) + + // Rendering all rows on initialization is necessary for filtering and sorting + const data = props.data.map((row, index) => + tableRowSerializer({ + index, + row, + columns, + menuBuilder: props.rowMenuItems, + }) + ) + + return { + props: { + ...props, + data: data.slice(0, TABLE_DATA_BUFFER_SIZE), + totalRecords: data.length, + columns, + } as T_IO_PROPS<'DISPLAY_TABLE'>, + async onStateChange(newState: T_IO_STATE<'DISPLAY_TABLE'>) { + const filtered = filterRows({ + queryTerm: newState.queryTerm, + data, + }) + + const sorted = sortRows({ + data: filtered, + column: newState.sortColumn ?? null, + direction: newState.sortDirection ?? null, + }) + + return { + ...props, + data: sorted.slice( + newState.offset, + newState.offset + TABLE_DATA_BUFFER_SIZE + ), + totalRecords: sorted.length, + columns: columns.map(c => ({ label: c.label })), + } + }, + } + } +} diff --git a/src/components/selectTable.ts b/src/components/selectTable.ts new file mode 100644 index 0000000..01268f6 --- /dev/null +++ b/src/components/selectTable.ts @@ -0,0 +1,86 @@ +import { z } from 'zod' +import Logger from '../classes/Logger' +import { + tableRow, + T_IO_PROPS, + menuItem, + T_IO_RETURNS, + T_IO_STATE, +} from '../ioSchema' +import { TableColumn } from '../types' +import { + columnsBuilder, + filterRows, + tableRowSerializer, + sortRows, + TABLE_DATA_BUFFER_SIZE, + missingColumnMessage, +} from '../utils/table' + +type PublicProps = Omit, 'data' | 'columns'> & { + data: Row[] + columns?: (TableColumn | string)[] + rowMenuItems?: (row: Row) => z.infer[] +} + +export default function selectTable(logger: Logger) { + return function = any>( + props: PublicProps + ) { + type DataList = typeof props['data'] + + const columns = columnsBuilder(props, column => + logger.error(missingColumnMessage('io.select.table')(column)) + ) + + // Rendering all rows on initialization is necessary for filtering and sorting + const data = props.data.map((row, index) => + tableRowSerializer({ + index, + row, + columns, + menuBuilder: props.rowMenuItems, + }) + ) + + return { + props: { + ...props, + data: data.slice(0, TABLE_DATA_BUFFER_SIZE), + totalRecords: data.length, + columns, + }, + getValue(response: T_IO_RETURNS<'SELECT_TABLE'>) { + const indices = response.map(({ key }) => Number(key)) + const rows = props.data.filter((_, idx) => indices.includes(idx)) + return rows as DataList + }, + async onStateChange(newState: T_IO_STATE<'SELECT_TABLE'>) { + const filtered = filterRows({ queryTerm: newState.queryTerm, data }) + + const sorted = sortRows({ + data: filtered, + column: newState.sortColumn ?? null, + direction: newState.sortDirection ?? null, + }) + + let selectedKeys: string[] = [] + + if (newState.isSelectAll) { + selectedKeys = sorted.map(({ key }) => key) + } + + return { + ...props, + data: sorted.slice( + newState.offset, + newState.offset + TABLE_DATA_BUFFER_SIZE + ), + totalRecords: sorted.length, + selectedKeys, + columns, + } + }, + } + } +} diff --git a/src/components/table.ts b/src/components/table.ts deleted file mode 100644 index 3fffe71..0000000 --- a/src/components/table.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { z } from 'zod' -import { - T_IO_PROPS, - tableColumn, - tableRow, - internalTableRow, - T_IO_RETURNS, - serializableRecord, - menuItem, -} from '../ioSchema' -import { columnsBuilder, tableRowSerializer } from '../utils/table' -import Logger from '../classes/Logger' - -export type CellValue = string | number | boolean | null | Date | undefined - -export type ColumnResult = - | ({ - label: string | number | boolean | null | Date | undefined - value?: CellValue - } & ( - | { url: string } - | { action: string; params?: z.infer } - | {} - )) - | CellValue - -export interface Column extends z.input { - label: string - renderCell: (row: Row) => ColumnResult -} - -function missingColumnMessage(component: string) { - return (column: string) => - `Provided column "${column}" not found in data for ${component}` -} - -export function selectTable(logger: Logger) { - return function = any>( - props: Omit, 'data' | 'columns'> & { - data: Row[] - columns?: (Column | string)[] - rowMenuItems?: (row: Row) => z.infer[] - } - ) { - type DataList = typeof props['data'] - - const columns = columnsBuilder(props, column => - logger.error(missingColumnMessage('io.select.table')(column)) - ) - - const data = props.data.map((row, idx) => - tableRowSerializer(idx, row, columns, props.rowMenuItems) - ) - - return { - props: { ...props, data, columns }, - getValue(response: T_IO_RETURNS<'SELECT_TABLE'>) { - const indices = response.map(row => - Number((row as z.infer).key) - ) - - const rows = props.data.filter((_, idx) => indices.includes(idx)) - - return rows as DataList - }, - } - } -} - -export function displayTable(logger: Logger) { - return function displayTable = any>( - props: Omit, 'data' | 'columns'> & { - data: Row[] - columns?: (Column | string)[] - rowMenuItems?: (row: Row) => z.infer[] - } - ) { - const columns = columnsBuilder(props, column => - logger.error(missingColumnMessage('io.display.table')(column)) - ) - - const data = props.data.map((row, idx) => - tableRowSerializer(idx, row, columns, props.rowMenuItems) - ) - - return { - props: { - ...props, - data, - columns, - }, - } - } -} diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index 200a94e..31902dd 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -6,16 +6,12 @@ import { } from '../../types' import editEmailForUser from './editEmail' import { fakeDb, mapToIntervalUser, sleep } from '../utils/helpers' -import { - table_basic, - table_custom_columns, - table_custom, - table_actions, -} from './selectFromTable' +import * as table_actions from './table' import unauthorized from './unauthorized' import './ghostHost' import { generateS3Urls } from '../utils/upload' import fs from 'fs' +import { ActionGroup } from '../../experimental' const actionLinks: IntervalActionHandler = async () => { await io.group([ @@ -232,10 +228,6 @@ const interval = new Interval({ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed sit amet quam in lorem sagittis accumsan malesuada nec mauris. Nulla cursus dolor id augue sodales, et consequat elit mattis. Suspendisse nec sollicitudin ex. Pellentesque laoreet nulla nec malesuada consequat. Donec blandit leo id tincidunt tristique. Mauris vehicula metus sed ex bibendum, nec bibendum urna tincidunt. Curabitur porttitor euismod velit sed interdum. Suspendisse at dapibus eros. Vestibulum varius, est vel luctus pellentesque, risus lorem ullamcorper est, a ullamcorper metus dolor eget neque. Donec sit amet nulla tempus, fringilla magna eu, bibendum tortor. Nam pulvinar diam id vehicula posuere. Praesent non turpis et nibh dictum suscipit non nec ante. Phasellus vulputate egestas nisl a dapibus. Duis augue lorem, mattis auctor condimentum a, convallis sed elit. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Pellentesque bibendum, magna vel pharetra fermentum, eros mi vulputate enim, in consectetur est quam quis felis.', } }, - table_actions, - table_basic, - table_custom, - table_custom_columns, // 'progress-through-long-list': async io => { // const resp = await io.experimental.progressThroughList( // 'Here are some items', @@ -961,6 +953,12 @@ const interval = new Interval({ return 'All done!' }, }, + groups: { + tables: new ActionGroup({ + name: 'Tables', + actions: table_actions, + }), + }, }) interval.listen() diff --git a/src/examples/basic/table.ts b/src/examples/basic/table.ts new file mode 100644 index 0000000..339d69e --- /dev/null +++ b/src/examples/basic/table.ts @@ -0,0 +1,233 @@ +import { IntervalActionDefinition } from '@interval/sdk/src/types' +import { IntervalActionHandler } from '../..' +import { faker } from '@faker-js/faker' + +function generateRows(count: number) { + return Array(count) + .fill(null) + .map((_, i) => ({ + id: i, + email: faker.internet.email(), + description: faker.helpers.arrayElement([ + faker.random.word(), + faker.random.words(), + faker.lorem.paragraph(), + ]), + number: faker.datatype.number(100), + boolean: faker.datatype.boolean(), + date: faker.datatype.datetime(), + })) +} + +export const no_pagination: IntervalActionHandler = async io => { + const data = generateRows(5) + + await io.display.table('Display users', { + data, + defaultPageSize: 50, + }) +} + +export const paginated: IntervalActionHandler = async io => { + const data = generateRows(50) + + await io.display.table('Display users', { + data, + defaultPageSize: 10, + }) +} + +export const large_table: IntervalActionDefinition = { + name: '10k rows', + handler: async io => { + const data = generateRows(10_000) + + await io.display.table('Display users', { + data, + defaultPageSize: Infinity, + }) + }, +} + +export const display_table: IntervalActionHandler = async io => { + const data = generateRows(50) + + await io.display.table('Display users', { + data, + defaultPageSize: 50, + columns: [ + 'id', + 'description', + 'boolean', + 'date', + { + label: 'renderCell', + renderCell: row => + `${String(row.description).split(' ')[0]} ${row.number}`, + }, + { + label: 'Link', + renderCell: row => ({ url: '#', label: row.email }), + }, + ], + rowMenuItems: row => [ + { + label: 'Edit', + action: 'edit_user', + params: { email: row.email }, + }, + ], + }) +} + +export const select_table: IntervalActionHandler = async io => { + faker.seed(0) + + const data = generateRows(50_000) + + const selected = await io.select.table('Display users', { + data, + defaultPageSize: 500, + columns: [ + 'id', + 'description', + 'number', + 'boolean', + 'date', + { + label: 'renderCell', + renderCell: row => + `${String(row.description).split(' ')[0]} ${row.number}`, + }, + { + label: 'Link', + renderCell: row => ({ url: '#', label: row.email }), + }, + ], + minSelections: 1, + rowMenuItems: row => [ + { + label: 'Edit', + action: 'edit_user', + params: { email: row.email }, + }, + ], + }) + + await io.display.table('Display users', { + data: selected, + columns: [ + 'description', + 'number', + 'boolean', + 'date', + { + label: 'renderCell', + renderCell: row => `${row.description} ${row.number}`, + }, + { + label: 'Edit', + renderCell: row => ({ url: '#', label: row.email }), + }, + ], + }) +} + +export const table_custom: IntervalActionHandler = async io => { + const options = [ + 'id', + 'name', + 'email', + 'url', + 'number', + 'paragraph', + 'address1', + 'address2', + 'city', + 'state', + 'zip', + ].map(f => ({ label: f, value: f })) + + const [rowsCount, fields, tableType, orientation] = await io.group([ + io.input.number('Number of rows', { defaultValue: 50 }), + io.select.multiple('Fields', { + options: options, + defaultValue: options, + }), + io.select.single('Table type', { + options: [ + { label: 'Display', value: 'display' }, + { label: 'Select', value: 'select' }, + ], + defaultValue: { label: 'Display', value: 'display' }, + }), + io.select.single('Orientation', { + options: [ + { label: 'Horizontal', value: 'horizontal' }, + { label: 'Vertical', value: 'vertical' }, + ], + defaultValue: { label: 'Horizontal', value: 'horizontal' }, + helpText: + 'Warning: Vertical orientation is not supported for select tables; it will be ignored', + }), + ]) + + const rows: { [key: string]: any }[] = [] + for (let i = 0; i < rowsCount; i++) { + const row: typeof rows[0] = {} + for (const field of fields) { + switch (field.value) { + case 'id': + row[field.value] = faker.datatype.uuid() + break + case 'name': + row[field.value] = faker.name.findName() + break + case 'email': + row[field.value] = faker.internet.email() + break + case 'url': + row[field.value] = faker.internet.url() + break + case 'number': + row[field.value] = faker.datatype.number() + break + case 'paragraph': + row[field.value] = faker.lorem.paragraph() + break + case 'address1': + row[field.value] = faker.address.streetAddress() + break + case 'address2': + row[field.value] = faker.address.secondaryAddress() + break + case 'city': + row[field.value] = faker.address.city() + break + case 'state': + row[field.value] = faker.address.state() + break + case 'zip': + row[field.value] = faker.address.zipCode() + break + default: + break + } + } + rows.push(row) + } + + if (tableType.value === 'display') { + await io.display.table('Table', { + data: rows, + orientation: orientation.value as 'horizontal' | 'vertical', + }) + } else { + const [selections] = await io.select.table('Select a person', { + data: rows, + minSelections: 1, + maxSelections: 3, + }) + await io.display.object('Selected', { data: selections }) + } +} diff --git a/src/ioSchema.ts b/src/ioSchema.ts index 615ef78..34fad56 100644 --- a/src/ioSchema.ts +++ b/src/ioSchema.ts @@ -189,6 +189,10 @@ export const internalTableRow = z.object({ key: z.string(), data: tableRow, menu: z.array(menuItem).optional(), + // filterValue is a string we compile when we render each row, allowing us to quickly + // filter array items without having to search all object keys for the query term. + // It is not sent to the client. + filterValue: z.string().optional(), }) export const tableColumn = z.object({ @@ -444,15 +448,29 @@ export const ioSchema = { SELECT_TABLE: { props: z.object({ helpText: z.optional(z.string()), - columns: z.optional(z.array(internalTableColumn)), + columns: z.array(internalTableColumn), data: z.array(internalTableRow), defaultPageSize: z.number().optional(), minSelections: z.optional(z.number().int().min(0)), maxSelections: z.optional(z.number().positive().int()), disabled: z.optional(z.boolean().default(false)), + //== private props + // added in v0.28, optional until required by all active versions + totalRecords: z.optional(z.number().int()), + selectedKeys: z.array(z.string()).default([]), }), - state: z.null(), - returns: z.array(internalTableRow), + state: z.object({ + queryTerm: z.string(), + sortColumn: z.string().nullish(), + sortDirection: z.enum(['asc', 'desc']).nullish(), + offset: z.number().int().default(0), + isSelectAll: z.boolean().default(false), + }), + // replaced full rows with just keys in v0.28 + returns: z.union([ + z.array(internalTableRow), + z.array(z.object({ key: z.string() })), + ]), }, SELECT_SINGLE: { props: z.object({ @@ -524,12 +542,20 @@ export const ioSchema = { DISPLAY_TABLE: { props: z.object({ helpText: z.optional(z.string()), - columns: z.optional(z.array(internalTableColumn)), + columns: z.array(internalTableColumn), data: z.array(internalTableRow), orientation: z.enum(['vertical', 'horizontal']).default('horizontal'), defaultPageSize: z.number().optional(), + //== private props + // added in v0.28, optional until required by all active versions + totalRecords: z.optional(z.number().int()), + }), + state: z.object({ + queryTerm: z.string(), + sortColumn: z.string().nullish(), + sortDirection: z.enum(['asc', 'desc']).nullish(), + offset: z.number().int().default(0), }), - state: z.null(), returns: z.null(), }, DISPLAY_PROGRESS_STEPS: { diff --git a/src/types.ts b/src/types.ts index 39953f1..ae7c3f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,8 @@ import type { T_IO_INPUT_METHOD_NAMES, LinkProps, ButtonTheme, + serializableRecord, + tableColumn, } from './ioSchema' import type { HostSchema } from './internalRpcSchema' import type { IOClient, IOClientRenderValidator } from './classes/IOClient' @@ -260,3 +262,20 @@ export type ButtonConfig = { export type GroupConfig = { continueButton: ButtonConfig } + +export type TableCellValue = string | number | boolean | null | Date | undefined + +export type TableColumnResult = + | { + label: string | number | boolean | null | Date | undefined + value?: TableCellValue + url?: string + action?: string + params?: z.infer + } + | TableCellValue + +export interface TableColumn extends z.input { + label: string + renderCell: (row: Row) => TableColumnResult +} diff --git a/src/utils/table.ts b/src/utils/table.ts index 21c19ae..f33983e 100644 --- a/src/utils/table.ts +++ b/src/utils/table.ts @@ -6,7 +6,8 @@ import { menuItem, } from '../ioSchema' import { z } from 'zod' -import Logger from '../classes/Logger' + +export const TABLE_DATA_BUFFER_SIZE = 500 /** * Generates column headers from rows if no columns are provided. @@ -53,36 +54,143 @@ export function columnsWithoutRender( return columns.map(({ renderCell, ...column }) => column) } +const dateFormatter = new Intl.DateTimeFormat('en-US') + +type RenderedTableRow = { + [key: string]: ReturnType['renderCell']> +} + /** * Applies cell renderers to a row. */ -export function tableRowSerializer>( - idx: number, - row: T, - columns: z.infer[], +export function tableRowSerializer>({ + index, + row, + columns, + menuBuilder, +}: { + index: number + row: T + columns: z.infer[] menuBuilder?: (row: T) => z.infer[] -): z.infer { - const key = idx.toString() +}): z.infer { + const key = index.toString() - const finalRow: { [key: string]: any } = {} + const renderedRow: RenderedTableRow = {} + const filterValues: string[] = [] for (let i = 0; i < columns.length; i++) { const col = columns[i] const val = col.renderCell(row) ?? null - if ( - !!val && - typeof val === 'object' && - 'label' in val && - val.label === undefined - ) { - val.label = null + + if (!!val && typeof val === 'object' && 'label' in val) { + if (val.label === undefined) { + val.label = null + } else if (val.label) { + filterValues.push(String(val.label)) + } + } else if (val instanceof Date) { + filterValues.push(dateFormatter.format(val)) + } else { + filterValues.push(String(val)) } - finalRow[i.toString()] = val + + renderedRow[i.toString()] = val } return { key, - data: finalRow, + data: renderedRow, + filterValue: filterValues.join(' ').toLowerCase(), menu: menuBuilder ? menuBuilder(row) : undefined, } } + +type IsomorphicTableRow = { + data: Record + key: string + filterValue?: string +} + +export function sortRows({ + data, + column, + direction, +}: { + data: T[] + column: string | null + direction: 'asc' | 'desc' | null +}): T[] { + if (column === null || direction === null) { + return data.sort((a, b) => (Number(a.key) > Number(b.key) ? 1 : -1)) + } + + return data.sort((a, b) => { + if (column === null) return 0 + + const sortA = getSortableValue(direction === 'desc' ? b : a, column) ?? null + const sortB = getSortableValue(direction === 'desc' ? a : b, column) ?? null + + if (sortA === null) return 1 + if (sortB === null) return -1 + + if (typeof sortA === 'string' && typeof sortB === 'string') { + return sortA.localeCompare(sortB, undefined, { numeric: true }) + } + if (sortA < sortB) return -1 + if (sortA > sortB) return 1 + + return 0 + }) +} + +function getSortableValue( + row: T, + sortByColumn: string +): string | null { + let sortVal + + if (row !== null && 'data' in row && row.data) { + if (sortByColumn in row.data) { + sortVal = (row.data as Record)[sortByColumn] ?? null + } + } + + if (sortVal && typeof sortVal === 'object') { + if ('value' in sortVal) { + return sortVal.value + } + if ('label' in sortVal) { + return sortVal.label + } + } + + return sortVal +} + +export function filterRows({ + queryTerm, + data, +}: { + queryTerm: string + data: T[] +}): Omit[] { + if (!queryTerm) return data + + return ( + data + .filter(row => { + if ('filterValue' in row && typeof row.filterValue === 'string') { + return row.filterValue.includes(queryTerm.toLowerCase()) + } + return true + }) + // filterValue is unnecessary beyond this point + .map(({ filterValue, ...row }) => row) + ) +} + +export function missingColumnMessage(component: string) { + return (column: string) => + `Provided column "${column}" not found in data for ${component}` +}