import { InputSpec, LazyBuild } from './inputSpec' import { List } from './list' import { UnionRes, UnionResStaticValidatedAs, Variants } from './variants' import { Pattern, RandomString, ValueSpec, ValueSpecDatetime, ValueSpecHidden, ValueSpecText, ValueSpecTextarea, } from '../inputSpecTypes' import { DefaultString } from '../inputSpecTypes' import { _, once } from '../../../util' import { z } from 'zod' import { DeepPartial } from '../../../types' /** Zod schema for a file upload result — validates `{ path, commitment: { hash, size } }`. */ export const fileInfoParser = z.object({ path: z.string(), commitment: z.object({ hash: z.string(), size: z.number() }), }) /** The parsed result of a file upload, containing the file path and its content commitment (hash + size). */ export type FileInfo = z.infer /** Conditional type: returns `T` if `Required` is `true`, otherwise `T | null`. */ export type AsRequired = Required extends true ? T : T | null const testForAsRequiredParser = once( () => (v: unknown) => z.object({ required: z.literal(true) }).safeParse(v).success, ) function asRequiredParser( parser: z.ZodType, input: Input, ): z.ZodType> { if (testForAsRequiredParser()(input)) return parser as any return parser.nullable() as any } /** * Core builder class for defining a single form field in a service configuration spec. * * Each static factory method (e.g. `Value.text()`, `Value.toggle()`, `Value.select()`) creates * a typed `Value` instance representing a specific field type. Dynamic variants (e.g. `Value.dynamicText()`) * allow the field options to be computed lazily at runtime. * * Use with {@link InputSpec} to compose complete form specifications. * * @typeParam Type - The runtime type this field produces when filled in * @typeParam StaticValidatedAs - The compile-time validated type (usually same as Type) * @typeParam OuterType - The parent form's type context (used by dynamic variants) */ export class Value< Type extends StaticValidatedAs, StaticValidatedAs = Type, OuterType = unknown, > { protected constructor( public build: LazyBuild< { spec: ValueSpec validator: z.ZodType }, OuterType >, public readonly validator: z.ZodType, ) {} public _TYPE: Type = null as any as Type public _PARTIAL: DeepPartial = null as any as DeepPartial /** @internal Used by {@link InputSpec.filter} to support nested filtering of object-typed fields. */ _objectSpec?: { inputSpec: InputSpec params: { name: string; description?: string | null } } /** * @description Displays a boolean toggle to enable/disable * @example * ``` toggleExample: Value.toggle({ // required name: 'Toggle Example', default: true, // optional description: null, warning: null, immutable: false, }), * ``` */ static toggle(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null default: boolean /** * @description Once set, the value can never be changed. * @default false */ immutable?: boolean }) { const validator = z.boolean() return new Value( async () => ({ spec: { description: null, warning: null, type: 'toggle' as const, disabled: false, immutable: a.immutable ?? false, ...a, }, validator, }), validator, ) } /** Like {@link Value.toggle} but options are resolved lazily at runtime via a builder function. */ static dynamicToggle( a: LazyBuild< { name: string description?: string | null warning?: string | null default: boolean disabled?: false | string }, OuterType >, ) { const validator = z.boolean() return new Value( async (options) => ({ spec: { description: null, warning: null, type: 'toggle' as const, disabled: false, immutable: false, ...(await a(options)), }, validator, }), validator, ) } /** * @description Displays a text input field * @example * ``` textExample: Value.text({ // required name: 'Text Example', required: false, default: null, // optional description: null, placeholder: null, warning: null, generate: null, inputmode: 'text', masked: false, minLength: null, maxLength: null, patterns: [], immutable: false, }), * ``` */ static text(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** * provide a default value. * @type { string | RandomString | null } * @example default: null * @example default: 'World' * @example default: { charset: 'abcdefg', len: 16 } */ default: string | RandomString | null required: Required /** * @description Mask (aka camouflage) text input with dots: ● ● ● * @default false */ masked?: boolean placeholder?: string | null minLength?: number | null maxLength?: number | null /** * @description A list of regular expressions to which the text must conform to pass validation. A human readable description is provided in case the validation fails. * @default [] * @example * ``` [ { regex: "[a-z]", description: "May only contain lower case letters from the English alphabet." } ] * ``` */ patterns?: Pattern[] /** * @description Informs the browser how to behave and which keyboard to display on mobile * @default "text" */ inputmode?: ValueSpecText['inputmode'] /** * @description Once set, the value can never be changed. * @default false */ immutable?: boolean /** * @description Displays a button that will generate a random string according to the provided charset and len attributes. */ generate?: RandomString | null }) { const validator = asRequiredParser(z.string(), a) return new Value>( async () => ({ spec: { type: 'text' as const, description: null, warning: null, masked: false, placeholder: null, minLength: null, maxLength: null, patterns: [], inputmode: 'text', disabled: false, immutable: a.immutable ?? false, generate: a.generate ?? null, ...a, }, validator, }), validator, ) } /** Like {@link Value.text} but options are resolved lazily at runtime via a builder function. */ static dynamicText( getA: LazyBuild< { name: string description?: string | null warning?: string | null default: DefaultString | null required: Required masked?: boolean placeholder?: string | null minLength?: number | null maxLength?: number | null patterns?: Pattern[] inputmode?: ValueSpecText['inputmode'] disabled?: string | false generate?: null | RandomString }, OuterType >, ) { return new Value, string | null, OuterType>( async (options) => { const a = await getA(options) return { spec: { type: 'text' as const, description: null, warning: null, masked: false, placeholder: null, minLength: null, maxLength: null, patterns: [], inputmode: 'text', disabled: false, immutable: false, generate: a.generate ?? null, ...a, }, validator: asRequiredParser(z.string(), a), } }, z.string().nullable(), ) } /** * @description Displays a large textarea field for long form entry. * @example * ``` textareaExample: Value.textarea({ // required name: 'Textarea Example', required: false, default: null, // optional description: null, placeholder: null, warning: null, minLength: null, maxLength: null, minRows: 3 maxRows: 6 immutable: false, }), * ``` */ static textarea(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null default: string | null required: Required minLength?: number | null maxLength?: number | null /** * @description A list of regular expressions to which the text must conform to pass validation. A human readable description is provided in case the validation fails. * @default [] * @example * ``` [ { regex: "[a-z]", description: "May only contain lower case letters from the English alphabet." } ] * ``` */ patterns?: Pattern[] /** Defaults to 3 */ minRows?: number /** Maximum number of rows before scroll appears. Defaults to 6 */ maxRows?: number placeholder?: string | null /** * @description Once set, the value can never be changed. * @default false */ immutable?: boolean }) { const validator = asRequiredParser(z.string(), a) return new Value>(async () => { const built: ValueSpecTextarea = { description: null, warning: null, minLength: null, maxLength: null, patterns: [], minRows: 3, maxRows: 6, placeholder: null, type: 'textarea' as const, disabled: false, immutable: a.immutable ?? false, ...a, } return { spec: built, validator } }, validator) } /** Like {@link Value.textarea} but options are resolved lazily at runtime via a builder function. */ static dynamicTextarea( getA: LazyBuild< { name: string description?: string | null warning?: string | null default: string | null required: Required minLength?: number | null maxLength?: number | null patterns?: Pattern[] minRows?: number maxRows?: number placeholder?: string | null disabled?: false | string }, OuterType >, ) { return new Value, string | null, OuterType>( async (options) => { const a = await getA(options) return { spec: { description: null, warning: null, minLength: null, maxLength: null, patterns: [], minRows: 3, maxRows: 6, placeholder: null, type: 'textarea' as const, disabled: false, immutable: false, ...a, }, validator: asRequiredParser(z.string(), a), } }, z.string().nullable(), ) } /** * @description Displays a number input field * @example * ``` numberExample: Value.number({ // required name: 'Number Example', required: false, default: null, integer: true, // optional description: null, placeholder: null, warning: null, min: null, max: null, immutable: false, step: null, units: null, }), * ``` */ static number(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** * @description optionally provide a default value. * @type { default: number | null } * @example default: null * @example default: 7 */ default: number | null required: Required min?: number | null max?: number | null /** * @description How much does the number increase/decrease when using the arrows provided by the browser. * @default 1 */ step?: number | null /** * @description Requires the number to be an integer. */ integer: boolean /** * @description Optionally display units to the right of the input box. */ units?: string | null placeholder?: string | null /** * @description Once set, the value can never be changed. * @default false */ immutable?: boolean }) { const validator = asRequiredParser(z.number(), a) return new Value>( () => ({ spec: { type: 'number' as const, description: null, warning: null, min: null, max: null, step: null, units: null, placeholder: null, disabled: false, immutable: a.immutable ?? false, ...a, }, validator, }), validator, ) } /** Like {@link Value.number} but options are resolved lazily at runtime via a builder function. */ static dynamicNumber( getA: LazyBuild< { name: string description?: string | null warning?: string | null default: number | null required: Required min?: number | null max?: number | null step?: number | null integer: boolean units?: string | null placeholder?: string | null disabled?: false | string }, OuterType >, ) { return new Value, number | null, OuterType>( async (options) => { const a = await getA(options) return { spec: { type: 'number' as const, description: null, warning: null, min: null, max: null, step: null, units: null, placeholder: null, disabled: false as const, immutable: false, ...a, }, validator: asRequiredParser(z.number(), a), } }, z.number().nullable(), ) } /** * @description Displays a browser-native color selector. * @example * ``` colorExample: Value.color({ // required name: 'Color Example', required: false, default: null, // optional description: null, warning: null, immutable: false, }), * ``` */ static color(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** * @description optionally provide a default value. * @type { default: string | null } * @example default: null * @example default: 'ffffff' */ default: string | null required: Required /** * @description Once set, the value can never be changed. * @default false */ immutable?: boolean }) { const validator = asRequiredParser(z.string(), a) return new Value>( () => ({ spec: { type: 'color' as const, description: null, warning: null, disabled: false, immutable: a.immutable ?? false, ...a, }, validator, }), validator, ) } /** Like {@link Value.color} but options are resolved lazily at runtime via a builder function. */ static dynamicColor( getA: LazyBuild< { name: string description?: string | null warning?: string | null default: string | null required: Required disabled?: false | string }, OuterType >, ) { return new Value, string | null, OuterType>( async (options) => { const a = await getA(options) return { spec: { type: 'color' as const, description: null, warning: null, disabled: false, immutable: false, ...a, }, validator: asRequiredParser(z.string(), a), } }, z.string().nullable(), ) } /** * @description Displays a browser-native date/time selector. * @example * ``` datetimeExample: Value.datetime({ // required name: 'Datetime Example', required: false, default: null, // optional description: null, warning: null, immutable: false, inputmode: 'datetime-local', min: null, max: null, }), * ``` */ static datetime(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** * @description optionally provide a default value. * @type { default: string | null } * @example default: null * @example default: '1985-12-16 18:00:00.000' */ default: string | null required: Required /** * @description Informs the browser how to behave and which date/time component to display. * @default "datetime-local" */ inputmode?: ValueSpecDatetime['inputmode'] min?: string | null max?: string | null /** * @description Once set, the value can never be changed. * @default false */ immutable?: boolean }) { const validator = asRequiredParser(z.string(), a) return new Value>( () => ({ spec: { type: 'datetime' as const, description: null, warning: null, inputmode: 'datetime-local', min: null, max: null, step: null, disabled: false, immutable: a.immutable ?? false, ...a, }, validator, }), validator, ) } /** Like {@link Value.datetime} but options are resolved lazily at runtime via a builder function. */ static dynamicDatetime( getA: LazyBuild< { name: string description?: string | null warning?: string | null default: string | null required: Required inputmode?: ValueSpecDatetime['inputmode'] min?: string | null max?: string | null disabled?: false | string }, OuterType >, ) { return new Value, string | null, OuterType>( async (options) => { const a = await getA(options) return { spec: { type: 'datetime' as const, description: null, warning: null, inputmode: 'datetime-local', min: null, max: null, disabled: false, immutable: false, ...a, }, validator: asRequiredParser(z.string(), a), } }, z.string().nullable(), ) } /** * @description Displays a select modal with radio buttons, allowing for a single selection. * @example * ``` selectExample: Value.select({ // required name: 'Select Example', default: 'radio1', values: { radio1: 'Radio 1', radio2: 'Radio 2', }, // optional description: null, warning: null, immutable: false, disabled: false, }), * ``` */ static select>(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** * @description Determines if the field is required. If so, optionally provide a default value from the list of values. * @type { (keyof Values & string) | null } * @example default: null * @example default: 'radio1' */ default: keyof Values & string /** * @description A mapping of unique radio options to their human readable display format. * @example * ``` { radio1: "Radio 1" radio2: "Radio 2" radio3: "Radio 3" } * ``` */ values: Values /** * @description Once set, the value can never be changed. * @default false */ immutable?: boolean }) { const validator = z.union( Object.keys(a.values).map((x: keyof Values & string) => z.literal(x)) as [ z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[], ], ) return new Value( () => ({ spec: { description: null, warning: null, type: 'select' as const, disabled: false, immutable: a.immutable ?? false, ...a, }, validator, }), validator, ) } /** Like {@link Value.select} but options are resolved lazily at runtime via a builder function. */ static dynamicSelect< Values extends Record, OuterType = unknown, >( getA: LazyBuild< { name: string description?: string | null warning?: string | null default: string values: Values disabled?: false | string | string[] }, OuterType >, ) { return new Value( async (options) => { const a = await getA(options) return { spec: { description: null, warning: null, type: 'select' as const, disabled: false, immutable: false, ...a, }, validator: z.union( Object.keys(a.values).map((x: keyof Values & string) => z.literal(x), ) as [ z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[], ], ), } }, z.string(), ) } /** * @description Displays a select modal with checkboxes, allowing for multiple selections. * @example * ``` multiselectExample: Value.multiselect({ // required name: 'Multiselect Example', values: { option1: 'Option 1', option2: 'Option 2', }, default: [], // optional description: null, warning: null, immutable: false, disabled: false, minlength: null, maxLength: null, }), * ``` */ static multiselect>(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** * @description A simple list of which options should be checked by default. */ default: (keyof Values & string)[] /** * @description A mapping of checkbox options to their human readable display format. * @example * ``` { option1: "Option 1" option2: "Option 2" option3: "Option 3" } * ``` */ values: Values minLength?: number | null maxLength?: number | null /** * @description Once set, the value can never be changed. * @default false */ immutable?: boolean }) { const validator = z.array( z.union( Object.keys(a.values).map((x) => z.literal(x)) as [ z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[], ], ), ) return new Value<(keyof Values & string)[]>( () => ({ spec: { type: 'multiselect' as const, minLength: null, maxLength: null, warning: null, description: null, disabled: false, immutable: a.immutable ?? false, ...a, }, validator, }), validator, ) } /** Like {@link Value.multiselect} but options are resolved lazily at runtime via a builder function. */ static dynamicMultiselect< Values extends Record, OuterType = unknown, >( getA: LazyBuild< { name: string description?: string | null warning?: string | null default: string[] values: Values minLength?: number | null maxLength?: number | null disabled?: false | string | string[] }, OuterType >, ) { return new Value< (keyof Values & string)[], (keyof Values & string)[], OuterType >(async (options) => { const a = await getA(options) return { spec: { type: 'multiselect' as const, minLength: null, maxLength: null, warning: null, description: null, disabled: false, immutable: false, ...a, }, validator: z.array( z.union( Object.keys(a.values).map((x) => z.literal(x)) as [ z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[], ], ), ), } }, z.array(z.string())) } /** * @description Display a collapsable grouping of additional fields, a "sub form". The second value is the inputSpec spec for the sub form. * @example * ``` objectExample: Value.object( { // required name: 'Object Example', // optional description: null, warning: null, }, InputSpec.of({}), ), * ``` */ static object< Type extends StaticValidatedAs, StaticValidatedAs extends Record, >( a: { name: string description?: string | null }, spec: InputSpec, ) { const value = new Value(async (options) => { const built = await spec.build(options as any) return { spec: { type: 'object' as const, description: null, warning: null, ...a, spec: built.spec, }, validator: built.validator, } }, spec.validator) value._objectSpec = { inputSpec: spec, params: a } return value } /** * Displays a file upload input field. * * @param a.extensions - Allowed file extensions (e.g. `[".pem", ".crt"]`) * @param a.required - Whether a file must be selected */ static file(a: { name: string description?: string | null warning?: string | null extensions: string[] required: Required }) { const buildValue = { type: 'file' as const, description: null, warning: null, ...a, } return new Value>( () => ({ spec: { ...buildValue, }, validator: asRequiredParser(fileInfoParser, a), }), asRequiredParser(fileInfoParser, a), ) } /** Like {@link Value.file} but options are resolved lazily at runtime via a builder function. */ static dynamicFile( a: LazyBuild< { name: string description?: string | null warning?: string | null extensions: string[] required: Required }, OuterType >, ) { return new Value< AsRequired, FileInfo | null, OuterType >(async (options) => { const spec = { type: 'file' as const, description: null, warning: null, ...(await a(options)), } return { spec, validator: asRequiredParser(fileInfoParser, spec), } }, fileInfoParser.nullable()) } /** * @description Displays a dropdown, allowing for a single selection. Depending on the selection, a different object ("sub form") is presented. * @example * ``` unionExample: Value.union( { // required name: 'Union Example', default: 'option1', // optional description: null, warning: null, disabled: false, immutable: false, }, Variants.of({ option1: { name: 'Option 1', spec: InputSpec.of({}), }, option2: { name: 'Option 2', spec: InputSpec.of({}), }, }), ), * ``` */ static union< VariantValues extends { [K in string]: { name: string spec: InputSpec } }, >(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null variants: Variants /** * @description Provide a default value from the list of variants. * @type { string } * @example default: 'variant1' */ default: keyof VariantValues & string /** * @description Once set, the value can never be changed. * @default false */ immutable?: boolean }) { return new Value< typeof a.variants._TYPE, typeof a.variants.validator._output >(async (options) => { const built = await a.variants.build(options as any) return { spec: { type: 'union' as const, description: null, warning: null, disabled: false, ...a, variants: built.spec, immutable: a.immutable ?? false, }, validator: built.validator, } }, a.variants.validator) } /** Like {@link Value.union} but options (including which variants are available) are resolved lazily at runtime. */ static dynamicUnion< VariantValues extends { [K in string]: { name: string spec: InputSpec } }, OuterType = unknown, >( getA: LazyBuild< { name: string description?: string | null warning?: string | null variants: Variants default: keyof VariantValues & string disabled: string[] | false | string }, OuterType >, ): Value, UnionRes, OuterType> /** Like {@link Value.union} but options are resolved lazily, with an explicit static validator type. */ static dynamicUnion< StaticVariantValues extends { [K in string]: { name: string spec: InputSpec } }, VariantValues extends StaticVariantValues, OuterType = unknown, >( getA: LazyBuild< { name: string description?: string | null warning?: string | null variants: Variants default: keyof VariantValues & string disabled: string[] | false | string }, OuterType >, validator: z.ZodType>, ): Value< UnionRes, UnionResStaticValidatedAs, OuterType > static dynamicUnion< VariantValues extends { [K in string]: { name: string spec: InputSpec } }, OuterType = unknown, >( getA: LazyBuild< { name: string description?: string | null warning?: string | null variants: Variants default: keyof VariantValues & string disabled: string[] | false | string }, OuterType >, validator: z.ZodType = z.any(), ) { return new Value< UnionRes, z.infer, OuterType >(async (options) => { const newValues = await getA(options) const built = await newValues.variants.build(options as any) return { spec: { type: 'union' as const, description: null, warning: null, ...newValues, variants: built.spec, immutable: false, }, validator: built.validator, } }, validator) } /** * @description Presents an interface to add/remove/edit items in a list. * @example * In this example, we create a list of text inputs. * * ``` listExampleText: Value.list( List.text( { // required name: 'Text List', // optional description: null, warning: null, default: [], minLength: null, maxLength: null, }, { // required patterns: [], // optional placeholder: null, generate: null, inputmode: 'url', masked: false, minLength: null, maxLength: null, }, ), ), * ``` * @example * In this example, we create a list of objects. * * ``` listExampleObject: Value.list( List.obj( { // required name: 'Object List', // optional description: null, warning: null, default: [], minLength: null, maxLength: null, }, { // required spec: InputSpec.of({}), // optional displayAs: null, uniqueBy: null, }, ), ), * ``` */ static list(a: List) { return new Value((options) => a.build(options), a.validator) } /** * @description Provides a way to define a hidden field with a static value. Useful for tracking * @example * ``` hiddenExample: Value.hidden(), * ``` */ static hidden(): Value static hidden(parser: z.ZodType): Value static hidden(parser: z.ZodType = z.any()) { return new Value>(async () => { return { spec: { type: 'hidden' as const, } as ValueSpecHidden, validator: parser, } }, parser) } /** * @description Provides a way to define a hidden field with a static value. Useful for tracking * @example * ``` hiddenExample: Value.hidden(), * ``` */ static dynamicHidden( getParser: LazyBuild, OuterType>, ) { return new Value(async (options) => { const validator = await getParser(options) return { spec: { type: 'hidden' as const, } as ValueSpecHidden, validator, } }, z.any()) } /** * Returns a new Value that produces the same field spec but with `disabled` set to the given message. * The field remains in the form but cannot be edited by the user. * * @param message - The reason the field is disabled, displayed to the user */ withDisabled(message: string): Value { const original = this const v = new Value(async (options) => { const built = await original.build(options) return { spec: { ...built.spec, disabled: message } as ValueSpec, validator: built.validator, } }, this.validator) v._objectSpec = this._objectSpec return v } /** * Transforms the validated output value using a mapping function. * The form field itself remains unchanged, but the value is transformed after validation. * * @param fn - A function to transform the validated value */ map(fn: (value: StaticValidatedAs) => U): Value { return new Value(async (options) => { const built = await this.build(options) return { spec: built.spec, validator: built.validator.transform(fn), } }, this.validator.transform(fn)) } }