import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Match, Option } from "effect"; import { Checkbox } from "./checkbox"; import { Input } from "./input"; import { Label } from "./label"; import { NativeSelect, NativeSelectOption } from "./native-select"; import { Textarea } from "./textarea"; export type ElicitationAction = "accept" | "decline" | "cancel"; export type ElicitationFieldValue = string | number | boolean | string[]; type ElicitationSchemaField = { readonly name: string; readonly schema: Record; readonly required: boolean; }; type ElicitationFormSchema = { readonly fields: readonly ElicitationSchemaField[]; }; type SelectOption = { readonly value: string; readonly label: string; }; export type ElicitationApprovalState = { readonly hasFields: boolean; readonly content: () => Record | null; readonly fields: ReactNode; }; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string"); const toOptionalString = (value: unknown): string | undefined => typeof value === "string" && value.length > 0 ? value : undefined; const fieldLabel = (field: ElicitationSchemaField): string => toOptionalString(field.schema.title) ?? field.name; const fieldDescription = (field: ElicitationSchemaField): string | undefined => toOptionalString(field.schema.description); const enumOptions = (schema: Record): readonly SelectOption[] => { const oneOf = schema.oneOf; if (Array.isArray(oneOf)) { return oneOf.flatMap((item): SelectOption[] => { if (!isRecord(item) || typeof item.const !== "string") return []; return [{ value: item.const, label: toOptionalString(item.title) ?? item.const }]; }); } const values = schema.enum; if (!isStringArray(values)) return []; const labels = isStringArray(schema.enumNames) ? schema.enumNames : values; return values.map((value, index) => ({ value, label: labels[index] ?? value })); }; const multiSelectOptions = (schema: Record): readonly SelectOption[] => { const items = schema.items; if (!isRecord(items)) return []; const anyOf = items.anyOf; if (Array.isArray(anyOf)) { return anyOf.flatMap((item): SelectOption[] => { if (!isRecord(item) || typeof item.const !== "string") return []; return [{ value: item.const, label: toOptionalString(item.title) ?? item.const }]; }); } const values = items.enum; if (!isStringArray(values)) return []; const labels = isStringArray(items.enumNames) ? items.enumNames : values; return values.map((value, index) => ({ value, label: labels[index] ?? value })); }; const parseElicitationFormSchema = (value: unknown): ElicitationFormSchema | null => { if (!isRecord(value) || !isRecord(value.properties)) return null; const required = isStringArray(value.required) ? value.required : []; const fields = Object.entries(value.properties).flatMap( ([name, schema]): ElicitationSchemaField[] => isRecord(schema) ? [{ name, schema, required: required.includes(name) }] : [], ); return fields.length > 0 ? { fields } : null; }; const initialFieldValue = (field: ElicitationSchemaField): ElicitationFieldValue | undefined => { const value = field.schema.default; if (value === undefined) return undefined; const matched = Match.value(field.schema.type).pipe( Match.when("boolean", () => value === true), Match.when( (type: unknown) => type === "number" || type === "integer", () => { const numberValue = typeof value === "number" ? value : Number(value); return Number.isFinite(numberValue) ? numberValue : undefined; }, ), Match.when("array", () => (isStringArray(value) ? value : undefined)), Match.option, ); return Option.getOrElse(matched, () => (typeof value === "string" ? value : String(value))); }; const initialFormValues = ( formSchema: ElicitationFormSchema | null, ): Record => { const values: Record = {}; for (const field of formSchema?.fields ?? []) { const value = initialFieldValue(field); if (value !== undefined) values[field.name] = value; } return values; }; const isEmptyFormValue = (value: ElicitationFieldValue | undefined): boolean => value === undefined || value === "" || (Array.isArray(value) && value.length === 0); const numericConstraint = ( schema: Record, key: "minimum" | "maximum", ): number | undefined => (typeof schema[key] === "number" ? schema[key] : undefined); const lengthConstraint = ( schema: Record, key: "minLength" | "maxLength" | "minItems" | "maxItems", ): number | undefined => (typeof schema[key] === "number" ? schema[key] : undefined); const validateEmail = (value: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); const validateUrl = (value: string): boolean => { try { void new URL(value); return true; } catch { return false; } }; const validateFieldValue = ( field: ElicitationSchemaField, value: ElicitationFieldValue | undefined, ): { readonly value?: ElicitationFieldValue; readonly error?: string } => { if (isEmptyFormValue(value)) { return field.required ? { error: "This field is required." } : {}; } if (field.schema.type === "boolean") { return typeof value === "boolean" ? { value } : { error: "Choose true or false." }; } if (field.schema.type === "number" || field.schema.type === "integer") { const numberValue = typeof value === "number" ? value : Number(value); const typeLabel = field.schema.type === "integer" ? "an integer" : "a number"; if (!Number.isFinite(numberValue)) return { error: `Must be ${typeLabel}.` }; if (field.schema.type === "integer" && !Number.isInteger(numberValue)) { return { error: "Must be an integer." }; } const minimum = numericConstraint(field.schema, "minimum"); const maximum = numericConstraint(field.schema, "maximum"); if (minimum !== undefined && numberValue < minimum) return { error: `Must be >= ${minimum}.` }; if (maximum !== undefined && numberValue > maximum) return { error: `Must be <= ${maximum}.` }; return { value: numberValue }; } if (field.schema.type === "array") { const selected = Array.isArray(value) ? value : []; const options = multiSelectOptions(field.schema); const allowed = new Set(options.map((option) => option.value)); if (!selected.every((item) => allowed.has(item))) return { error: "Choose a valid option." }; const minItems = lengthConstraint(field.schema, "minItems"); const maxItems = lengthConstraint(field.schema, "maxItems"); if (minItems !== undefined && selected.length < minItems) { return { error: `Choose at least ${minItems} option${minItems === 1 ? "" : "s"}.` }; } if (maxItems !== undefined && selected.length > maxItems) { return { error: `Choose at most ${maxItems} option${maxItems === 1 ? "" : "s"}.` }; } return { value: selected }; } const stringValue = String(value); const options = enumOptions(field.schema); if (options.length > 0 && !options.some((option) => option.value === stringValue)) { return { error: "Choose a valid option." }; } const minLength = lengthConstraint(field.schema, "minLength"); const maxLength = lengthConstraint(field.schema, "maxLength"); if (minLength !== undefined && stringValue.length < minLength) { return { error: `Must be at least ${minLength} character${minLength === 1 ? "" : "s"}.` }; } if (maxLength !== undefined && stringValue.length > maxLength) { return { error: `Must be at most ${maxLength} character${maxLength === 1 ? "" : "s"}.` }; } if (field.schema.format === "email" && !validateEmail(stringValue)) { return { error: "Must be a valid email address." }; } if (field.schema.format === "uri" && !validateUrl(stringValue)) { return { error: "Must be a valid URL." }; } return { value: stringValue }; }; const buildElicitationContent = ( formSchema: ElicitationFormSchema | null, formValues: Record, ): { readonly content: Record; readonly errors: Record } => { if (!formSchema) return { content: {}, errors: {} }; const content: Record = {}; const errors: Record = {}; for (const field of formSchema.fields) { const result = validateFieldValue(field, formValues[field.name]); if (result.error) { errors[field.name] = result.error; continue; } if (result.value !== undefined) content[field.name] = result.value; } return { content, errors }; }; export function useElicitationApproval(schema: unknown): ElicitationApprovalState { const formSchema = useMemo(() => parseElicitationFormSchema(schema), [schema]); const [formValues, setFormValues] = useState>(() => initialFormValues(formSchema), ); const [fieldErrors, setFieldErrors] = useState>({}); useEffect(() => { setFormValues(initialFormValues(formSchema)); setFieldErrors({}); }, [formSchema]); const setFieldValue = (name: string, value: ElicitationFieldValue | undefined) => { setFormValues((prev) => { const next = { ...prev }; if (value === undefined) { delete next[name]; } else { next[name] = value; } return next; }); setFieldErrors((prev) => { if (!prev[name]) return prev; const next = { ...prev }; delete next[name]; return next; }); }; return { hasFields: Boolean(formSchema), content: () => { const result = buildElicitationContent(formSchema, formValues); setFieldErrors(result.errors); return Object.keys(result.errors).length > 0 ? null : result.content; }, fields: formSchema ? ( ) : null, }; } function ElicitationApprovalFields({ formSchema, formValues, fieldErrors, onChange, }: { formSchema: ElicitationFormSchema; formValues: Record; fieldErrors: Record; onChange: (name: string, value: ElicitationFieldValue | undefined) => void; }) { return (
Additional details
These values will be sent with approval.
{formSchema.fields.map((field) => ( onChange(field.name, value)} /> ))}
); } function ElicitationApprovalField({ field, value, error, onChange, }: { field: ElicitationSchemaField; value: ElicitationFieldValue | undefined; error: string | undefined; onChange: (value: ElicitationFieldValue | undefined) => void; }) { const label = fieldLabel(field); const description = fieldDescription(field); const fieldId = `trusted-interaction-field-${field.name}`; const describedBy = description ? `${fieldId}-description` : undefined; const errorId = error ? `${fieldId}-error` : undefined; const ariaDescribedBy = [describedBy, errorId].filter(Boolean).join(" ") || undefined; return (
{description && (
{description}
)} {error && (
{error}
)}
); } function ElicitationApprovalFieldControl({ field, fieldId, value, ariaDescribedBy, invalid, onChange, }: { field: ElicitationSchemaField; fieldId: string; value: ElicitationFieldValue | undefined; ariaDescribedBy: string | undefined; invalid: boolean; onChange: (value: ElicitationFieldValue | undefined) => void; }) { const options = enumOptions(field.schema); if (options.length > 0) { return ( onChange(event.target.value || undefined)} aria-describedby={ariaDescribedBy} aria-invalid={invalid} className="w-full" > Select... {options.map((option) => ( {option.label} ))} ); } const multiOptions = multiSelectOptions(field.schema); if (multiOptions.length > 0) { const selected = Array.isArray(value) ? value : []; return (
{multiOptions.map((option) => { const checked = selected.includes(option.value); return ( ); })}
); } if (field.schema.type === "boolean") { return ( ); } if (field.schema.type === "number" || field.schema.type === "integer") { return ( onChange(event.target.value)} aria-describedby={ariaDescribedBy} aria-invalid={invalid} /> ); } const isLongText = typeof field.schema.maxLength === "number" && field.schema.maxLength > 160; if (field.schema.type === "string" && isLongText && field.schema.format === undefined) { return (