diff --git a/src/classes/IntervalClient.ts b/src/classes/IntervalClient.ts index 423f99f..4fff6b6 100644 --- a/src/classes/IntervalClient.ts +++ b/src/classes/IntervalClient.ts @@ -38,6 +38,8 @@ import type { IntervalActionStore, IntervalPageStore, InternalMenuItem, + InternalButtonItem, + PageError, } from '../types' import TransactionLoadingState from '../classes/TransactionLoadingState' import localConfig from '../localConfig' @@ -49,6 +51,7 @@ import { LayoutSchemaInput, MetaItemSchema, MetaItemsSchema, + BasicLayoutConfig, } from './Layout' export const DEFAULT_WEBSOCKET_ENDPOINT = 'wss://interval.com/websocket' @@ -646,8 +649,9 @@ export default class IntervalClient { } let page: Layout - let menuItems: InternalMenuItem[] | undefined = undefined + let menuItems: InternalButtonItem[] | undefined = undefined let renderInstruction: T_IO_RENDER_INPUT | undefined = undefined + let errors: PageError[] = [] const MAX_PAGE_RETRIES = 5 @@ -669,16 +673,17 @@ export default class IntervalClient { : null, menuItems, children: renderInstruction, + errors, } if (page.metadata) { const items: MetaItemSchema[] = [] for (const pageItem of page.metadata) { - let { label, value } = pageItem + let { label, value, error } = pageItem if (typeof value === 'function' || value instanceof Promise) { items.push({ label }) } else { - items.push({ label, value }) + items.push({ label, value, error }) } } @@ -774,82 +779,153 @@ export default class IntervalClient { this.#pageIOClients.set(pageKey, client) this.#ioResponseHandlers.set(pageKey, client.onResponse.bind(client)) - pageLocalStorage.run({ display, ctx }, () => { - appHandler(display, ctx).then(res => { - page = res - - if (typeof page.title === 'function') { - page.title = page.title() + const pageError = ( + error: unknown, + layoutKey?: keyof BasicLayoutConfig + ) => { + if (error instanceof Error) { + return { + layoutKey, + error: error.name, + message: error.message, } - - if (page.title instanceof Promise) { - page.title.then(title => { - page.title = title - scheduleSendPage() - }) + } else { + return { + layoutKey, + error: 'Unknown error', + message: String(error), } + } + } - if (page.description) { - if (typeof page.description === 'function') { - page.description = page.description() + pageLocalStorage.run({ display, ctx }, () => { + appHandler(display, ctx) + .then(res => { + page = res + + if (typeof page.title === 'function') { + try { + page.title = page.title() + } catch (err) { + this.#logger.error(err) + errors.push(pageError(err, 'title')) + } } - if (page.description instanceof Promise) { - page.description.then(description => { - page.description = description - scheduleSendPage() - }) + if (page.title instanceof Promise) { + page.title + .then(title => { + page.title = title + scheduleSendPage() + }) + .catch(err => { + this.#logger.error(err) + errors.push(pageError(err, 'title')) + scheduleSendPage() + }) } - } - - if (page.menuItems) { - menuItems = page.menuItems.map(menuItem => { - // if ( - // 'action' in menuItem && - // typeof menuItem['action'] === 'function' - // ) { - // const inlineAction = client.addInlineAction(menuItem.action) - // return { - // ...menuItem, - // inlineAction, - // } - // } - - return menuItem - }) - } - if (page instanceof Basic) { - const { metadata } = page - if (metadata) { - for (let i = 0; i < metadata.length; i++) { - let { value } = metadata[i] - if (typeof value === 'function') { - value = value() - metadata[i].value = value + if (page.description) { + if (typeof page.description === 'function') { + try { + page.description = page.description() + } catch (err) { + this.#logger.error(err) + errors.push(pageError(err, 'description')) } + } - if (value instanceof Promise) { - value.then(resolved => { - metadata[i].value = resolved + if (page.description instanceof Promise) { + page.description + .then(description => { + page.description = description scheduleSendPage() }) + .catch(err => { + this.#logger.error(err) + errors.push(pageError(err, 'description')) + scheduleSendPage() + }) + } + } + + if (page.menuItems) { + menuItems = page.menuItems.map(menuItem => { + // if ( + // 'action' in menuItem && + // typeof menuItem['action'] === 'function' + // ) { + // const inlineAction = client.addInlineAction(menuItem.action) + // return { + // ...menuItem, + // inlineAction, + // } + // } + + return menuItem + }) + } + + if (page instanceof Basic) { + const { metadata } = page + if (metadata) { + for (let i = 0; i < metadata.length; i++) { + let { value } = metadata[i] + if (typeof value === 'function') { + try { + value = value() + metadata[i].value = value + } catch (err) { + this.#logger.error(err) + const error = pageError(err, 'metadata') + errors.push(error) + metadata[i].value = null + metadata[i].error = error.message + } + } + + if (value instanceof Promise) { + value + .then(resolved => { + metadata[i].value = resolved + scheduleSendPage() + }) + .catch(err => { + this.#logger.error(err) + const error = pageError(err, 'metadata') + errors.push(error) + metadata[i].value = null + metadata[i].error = error.message + scheduleSendPage() + }) + } } } } - } - if (page.children) { - group(page.children).then(() => { - this.#logger.debug( - 'Initial children render complete for pageKey', - pageKey - ) + if (page.children) { + group(page.children).then(() => { + this.#logger.debug( + 'Initial children render complete for pageKey', + pageKey + ) + }) + } else { + scheduleSendPage() + } + }) + .catch(async err => { + this.#logger.error(err) + errors.push(pageError(err)) + const pageLayout: LayoutSchemaInput = { + kind: 'BASIC', + errors, + } + await this.#send('SEND_PAGE', { + pageKey, + page: JSON.stringify(pageLayout), }) - } else { - scheduleSendPage() - } - }) + }) }) return { diff --git a/src/classes/Layout.ts b/src/classes/Layout.ts index 2d1e0dc..78ae2d2 100644 --- a/src/classes/Layout.ts +++ b/src/classes/Layout.ts @@ -1,6 +1,12 @@ import { z } from 'zod' -import { primitiveValue, Literal, IO_RENDER, menuItem } from '../ioSchema' -import { AnyDisplayIOPromise, MenuItem } from '../types' +import { + primitiveValue, + Literal, + IO_RENDER, + menuItem, + buttonItem, +} from '../ioSchema' +import { AnyDisplayIOPromise, ButtonItem, PageError } from '../types' type EventualString = | string @@ -8,11 +14,11 @@ type EventualString = | (() => string) | (() => Promise) -interface BasicLayoutConfig { +export interface BasicLayoutConfig { title?: EventualString description?: EventualString children?: AnyDisplayIOPromise[] - menuItems?: MenuItem[] + menuItems?: ButtonItem[] metadata?: MetaItem[] } @@ -20,7 +26,8 @@ export interface Layout { title?: EventualString description?: EventualString children?: AnyDisplayIOPromise[] - menuItems?: MenuItem[] + menuItems?: ButtonItem[] + errors?: PageError[] } // Base class @@ -28,8 +35,9 @@ export class Basic implements Layout { title?: EventualString description?: EventualString children?: AnyDisplayIOPromise[] - menuItems?: MenuItem[] + menuItems?: ButtonItem[] metadata?: MetaItem[] + errors?: PageError[] constructor(config: BasicLayoutConfig) { this.title = config.title @@ -37,6 +45,7 @@ export class Basic implements Layout { this.children = config.children this.menuItems = config.menuItems this.metadata = config.metadata + this.errors = [] } } @@ -49,11 +58,13 @@ export interface MetaItem { | Promise | (() => MetaItemValue) | (() => Promise) + error?: string } export const META_ITEM_SCHEMA = z.object({ label: z.string(), value: primitiveValue.or(z.bigint()).nullish(), + error: z.string().nullish(), }) export type MetaItemSchema = z.infer @@ -72,7 +83,16 @@ export const BASIC_LAYOUT_SCHEMA = z.object({ description: z.string().nullish(), children: IO_RENDER.optional(), metadata: META_ITEMS_SCHEMA.optional(), - menuItems: z.array(menuItem).optional(), + menuItems: z.array(buttonItem).optional(), + errors: z + .array( + z.object({ + layoutKey: z.string().optional(), + error: z.string(), + message: z.string(), + }) + ) + .optional(), }) // To be extended with z.discriminatedUnion when adding different pages diff --git a/src/components/displayTable.ts b/src/components/displayTable.ts index 8cee72f..04c725a 100644 --- a/src/components/displayTable.ts +++ b/src/components/displayTable.ts @@ -1,13 +1,7 @@ import { z } from 'zod' import Logger from '../classes/Logger' -import { - tableRow, - T_IO_PROPS, - menuItem, - T_IO_STATE, - internalTableRow, -} from '../ioSchema' -import { TableColumn } from '../types' +import { tableRow, T_IO_PROPS, T_IO_STATE, internalTableRow } from '../ioSchema' +import { MenuItem, TableColumn } from '../types' import { columnsBuilder, tableRowSerializer, @@ -24,7 +18,7 @@ type PublicProps = Omit< 'data' | 'columns' | 'totalRecords' | 'isAsync' > & { columns?: (TableColumn | string)[] - rowMenuItems?: (row: Row) => z.infer[] + rowMenuItems?: (row: Row) => MenuItem[] } & ( | { data: Row[] diff --git a/src/components/selectTable.ts b/src/components/selectTable.ts index b434d80..fec6eed 100644 --- a/src/components/selectTable.ts +++ b/src/components/selectTable.ts @@ -7,7 +7,7 @@ import { T_IO_RETURNS, T_IO_STATE, } from '../ioSchema' -import { TableColumn } from '../types' +import { MenuItem, TableColumn } from '../types' import { columnsBuilder, filterRows, @@ -20,7 +20,7 @@ import { type PublicProps = Omit, 'data' | 'columns'> & { data: Row[] columns?: (TableColumn | string)[] - rowMenuItems?: (row: Row) => z.infer[] + rowMenuItems?: (row: Row) => MenuItem[] } export default function selectTable(logger: Logger) { diff --git a/src/examples/app/index.ts b/src/examples/app/index.ts index bad5e4a..3784f4e 100644 --- a/src/examples/app/index.ts +++ b/src/examples/app/index.ts @@ -4,6 +4,7 @@ import * as db from './db' const hello_app = new Router({ name: 'App', + description: 'This should have a description', index: async () => { return new Layout.Basic({ title: sleep(1000).then(() => 'Resource'), @@ -17,12 +18,21 @@ const hello_app = new Router({ label: 'Async function', value: async () => { await sleep(1000) + throw new Error('Metadata error in an async function') return '1 second' }, }, { label: 'Promise', value: sleep(2000).then(() => 2000) }, ], menuItems: [ + { + label: 'Hello world', + action: 'hello_world', + }, + { + label: 'Add user', + action: 'users/create', + }, { label: 'Action link', action: 'hello_app/hello_world', @@ -194,6 +204,7 @@ const interval = new Interval({ }), hello_world: { name: 'Hello world', + description: 'This should have a description too', handler: async () => { await io.display.markdown('Hello, world!') }, diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index fd50f9e..901aad4 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -347,6 +347,7 @@ const interval = new Interval({ }, code: async () => { await io.group([ + io.input.text('Text input'), io.display.code('Code from string', { code: 'console.log("Hello, world!")', language: 'typescript', diff --git a/src/ioSchema.ts b/src/ioSchema.ts index e702f8b..f9880a1 100644 --- a/src/ioSchema.ts +++ b/src/ioSchema.ts @@ -29,8 +29,12 @@ export const DISPLAY_RENDER = z.object({ kind: z.literal('RENDER'), }) -const buttonTheme = z.enum(['default', 'danger']).default('default').optional() -export type ButtonTheme = z.infer +// `default` deprecated in 0.31.0 +const buttonTheme = z + .enum(['default', 'primary', 'secondary', 'danger']) + .default('primary') + .optional() +export type ButtonTheme = 'primary' | 'secondary' | 'danger' export const IO_RENDER = z.object({ id: z.string(), @@ -186,6 +190,28 @@ export const tableRow = z .or(z.record(z.any())) export const menuItem = z.intersection( + z.object({ + label: z.string(), + // `default` deprecated in 0.31.0 + theme: z.enum(['default', 'danger']).optional(), + }), + z.union([ + z.object({ + action: z.string(), + params: serializableRecord.optional(), + disabled: z.boolean().optional(), + }), + z.object({ + url: z.string(), + disabled: z.boolean().optional(), + }), + z.object({ + disabled: z.literal(true), + }), + ]) +) + +export const buttonItem = z.intersection( z.object({ label: z.string(), theme: buttonTheme, @@ -267,6 +293,9 @@ export function resolvesImmediately(methodName: T_IO_METHOD_NAMES): boolean { return 'immediate' in ioSchema[methodName] } +/** + * IMPORTANT: When adding any new DISPLAY methods, be sure to also add their method names to T_IO_DISPLAY_METHOD_NAMES below. + */ export const ioSchema = { INPUT_TEXT: { props: z.object({ @@ -611,11 +640,14 @@ export type T_IO_Schema = typeof ioSchema export type T_IO_METHOD_NAMES = keyof T_IO_Schema export type T_IO_DISPLAY_METHOD_NAMES = + | 'DISPLAY_CODE' | 'DISPLAY_HEADING' | 'DISPLAY_MARKDOWN' | 'DISPLAY_LINK' | 'DISPLAY_OBJECT' | 'DISPLAY_TABLE' + | 'DISPLAY_IMAGE' + | 'DISPLAY_VIDEO' export type T_IO_INPUT_METHOD_NAMES = Exclude< T_IO_METHOD_NAMES, diff --git a/src/types.ts b/src/types.ts index daa7de9..82bafb8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ import type { T_IO_INPUT_METHOD_NAMES, LinkProps, menuItem, + buttonItem, ButtonTheme, serializableRecord, ImageSize, @@ -33,6 +34,7 @@ import type { import type IOError from './classes/IOError' import type TransactionLoadingState from './classes/TransactionLoadingState' import type { Router } from './experimental' +import { BasicLayoutConfig } from './classes/Layout' export type ActionCtx = Pick< z.infer, @@ -283,8 +285,19 @@ export type IOComponentDefinition< ) => Promise> } +type DistributiveOmit = T extends any + ? Omit + : never + export type InternalMenuItem = z.input -export type MenuItem = InternalMenuItem +export type MenuItem = DistributiveOmit & { + theme?: 'danger' +} + +export type InternalButtonItem = z.input +export type ButtonItem = DistributiveOmit & { + theme?: 'primary' | 'secondary' | 'danger' +} // | { // label: InternalMenuItem['label'] // theme?: InternalMenuItem['theme'] @@ -331,3 +344,9 @@ export type TableColumn = { renderCell: (row: Row) => TableColumnResult } ) + +export type PageError = { + error: string + message: string + layoutKey?: keyof BasicLayoutConfig +}