diff --git a/src/classes/IOClient.ts b/src/classes/IOClient.ts index da80541..83cbd0b 100644 --- a/src/classes/IOClient.ts +++ b/src/classes/IOClient.ts @@ -1,5 +1,6 @@ import { v4 } from 'uuid' import { z } from 'zod' +import { Evt } from 'evt' import superjson from '../utils/superjson' import { T_IO_RENDER_INPUT, @@ -35,6 +36,7 @@ import displayGrid from '../components/displayGrid' import displayLink from '../components/displayLink' import displayImage from '../components/displayImage' import displayVideo from '../components/displayVideo' +import displayMetadata from '../components/displayMetadata' import urlInput from '../components/url' import { date, datetime } from '../components/inputDate' import { file } from '../components/upload' @@ -413,7 +415,8 @@ export class IOClient { Props, Output, DefaultValue - > + >, + onPropsUpdate?: Evt> ) { let props: T_IO_PROPS = inputProps ? (inputProps as T_IO_PROPS) @@ -427,7 +430,8 @@ export class IOClient { if (componentDef) { const componentGetters = componentDef.bind(this)( - inputProps ?? ({} as Props) + inputProps ?? ({} as Props), + onPropsUpdate ) if (componentGetters.props) { @@ -540,6 +544,8 @@ export class IOClient { } = {} ) { return (label: string, props?: Props) => { + const onPropsUpdate = new Evt>() + if (supportsMultiple(methodName)) { return new MultipleableIOPromise({ ...this.getPromiseProps( @@ -551,7 +557,8 @@ export class IOClient { Props, T_IO_RETURNS > - | undefined + | undefined, + onPropsUpdate as Evt> ), methodName: methodName as T_IO_MULTIPLEABLE_METHOD_NAMES, renderer: this.renderComponents.bind( @@ -559,6 +566,9 @@ export class IOClient { ) as ComponentRenderer, label, displayResolvesImmediately: this.displayResolvesImmediately, + onPropsUpdate: onPropsUpdate as Evt< + T_IO_PROPS + >, }) } @@ -573,7 +583,8 @@ export class IOClient { Props, T_IO_RETURNS > - | undefined + | undefined, + onPropsUpdate as Evt> ), methodName: methodName as T_IO_DISPLAY_METHOD_NAMES, renderer: this.renderComponents.bind( @@ -581,6 +592,9 @@ export class IOClient { ) as ComponentRenderer, label, displayResolvesImmediately: this.displayResolvesImmediately, + onPropsUpdate: onPropsUpdate as Evt< + T_IO_PROPS + >, }) : new InputIOPromise({ ...this.getPromiseProps( @@ -592,7 +606,8 @@ export class IOClient { Props, T_IO_RETURNS > - | undefined + | undefined, + onPropsUpdate as Evt> ), methodName: methodName as T_IO_INPUT_METHOD_NAMES, renderer: this.renderComponents.bind( @@ -600,6 +615,9 @@ export class IOClient { ) as ComponentRenderer, label, displayResolvesImmediately: this.displayResolvesImmediately, + onPropsUpdate: onPropsUpdate as Evt< + T_IO_PROPS + >, }) } } @@ -1016,6 +1034,7 @@ export class IOClient { * ``` */ metadata: this.createIOMethod('DISPLAY_METADATA', { + componentDef: displayMetadata(this.logger), propsRequired: true, }), /** diff --git a/src/classes/IOComponent.ts b/src/classes/IOComponent.ts index c058369..6a29055 100644 --- a/src/classes/IOComponent.ts +++ b/src/classes/IOComponent.ts @@ -1,4 +1,5 @@ import { z, ZodError } from 'zod' +import { Evt } from 'evt' import { ioSchema, resolvesImmediately, @@ -70,6 +71,7 @@ export default class IOComponent { incomingState: z.infer ) => Promise>>) | undefined + onPropsUpdate: (() => T_IO_PROPS) | undefined validator: | IOPromiseValidator< @@ -100,6 +102,7 @@ export default class IOComponent { validator, multipleProps, displayResolvesImmediately, + onPropsUpdate, }: { methodName: MethodName label: string @@ -116,11 +119,16 @@ export default class IOComponent { defaultValue?: T_IO_RETURNS[] | null } displayResolvesImmediately?: boolean + onPropsUpdate?: Evt> }) { this.handleStateChange = onStateChange this.schema = ioSchema[methodName] this.validator = validator + if (onPropsUpdate) { + onPropsUpdate.attach(this.setProps.bind(this)) + } + try { initialProps = this.schema.props.parse(initialProps ?? {}) } catch (err) { diff --git a/src/classes/IOPromise.ts b/src/classes/IOPromise.ts index a52c5ca..9be031b 100644 --- a/src/classes/IOPromise.ts +++ b/src/classes/IOPromise.ts @@ -1,3 +1,4 @@ +import type { Evt } from 'evt' import { ioSchema, T_IO_DISPLAY_METHOD_NAMES, @@ -38,6 +39,7 @@ interface IOPromiseProps< methodName: MethodName label: string props: Props + onPropsUpdate?: Evt> valueGetter?: (response: ComponentReturnValue) => ComponentOutput onStateChange?: ( incomingState: T_IO_STATE @@ -71,6 +73,7 @@ export class IOPromise< | undefined /* @internal */ validator: IOPromiseValidator | undefined protected displayResolvesImmediately: boolean | undefined + protected onPropsUpdate: Evt> | undefined constructor({ renderer, @@ -81,6 +84,7 @@ export class IOPromise< onStateChange, validator, displayResolvesImmediately, + onPropsUpdate, }: IOPromiseProps) { this.renderer = renderer this.methodName = methodName @@ -90,6 +94,7 @@ export class IOPromise< this.onStateChange = onStateChange this.validator = validator this.displayResolvesImmediately = displayResolvesImmediately + this.onPropsUpdate = onPropsUpdate } then( @@ -130,6 +135,7 @@ export class IOPromise< initialProps: this.props, onStateChange: this.onStateChange, displayResolvesImmediately: this.displayResolvesImmediately, + onPropsUpdate: this.onPropsUpdate, }) } } @@ -172,6 +178,7 @@ export class InputIOPromise< onStateChange: this.onStateChange, validator: this.validator ? this.handleValidation.bind(this) : undefined, displayResolvesImmediately: this.displayResolvesImmediately, + onPropsUpdate: this.onPropsUpdate, }) } @@ -289,6 +296,7 @@ export class OptionalIOPromise< isOptional: true, validator: this.validator ? this.handleValidation.bind(this) : undefined, displayResolvesImmediately: this.displayResolvesImmediately, + onPropsUpdate: this.onPropsUpdate, }) } @@ -353,6 +361,7 @@ export class MultipleableIOPromise< ) => Promise> validator?: IOPromiseValidator | undefined displayResolvesImmediately?: boolean + onPropsUpdate?: Evt> }) { super(props) this.defaultValueGetter = defaultValueGetter @@ -439,6 +448,7 @@ export class MultipleIOPromise< incomingState: T_IO_STATE ) => Promise> validator?: IOPromiseValidator | undefined + onPropsUpdate?: Evt> }) { super(rest) this.getSingleValue = valueGetter @@ -512,6 +522,7 @@ export class MultipleIOPromise< defaultValue: this.defaultValue, }, displayResolvesImmediately: this.displayResolvesImmediately, + onPropsUpdate: this.onPropsUpdate, }) } @@ -572,6 +583,7 @@ export class OptionalMultipleIOPromise< incomingState: T_IO_STATE ) => Promise> validator?: IOPromiseValidator | undefined + onPropsUpdate?: Evt> }) { super(rest) this.getSingleValue = valueGetter @@ -645,6 +657,7 @@ export class OptionalMultipleIOPromise< defaultValue: this.defaultValue, }, displayResolvesImmediately: this.displayResolvesImmediately, + onPropsUpdate: this.onPropsUpdate, }) } } @@ -1037,6 +1050,7 @@ export class ExclusiveIOPromise< isOptional: false, validator: this.validator ? this.handleValidation.bind(this) : undefined, displayResolvesImmediately: this.displayResolvesImmediately, + onPropsUpdate: this.onPropsUpdate, }) } diff --git a/src/classes/Layout.ts b/src/classes/Layout.ts index fa8c5e6..3b9ab23 100644 --- a/src/classes/Layout.ts +++ b/src/classes/Layout.ts @@ -1,12 +1,13 @@ import { z } from 'zod' import { Literal, IO_RENDER, buttonItem, metaItemSchema } from '../ioSchema' -import { AnyDisplayIOPromise, ButtonItem, PageError } from '../types' +import { + AnyDisplayIOPromise, + ButtonItem, + PageError, + EventualValue, +} from '../types' -type EventualString = - | string - | Promise - | (() => string) - | (() => Promise) +type EventualString = EventualValue export interface BasicLayoutConfig { title?: EventualString diff --git a/src/components/displayMetadata.ts b/src/components/displayMetadata.ts new file mode 100644 index 0000000..e1fec5e --- /dev/null +++ b/src/components/displayMetadata.ts @@ -0,0 +1,247 @@ +import type { Evt } from 'evt' +import Logger from '../classes/Logger' +import { + T_IO_PROPS, + Serializable, + SerializableRecord, + ImageSchema, +} from '../ioSchema' +import { EventualValue } from '../types' + +export interface EventualMetaItem { + label: string + value?: EventualValue + url?: EventualValue + image?: EventualValue + route?: EventualValue + /** @deprecated Please use `route` instead */ + action?: EventualValue + params?: EventualValue +} + +export default function displaymetadata(logger: Logger) { + return function displayMetadata( + props: Pick, 'layout'> & { + data: EventualMetaItem[] + }, + onPropsUpdate?: Evt> + ): { props: T_IO_PROPS<'DISPLAY_METADATA'> } { + const layout = props.layout + const metaItems: EventualMetaItem[] = [] + const data: T_IO_PROPS<'DISPLAY_METADATA'>['data'] = props.data.map( + metaItem => { + metaItem = { ...metaItem } + + const initialItem: T_IO_PROPS<'DISPLAY_METADATA'>['data'][0] = { + label: metaItem.label, + } + + // Currently doing all of this repetitive work separately to leverage + // static type checking, but could be done more dynamically in a loop as well + + if ('value' in metaItem && metaItem.value !== undefined) { + if (typeof metaItem.value === 'function') { + metaItem.value = metaItem.value() + } + + if (!(metaItem.value instanceof Promise)) { + initialItem.value = metaItem.value + } else { + initialItem.value = undefined + } + } + + if ('url' in metaItem && metaItem.url !== undefined) { + if (typeof metaItem.url === 'function') { + metaItem.url = metaItem.url() + } + + if (!(metaItem.url instanceof Promise)) { + initialItem.url = metaItem.url + } else { + initialItem.url = undefined + } + } + + if ('image' in metaItem && metaItem.image !== undefined) { + if (typeof metaItem.image === 'function') { + metaItem.image = metaItem.image() + } + + if (!(metaItem.image instanceof Promise)) { + initialItem.image = metaItem.image + } else { + initialItem.image = undefined + } + } + + if ('route' in metaItem && metaItem.route !== undefined) { + if (typeof metaItem.route === 'function') { + metaItem.route = metaItem.route() + } + + if (!(metaItem.route instanceof Promise)) { + initialItem.route = metaItem.route + } else { + initialItem.route = undefined + } + } + + if ('action' in metaItem && metaItem.action !== undefined) { + if (typeof metaItem.action === 'function') { + metaItem.action = metaItem.action() + } + + if (!(metaItem.action instanceof Promise)) { + initialItem.action = metaItem.action + } else { + initialItem.action = undefined + } + } + + if ('params' in metaItem && metaItem.params !== undefined) { + if (typeof metaItem.params === 'function') { + metaItem.params = metaItem.params() + } + + if (!(metaItem.params instanceof Promise)) { + initialItem.params = metaItem.params + } else { + initialItem.params = undefined + } + } + + metaItems.push(metaItem) + + return initialItem + } + ) + + if (onPropsUpdate) { + for (let i = 0; i < metaItems.length; i++) { + const metaItem = metaItems[i] + + if ('value' in metaItem) { + if (metaItem.value instanceof Promise) { + metaItem.value + .then(resolvedValue => { + data[i].value = resolvedValue + onPropsUpdate?.post({ + layout, + data, + }) + }) + .catch(err => { + logger.error( + 'Error updating metadata field "value" with result from Promise:', + err + ) + }) + } + } + + if ('url' in metaItem) { + if (metaItem.url instanceof Promise) { + metaItem.url + .then(resolvedurl => { + data[i].url = resolvedurl + onPropsUpdate?.post({ + layout, + data, + }) + }) + .catch(err => { + logger.error( + 'Error updating metadata field "url" with result from Promise:', + err + ) + }) + } + } + + if ('image' in metaItem) { + if (metaItem.image instanceof Promise) { + metaItem.image + .then(resolvedimage => { + data[i].image = resolvedimage + onPropsUpdate?.post({ + layout, + data, + }) + }) + .catch(err => { + logger.error( + 'Error updating metadata field "image" with result from Promise:', + err + ) + }) + } + } + + if ('route' in metaItem) { + if (metaItem.route instanceof Promise) { + metaItem.route + .then(resolvedroute => { + data[i].route = resolvedroute + onPropsUpdate?.post({ + layout, + data, + }) + }) + .catch(err => { + logger.error( + 'Error updating metadata field "route" with result from Promise:', + err + ) + }) + } + } + + if ('action' in metaItem) { + if (metaItem.action instanceof Promise) { + metaItem.action + .then(resolvedaction => { + data[i].action = resolvedaction + onPropsUpdate?.post({ + layout, + data, + }) + }) + .catch(err => { + logger.error( + 'Error updating metadata field "action" with result from Promise:', + err + ) + }) + } + } + + if ('params' in metaItem) { + if (metaItem.params instanceof Promise) { + metaItem.params + .then(resolvedparams => { + data[i].params = resolvedparams + onPropsUpdate?.post({ + layout, + data, + }) + }) + .catch(err => { + logger.error( + 'Error updating metadata field "params" with result from Promise:', + err + ) + }) + } + } + } + } + + return { + props: { + data, + layout, + }, + } + } +} diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index 294dd30..1cd981a 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -1,4 +1,3 @@ -import { T_IO_PROPS } from './../../ioSchema' import Interval, { IOError, io, ctx, Action, Page, Layout } from '../../index' import IntervalClient from '../../classes/IntervalClient' import { @@ -13,6 +12,7 @@ import { sleep, generateRows, } from '../utils/helpers' +import type { EventualMetaItem } from '../../components/displayMetadata' import * as table_actions from './table' import * as grid_actions from './grid' import unauthorized from './unauthorized' @@ -1075,7 +1075,7 @@ const interval = new Interval({ }) }, metadata: async (io, ctx) => { - const data: T_IO_PROPS<'DISPLAY_METADATA'>['data'] = [ + const data: EventualMetaItem[] = [ { label: 'Is true', value: true, @@ -1088,10 +1088,38 @@ const interval = new Interval({ label: 'Is null', value: null, }, + { + label: 'Is undefined', + value: undefined, + }, { label: 'Is empty string', value: '', }, + { + label: 'Is a promise', + value: new Promise(async resolve => { + await sleep(2000) + resolve('Done!') + }), + }, + { + label: 'Throws an error', + value: new Promise(() => { + throw new Error('Oops!') + }), + }, + { + label: 'Is a function', + value: () => 'Called it', + }, + { + label: 'Is an async function', + value: async () => { + await sleep(3500) + return 'Did it' + }, + }, { label: 'Is long string', value: @@ -1119,10 +1147,14 @@ const interval = new Interval({ { label: 'Image', value: 'Optional caption', - image: { - url: 'https://picsum.photos/200/300', - size: 'small', - }, + image: new Promise(resolve => { + sleep(1500).then(() => { + resolve({ + url: 'https://picsum.photos/200/300', + size: 'small', + }) + }) + }), }, ] diff --git a/src/types.ts b/src/types.ts index df29f80..6c0acae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { z } from 'zod' +import type { Evt } from 'evt' import type { T_IO_RENDER_INPUT, T_IO_RESPONSE, @@ -438,7 +439,8 @@ export type IOComponentDefinition< DefaultValue = Output > = ( this: IOClient, - props: Props + props: Props, + onPropsUpdate?: Evt> ) => { props?: T_IO_PROPS getValue?: (response: T_IO_RETURNS) => Output @@ -559,3 +561,5 @@ export type IntervalErrorProps = { } export type IntervalErrorHandler = (props: IntervalErrorProps) => void + +export type EventualValue = T | Promise | (() => T) | (() => Promise)