480 lines · 16.2 KB · TypeScript
RawDownload
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<string, unknown>;  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<string, unknown> | null;  readonly fields: ReactNode;}; const isRecord = (value: unknown): value is Record<string, unknown> =>  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<string, unknown>): 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<string, unknown>): 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<string, ElicitationFieldValue> => {  const values: Record<string, ElicitationFieldValue> = {};  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<string, unknown>,  key: "minimum" | "maximum",): number | undefined => (typeof schema[key] === "number" ? schema[key] : undefined); const lengthConstraint = (  schema: Record<string, unknown>,  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<string, ElicitationFieldValue>,): { readonly content: Record<string, unknown>; readonly errors: Record<string, string> } => {  if (!formSchema) return { content: {}, errors: {} };   const content: Record<string, unknown> = {};  const errors: Record<string, string> = {};  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<Record<string, ElicitationFieldValue>>(() =>    initialFormValues(formSchema),  );  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});   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 ? (      <ElicitationApprovalFields        formSchema={formSchema}        formValues={formValues}        fieldErrors={fieldErrors}        onChange={setFieldValue}      />    ) : null,  };} function ElicitationApprovalFields({  formSchema,  formValues,  fieldErrors,  onChange,}: {  formSchema: ElicitationFormSchema;  formValues: Record<string, ElicitationFieldValue>;  fieldErrors: Record<string, string>;  onChange: (name: string, value: ElicitationFieldValue | undefined) => void;}) {  return (    <div data-testid="trusted-interaction-fields" className="space-y-3">      <div>        <div className="text-xs font-medium text-muted-foreground">Additional details</div>        <div className="mt-0.5 text-xs text-muted-foreground">          These values will be sent with approval.        </div>      </div>      {formSchema.fields.map((field) => (        <ElicitationApprovalField          key={field.name}          field={field}          value={formValues[field.name]}          error={fieldErrors[field.name]}          onChange={(value) => onChange(field.name, value)}        />      ))}    </div>  );} 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 (    <div className="space-y-1.5" data-testid={`trusted-interaction-field-${field.name}`}>      <Label htmlFor={fieldId} className="text-xs font-medium">        {label}        {field.required && <span className="ml-1 text-destructive">*</span>}      </Label>      <ElicitationApprovalFieldControl        field={field}        fieldId={fieldId}        value={value}        ariaDescribedBy={ariaDescribedBy}        invalid={Boolean(error)}        onChange={onChange}      />      {description && (        <div id={describedBy} className="text-xs text-muted-foreground">          {description}        </div>      )}      {error && (        <div id={errorId} className="text-xs text-destructive">          {error}        </div>      )}    </div>  );} 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 (      <NativeSelect        id={fieldId}        value={typeof value === "string" ? value : ""}        onChange={(event) => onChange(event.target.value || undefined)}        aria-describedby={ariaDescribedBy}        aria-invalid={invalid}        className="w-full"      >        <NativeSelectOption value="">Select...</NativeSelectOption>        {options.map((option) => (          <NativeSelectOption key={option.value} value={option.value}>            {option.label}          </NativeSelectOption>        ))}      </NativeSelect>    );  }   const multiOptions = multiSelectOptions(field.schema);  if (multiOptions.length > 0) {    const selected = Array.isArray(value) ? value : [];    return (      <div        id={fieldId}        aria-describedby={ariaDescribedBy}        aria-invalid={invalid}        className="space-y-1.5 rounded-md border border-border p-2"      >        {multiOptions.map((option) => {          const checked = selected.includes(option.value);          return (            <Label key={option.value} className="flex items-center gap-2 text-sm">              <Checkbox                checked={checked}                onCheckedChange={(nextChecked) => {                  const next = nextChecked === true;                  onChange(                    next                      ? [...selected, option.value]                      : selected.filter((item) => item !== option.value),                  );                }}              />              <span>{option.label}</span>            </Label>          );        })}      </div>    );  }   if (field.schema.type === "boolean") {    return (      <Label className="flex items-center gap-2 rounded-md border border-border p-2 text-sm">        <Checkbox          id={fieldId}          checked={value === true}          onCheckedChange={(nextChecked) => onChange(nextChecked === true)}          aria-describedby={ariaDescribedBy}          aria-invalid={invalid}        />        <span>Yes</span>      </Label>    );  }   if (field.schema.type === "number" || field.schema.type === "integer") {    return (      <Input        id={fieldId}        type="number"        value={value === undefined ? "" : String(value)}        step={field.schema.type === "integer" ? 1 : "any"}        min={numericConstraint(field.schema, "minimum")}        max={numericConstraint(field.schema, "maximum")}        onChange={(event) => 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 (      <Textarea        id={fieldId}        value={typeof value === "string" ? value : ""}        onChange={(event) => onChange(event.target.value)}        aria-describedby={ariaDescribedBy}        aria-invalid={invalid}        className="min-h-20"      />    );  }   const inputType =    field.schema.format === "email" ? "email" : field.schema.format === "uri" ? "url" : "text";  return (    <Input      id={fieldId}      type={inputType}      value={typeof value === "string" ? value : ""}      onChange={(event) => onChange(event.target.value)}      aria-describedby={ariaDescribedBy}      aria-invalid={invalid}    />  );}