diff --git a/src/classes/IntervalError.ts b/src/classes/IntervalError.ts new file mode 100644 index 0000000..a3ce7cc --- /dev/null +++ b/src/classes/IntervalError.ts @@ -0,0 +1,5 @@ +export default class IntervalError extends Error { + constructor(message: string) { + super(message) + } +} diff --git a/src/components/displayImage.ts b/src/components/displayImage.ts index 2b4adb2..fb809c2 100644 --- a/src/components/displayImage.ts +++ b/src/components/displayImage.ts @@ -1,7 +1,5 @@ import { T_IO_PROPS, ImageSize } from '../ioSchema' -import { IntervalError } from '..' - -const MAX_BUFFER_SIZE_MB = 50 +import { bufferToDataUrl } from '../utils/image' export default function displayImage( props: { @@ -24,41 +22,10 @@ export default function displayImage( props.height = size ? size : props.height if ('buffer' in props) { - if (Buffer.byteLength(props.buffer) > MAX_BUFFER_SIZE_MB * 1000 * 1000) { - throw new IntervalError( - `Buffer for io.display.image is too large, must be under ${MAX_BUFFER_SIZE_MB} MB` - ) - } - - const data = props.buffer.toString('base64') - - // using first character as a simple check for common image formats: - // https://stackoverflow.com/questions/27886677/javascript-get-extension-from-base64-image/50111377#50111377 - // image/unknown actually seems to just work for all the types I've - // encountered, but we can expand this switch if we need to - let mime - switch (data[0]) { - case 'i': - mime = 'image/png' - break - case 'R': - mime = 'image/gif' - break - case '/': - mime = 'image/jpeg' - break - case 'U': - mime = 'image/webp' - break - default: - mime = 'image/unknown' - break - } - return { props: { ...props, - url: `data:${mime};base64,${data}`, + url: bufferToDataUrl(props.buffer), }, } } else { diff --git a/src/components/displayTable.ts b/src/components/displayTable.ts index c982a2b..3e2c451 100644 --- a/src/components/displayTable.ts +++ b/src/components/displayTable.ts @@ -35,6 +35,7 @@ export default function displayTable(logger: Logger) { row, columns, menuBuilder: props.rowMenuItems, + logger, }) ) diff --git a/src/components/selectTable.ts b/src/components/selectTable.ts index 01268f6..ccde96d 100644 --- a/src/components/selectTable.ts +++ b/src/components/selectTable.ts @@ -40,6 +40,7 @@ export default function selectTable(logger: Logger) { row, columns, menuBuilder: props.rowMenuItems, + logger, }) ) diff --git a/src/examples/basic/table.ts b/src/examples/basic/table.ts index 339d69e..0281c1c 100644 --- a/src/examples/basic/table.ts +++ b/src/examples/basic/table.ts @@ -7,6 +7,7 @@ function generateRows(count: number) { .fill(null) .map((_, i) => ({ id: i, + name: `${faker.name.firstName()} ${faker.name.lastName()}`, email: faker.internet.email(), description: faker.helpers.arrayElement([ faker.random.word(), @@ -16,6 +17,12 @@ function generateRows(count: number) { number: faker.datatype.number(100), boolean: faker.datatype.boolean(), date: faker.datatype.datetime(), + image: faker.image.imageUrl( + 480, + Math.random() < 0.25 ? 300 : 480, + undefined, + true + ), })) } @@ -57,6 +64,17 @@ export const display_table: IntervalActionHandler = async io => { defaultPageSize: 50, columns: [ 'id', + { + label: 'User', + renderCell: row => ({ + label: row.name, + image: { + alt: 'Alt tag', + url: row.image, + size: 'small', + }, + }), + }, 'description', 'boolean', 'date', @@ -231,3 +249,128 @@ export const table_custom: IntervalActionHandler = async io => { await io.display.object('Selected', { data: selections }) } } + +export const image_viewer: IntervalActionHandler = async io => { + const data = Array(50) + .fill(null) + .map((_, i) => { + const [width, height, crazyW, crazyH] = [ + faker.datatype.number({ min: 500, max: 700 }), + faker.datatype.number({ min: 200, max: 400 }), + faker.datatype.number({ min: 100, max: 999 }), + faker.datatype.number({ min: 100, max: 999 }), + ] + + return { + id: i, + name: faker.name.findName(), + square: faker.image.avatar(), + width, + height, + crazyW, + crazyH, + wide: faker.image.imageUrl(width, height, undefined, true), + tall: faker.image.imageUrl(height, width, undefined, true), + crazy: faker.image.imageUrl(crazyW, crazyH, undefined, true), + } + }) + + await io.display.table('Images', { + data, + defaultPageSize: 50, + columns: [ + 'id', + { + label: 'Square', + renderCell: row => ({ + image: { + alt: 'Alt tag', + url: row.square, + size: 'small', + }, + }), + }, + { + label: 'Tall', + renderCell: row => ({ + label: `${row.height} x ${row.width}`, + image: { + alt: 'Alt tag', + url: row.tall, + size: 'small', + }, + }), + }, + { + label: 'Wide', + renderCell: row => ({ + label: `${row.width} x ${row.height}`, + image: { + alt: 'Alt tag', + url: row.wide, + size: 'small', + }, + }), + }, + { + label: 'Crazy', + renderCell: row => ({ + label: `${row.crazyW} x ${row.crazyH}`, + image: { + alt: 'Alt tag', + url: row.crazy, + size: 'small', + }, + }), + }, + ], + }) + + await io.display.table('Image sizes', { + data, + defaultPageSize: 50, + columns: [ + 'id', + { + label: 'Thumbnail', + renderCell: row => ({ + image: { + alt: 'Alt tag', + url: row.wide, + size: 'thumbnail', + }, + }), + }, + { + label: 'Small', + renderCell: row => ({ + image: { + alt: 'Alt tag', + url: row.wide, + size: 'small', + }, + }), + }, + { + label: 'Medium', + renderCell: row => ({ + image: { + alt: 'Alt tag', + url: row.wide, + size: 'medium', + }, + }), + }, + { + label: 'Large', + renderCell: row => ({ + image: { + alt: 'Alt tag', + url: row.wide, + size: 'large', + }, + }), + }, + ], + }) +} diff --git a/src/index.ts b/src/index.ts index 2ef2955..028d423 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import type { NotifyConfig, IntervalActionDefinitions, } from './types' +import IntervalError from './classes/IntervalError' import IntervalClient, { DEFAULT_WEBSOCKET_ENDPOINT, getHttpEndpoint, @@ -47,12 +48,6 @@ export interface QueuedAction { params?: SerializableRecord } -export class IntervalError extends Error { - constructor(message: string) { - super(message) - } -} - export function getActionStore(): IntervalActionStore { const store = actionLocalStorage.getStore() if (!store) { @@ -193,4 +188,4 @@ export default class Interval { } } -export { Interval, IOError } +export { Interval, IOError, IntervalError } diff --git a/src/ioSchema.ts b/src/ioSchema.ts index 34fad56..41f2384 100644 --- a/src/ioSchema.ts +++ b/src/ioSchema.ts @@ -119,6 +119,9 @@ export type Serializable = z.infer export const serializableRecord = z.record(serializableSchema) export type SerializableRecord = z.infer +export const imageSize = z.enum(['thumbnail', 'small', 'medium', 'large']) +export type ImageSize = z.infer + export const tableRowValue = z.union([ z.string(), z.number(), @@ -128,7 +131,7 @@ export const tableRowValue = z.union([ z.undefined(), z.bigint(), z.object({ - label: z.string(), + label: z.string().optional(), value: z .union([ z.string(), @@ -141,6 +144,14 @@ export const tableRowValue = z.union([ .optional(), href: z.string().optional(), url: z.string().optional(), + image: z + .object({ + alt: z.string().optional(), + width: imageSize.optional(), + height: imageSize.optional(), + url: z.string(), + }) + .optional(), action: z.string().optional(), params: serializableRecord.optional(), }), @@ -150,7 +161,7 @@ export const tableRow = z .record(tableRowValue) // Allow arbitrary objects/interfaces with specified column mappings. // If no columns specified, we'll just serialize any nested objects. - .or(z.object({}).passthrough()) + .or(z.record(z.any())) export const menuItem = z.intersection( z.object({ @@ -195,58 +206,6 @@ export const internalTableRow = z.object({ filterValue: z.string().optional(), }) -export const tableColumn = z.object({ - label: z.string(), - renderCell: z - .function() - .args(z.any()) - .returns( - z.union([ - z.intersection( - z.object({ - label: z.union([ - z.string(), - z.number(), - z.boolean(), - z.date(), - z.null(), - z.undefined(), - ]), - value: z - .union([ - z.string(), - z.number(), - z.boolean(), - z.null(), - z.date(), - z.undefined(), - ]) - .optional(), - }), - z.union([ - z.object({ - url: z.string(), - }), - z.object({ - href: z.string(), - }), - z.object({ - action: z.string(), - params: serializableRecord.optional(), - }), - z.object({}), - ]) - ), - z.string(), - z.number(), - z.boolean(), - z.date(), - z.null(), - z.undefined(), - ]) - ), -}) - export const internalTableColumn = z.object({ label: z.string(), }) @@ -263,9 +222,6 @@ export const CURRENCIES = [ export const currencyCode = z.enum(CURRENCIES) export type CurrencyCode = z.infer -export const imageSize = z.enum(['thumbnail', 'small', 'medium', 'large']) -export type ImageSize = z.infer - export const dateObject = z.object({ year: z.number(), month: z.number(), diff --git a/src/types.ts b/src/types.ts index ae7c3f5..c347e17 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,7 @@ import type { LinkProps, ButtonTheme, serializableRecord, - tableColumn, + ImageSize, } from './ioSchema' import type { HostSchema } from './internalRpcSchema' import type { IOClient, IOClientRenderValidator } from './classes/IOClient' @@ -267,15 +267,21 @@ export type TableCellValue = string | number | boolean | null | Date | undefined export type TableColumnResult = | { - label: string | number | boolean | null | Date | undefined + label?: TableCellValue value?: TableCellValue + image?: { + alt?: string + size?: ImageSize + width?: ImageSize + height?: ImageSize + } & ({ url: string } | { buffer: Buffer }) url?: string action?: string params?: z.infer } | TableCellValue -export interface TableColumn extends z.input { +export interface TableColumn { label: string renderCell: (row: Row) => TableColumnResult } diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 0000000..cc564d3 --- /dev/null +++ b/src/utils/image.ts @@ -0,0 +1,38 @@ +import IntervalError from '../classes/IntervalError' + +const MAX_BUFFER_SIZE_MB = 50 + +export function bufferToDataUrl(buffer: Buffer): string { + if (Buffer.byteLength(buffer) > MAX_BUFFER_SIZE_MB * 1000 * 1000) { + throw new IntervalError( + `Buffer for image is too large, must be under ${MAX_BUFFER_SIZE_MB} MB` + ) + } + + const data = buffer.toString('base64') + + // using first character as a simple check for common image formats: + // https://stackoverflow.com/questions/27886677/javascript-get-extension-from-base64-image/50111377#50111377 + // image/unknown actually seems to just work for all the types I've + // encountered, but we can expand this switch if we need to + let mime + switch (data[0]) { + case 'i': + mime = 'image/png' + break + case 'R': + mime = 'image/gif' + break + case '/': + mime = 'image/jpeg' + break + case 'U': + mime = 'image/webp' + break + default: + mime = 'image/unknown' + break + } + + return `data:${mime};base64,${data}` +} diff --git a/src/utils/table.ts b/src/utils/table.ts index f33983e..7b393bc 100644 --- a/src/utils/table.ts +++ b/src/utils/table.ts @@ -1,24 +1,26 @@ import { - tableColumn, internalTableColumn, tableRow, internalTableRow, menuItem, } from '../ioSchema' import { z } from 'zod' +import { TableColumn, TableColumnResult } from '../types' +import { bufferToDataUrl } from './image' +import Logger from '../classes/Logger' export const TABLE_DATA_BUFFER_SIZE = 500 /** * Generates column headers from rows if no columns are provided. */ -export function columnsBuilder( +export function columnsBuilder>( props: { - columns?: (z.infer | string)[] - data: z.infer[] + columns?: (TableColumn | string)[] + data: Row[] }, logMissingColumn: (column: string) => void -): z.infer[] { +): TableColumn[] { const dataColumns = new Set(props.data.flatMap(record => Object.keys(record))) if (!props.columns) { const labels = Array.from(dataColumns.values()) @@ -49,7 +51,7 @@ export function columnsBuilder( * Removes the `render` function from column defs before sending data up to the server. */ export function columnsWithoutRender( - columns: z.infer[] + columns: TableColumn[] ): z.infer[] { return columns.map(({ renderCell, ...column }) => column) } @@ -57,22 +59,24 @@ export function columnsWithoutRender( const dateFormatter = new Intl.DateTimeFormat('en-US') type RenderedTableRow = { - [key: string]: ReturnType['renderCell']> + [key: string]: TableColumnResult } /** * Applies cell renderers to a row. */ -export function tableRowSerializer>({ +export function tableRowSerializer>({ index, row, columns, menuBuilder, + logger, }: { index: number - row: T - columns: z.infer[] - menuBuilder?: (row: T) => z.infer[] + row: Row + columns: TableColumn[] + menuBuilder?: (row: Row) => z.infer[] + logger: Logger }): z.infer { const key = index.toString() @@ -83,11 +87,44 @@ export function tableRowSerializer>({ const col = columns[i] const val = col.renderCell(row) ?? null - if (!!val && typeof val === 'object' && 'label' in val) { + if (val && typeof val === 'object' && 'image' in val) { + const image = val.image + + if (image) { + if (image.size) { + if (!image.width) { + image.width = image.size + } + + if (!image.height) { + image.height = image.size + } + } + + if ('buffer' in image) { + const { buffer, ...rest } = image + try { + val.image = { + ...rest, + url: bufferToDataUrl(buffer), + } + } catch (err) { + logger.error(err) + delete val.image + } + } + } + } + + if (val && typeof val === 'object' && 'label' in val) { if (val.label === undefined) { val.label = null } else if (val.label) { - filterValues.push(String(val.label)) + filterValues.push( + val.label instanceof Date + ? dateFormatter.format(val.label) + : String(val.label) + ) } } else if (val instanceof Date) { filterValues.push(dateFormatter.format(val))