Skip to content

Spike: Investigate approaches for adding field validation from react-hook-form #372

Closed

Description

@handreyrc

Description

Investigate approaches for adding field validation from react-hook-form.

Motivation

react-hook-form is the chosen library for form generation, so it shall consume a filtered task schema to build a complete form for a selected task. Form libraries generally come with their own strategies to validate fields out-of-the-box, and react-hook-form is no different.

react-hook-form uses resolvers to extend form validation straight from a schema. However, there are some requirements to achieve the proposed layered validation, so it is necessary to investigate the best way to achieve that.

Proposed Implementation

No response

Definition of Done

  • Implementation: Build a PoC consuming a task filtered schema and react-hook-form considering layered validation as requirement.
  • Unit Tests: Comprehensive unit tests are included and passing.
  • Integration Tests: Verified within the monorepo and target environments (Web/VS Code).
  • Documentation: Updated README.md, ADRs, or official docs.
  • Performance: No significant regression in editor responsiveness.
  • Accessibility: UI changes comply with accessibility standards.of-thett

Activity

  1. self-assigned this
    on Aug 28, 2026
  2. added theissue type on Aug 28, 2026
  3. moved this from Backlog to Next in Editor Project Trackeron Aug 28, 2026
  4. handreyrc commented on Sep 2, 2026

    @handreyrc
    ContributorAuthor

    Task Form Validation Workflow

    To implement validation in the task form, the pipeline shall follow four sequential steps:

    1. Build and Initialize the Form from Filtered Schema
      Extract the scoped task schema for the selected node type (getFormFieldsForNodeType) and walk it to produce a typed FormFieldDescriptor[] tree. The form is initialized with useForm, seeding defaultValues by flattening the live task instance into matching dot-notation paths (e.g. for.each, call.http.endpoint).

    2. Add Validation Capabilities with a Lightweight Resolver
      Attach a lightweight custom resolver (buildTaskFormResolver) to React Hook Form. It traverses active fields against live form values and enforces essential schema constraints: required presence, type assertions (number, boolean), enum membership, and ISO 8601 duration patterns without heavyweight dependencies.

    3. Incorporate Layered Validation Support
      Validation operates across three complementary layers:

      • SDK Workflow Validation: On mount or task change, workflow-level SDK diagnostics mapped to the task (useWorkflowErrorsForForm) are injected directly into matching field paths, surfacing pre-existing model errors immediately.
      • Field-Level Validation (on blur): React Hook Form validates individual fields as they lose focus (onBlur / touched state), giving fast contextual feedback per control.
      • Task-Level Form Validation: Full form validation evaluates all active fields across variants during submission or apply actions, guaranteeing integrity before committing edits back to the workflow model.
    4. Handle Error Messages using Current Types and Structures
      Error handling hooks into the existing types and localization system:

      • Differentiates error origins via explicit types (schema for resolver-driven rules vs. workflow for SDK-level errors).
      • Generates locale-aware messages through the FormErrors interface and buildFormErrors(t) helper to keep message templates consistent and fully localized.
  5. handreyrc commented on Sep 2, 2026

    @handreyrc
    ContributorAuthor

    Schema → Form Fields

    The key for building the form from schema is to translate the schema into a FormFieldDescriptor tree that drives the task form UI.


    Overview

    The pipeline runs in four stages, preceded by a prerequisite step that supplies the task instance to populate:

    current workflow model
            │
            ▼
      Task population: resolve selected node → task instance
            │
            ▼
    workflowSchema ($defs)
            │
            ▼
      Stage 1: extract and prepare the definition
            │
            ▼
      Stage 2: resolve node type → definition name
            │
            ▼
      Stage 3: walk schema → FormFieldDescriptor[]
            │
            ▼
      Stage 4: render form with react-hook-form
    

    The result is a live form whose fields and default values all derive from the descriptor tree (schema) and the task instance (data) together.


    Task population — seeding default values

    When a node is selected in the diagram, the corresponding task instance is read directly from the React Flow node data. That data carries a structured clone of the task object taken from the current workflow model at diagram-build time, so it always reflects the latest parsed state of the workflow — including any undo/redo changes applied since the form was last open.

    The task object is then flattened into a dot-notation record before it is handed to the form. Nested properties are expanded recursively (e.g. { for: { each: "${items}" } } becomes { "for.each": "${items}" }), while arrays are kept as-is under their own key. The resulting flat record is used as the defaultValues for useForm.

    The flattened paths must match the path values on the FormFieldDescriptor tree produced by the schema walk — that alignment is what ensures each control starts with the correct value from the task. If a property is absent from the task, the corresponding path simply has no entry in defaultValues and the control starts empty.

    The form also resets to a fresh snapshot of the task whenever the selected node changes or when the model is updated externally (e.g. undo/redo with the same node still selected), so the displayed values always stay in sync with the model.


    Stage 1 — Schema extraction

    Entry point: getSchemaForDefinition(definitionName)

    Produces a task-scoped, self-contained schema extracted from the workflow schema exposed by the SDK. From this point on, all subsequent stages work exclusively against that scoped schema.


    Stage 2 — Node-type mapping

    Entry point: getFormFieldsForNodeType(nodeType)

    The diagram operates with graph node types (e.g. call, for, try), but the schema uses definition names (e.g. "callTask", "forTask"). A mapping step is needed to bridge the two: without it the walker has no way to know which schema definition to load for a given node selected in the diagram.

    The output of this stage is the ordered FormFieldDescriptor[] for the selected node type — the complete field tree ready to be handed to Stage 3 for walking. Because node types and their schemas are static, the result is cached so the work happens only once per type for the lifetime of the editor.


    Stage 3 — Schema walker

    Entry point: schemaToFormFields(schema, defs, requiredSet, path)

    The walker traverses the task-scoped schema from Stage 1 and translates each property into a typed FormFieldDescriptor. It recurses into nested objects and variant branches, building a tree that mirrors the schema structure while carrying only the information the form UI needs — kind, path, label, required flag, and any kind-specific data such as enum options or variant lists.

    The result is a FormFieldDescriptor[] that is fully decoupled from the schema: it contains no JSON Schema syntax, no $ref pointers, and no structural scaffolding. Each descriptor maps to exactly one UI control and carries a dot-notation path that doubles as the react-hook-form field name.

    That path alignment is what connects the descriptor tree to react-hook-form. The form is initialised with defaultValues built by flattening the live task object along the same dot-notation paths (e.g. "for.each", "call.http.endpoint"). From that point, react-hook-form owns the field state and each descriptor drives three things independently:

    • Rendering — the kind determines which control to mount and the path is passed to register or Controller so react-hook-form tracks the field's value.
    • Validation — the resolver receives the same descriptor list and reads each field's live value via the path, applying the required check and any kind-specific constraint (type, enum membership, ISO 8601 pattern).
    • Read-only filtering — the matchesData predicate on OneOfField variants is used to auto-select the correct variant when displaying an existing task without user interaction.

    Stage 4 — Form rendering

    Each FormFieldDescriptor in the tree is rendered as one row in the form. The process has three parts that work together.

    Initialisation. Before rendering, the live task object is flattened into a dot-notation record that matches the descriptor paths (e.g. { "for.each": "${items}", "for.in": "${data}" }). This record becomes the defaultValues for useForm, so react-hook-form starts with the current task state already populated.

    Rendering. The descriptor tree is walked recursively. Each node is handled by its kind:

    FormFieldDescriptor
    │
    ├── kind: "object"       → collapsible group; recurses into children
    ├── kind: "one-of"       → variant selector (select); matchesData picks
    │                          the active variant; recurses into its fields
    └── leaf kinds           → FieldControl dispatcher:
          ├── "string"       → Input or Textarea (multiline flag)
          ├── "number"       → Input type="number"
          ├── "boolean"      → Switch (toggle)
          ├── "enum"         → Select with options from descriptor
          ├── "duration"     → Input with ISO 8601 pattern hint
          ├── "then"         → Select populated from sibling task names
          └── "child-task-list" → read-only list of child task names
    

    Every leaf control uses Controller with name={field.path}. That single prop is the only connection between the descriptor and react-hook-form — it tells RHF which slot in the form state this control reads from and writes to.


  6. handreyrc commented on Sep 2, 2026

    @handreyrc
    ContributorAuthor

    Proposed Form Validation Strategy

    Validation in the task form is layered and mode-aware: what runs depends on whether the editor is in read-only or edit mode. A fast, descriptor-driven field-level pass runs first in edit mode; a heavier workflow-level pass runs only on Apply. Both layers write their errors into the same react-hook-form error map so the UI is consistent regardless of which layer raised an issue.


    Overview

                            ┌─────────────────────────────────────┐
                            │   read-only mode                    │
                            │                                     │
                            │  FormFieldDescriptor[] (filtered)   │
                            │          │                          │
                            │          ▼                          │
                            │   useWorkflowErrorsForForm          │
                            │   (pre-seed SDK errors on mount)    │
                            │          │                          │
                            │          ▼                          │
                            │   RHF error map (display only)      │
                            └─────────────────────────────────────┘
    
                            ┌─────────────────────────────────────┐
                            │   edit mode                         │
                            │                                     │
                            │  FormFieldDescriptor[] (filtered)   │
                            │          │                          │
                            │          ▼                          │
                            │   Layer 1 — field-level resolver    │
                            │   (buildTaskFormResolver)           │
                            │   runs on every blur / trigger()    │
                            │          │ on valid                 │
                            │          ▼                          │
                            │   unflattenValues → updateTask      │
                            │          │                          │
                            │          ▼                          │
                            │   Layer 2 — workflow-level          │
                            │   (validateWorkflow via SDK)        │
                            │   runs only on Apply                │
                            │          │                          │
                            │          ▼                          │
                            │   setError() → RHF error map        │
                            └─────────────────────────────────────┘
    

    The result is a single error surface: field highlights and messages in the form come from either layer and are visually indistinguishable to the user.


    The filtered schema as the validation contract

    The FormFieldDescriptor[] tree produced by schemaToFormFields (see SCHEMA2FORM.md) is the sole input for field-level validation. The resolver never touches the raw JSON Schema again — all type constraints, required flags, enum options, and format patterns have already been extracted into the descriptor at walk time.

    This means the validation contract is identical to the rendering contract: whatever fields are drawn in the form are exactly the fields that are validated, with the same paths and the same rules. This is true in both modes, though read-only mode never runs the resolver (see below).

    Variant-aware leaf collection

    A one-of field represents mutually exclusive schema branches. Validating leaves from all variants simultaneously would fire spurious required-field errors for paths that belong to inactive variants (e.g. the arguments.method enum on an MCP task firing when an HTTP task is open).

    collectLeafFields resolves this by discriminating each one-of against the current form values before recursing:

    for each field in the descriptor tree:
      kind === "object"  → recurse into children
      kind === "one-of"  → read dataAtPath, find first variant where
                            matchesData(dataAtPath) is true, recurse
                            into that variant's fields only
      leaf kind          → add to leaves list
    

    For the root-level one-of (path "__root__"), the full flat values object is used as the discriminator input. For property-level one-ofs, get(values, field.path) is used so that RHF's nested value tree is read correctly.


    Read-only mode

    In read-only mode the form renders the task's current values for inspection only. No user edits are accepted, so interactive validation is disabled entirely.

    What is active:

    • filterReadOnlyFields prunes the full descriptor tree to only the fields that have a non-empty value in the task object. Fields with no data are not rendered.
    • useWorkflowErrorsForForm still runs on mount and on every task selection change. If the workflow was loaded in an invalid state, SDK errors for the selected task are mapped to dot-notation field paths and set on the RHF error map via setError. Fields with errors are highlighted immediately so the user can see which properties are invalid even without being able to edit them.
    • Errors set here carry type: "workflow".

    What is disabled:

    • The resolver is set to undefined — buildTaskFormResolver is never called and Layer 1 never runs.
    • Layer 2 (useApply) is never triggered — there is no Apply button in read-only mode.
    • onValidityChange is never called, so the Apply button state is irrelevant.

    Wiring (read-only):

    TaskForm (isReadOnly = true)
     ├── allFields = getFormFieldsForNodeType(nodeType)
     ├── visibleFields = filterReadOnlyFields(allFields, task)   ← prune empty fields
     ├── resolver = undefined                                    ← Layer 1 disabled
     ├── useForm({ mode:"onBlur" })                             ← no resolver
     ├── useWorkflowErrorsForForm(...)                          ← seed SDK errors on mount
     └── <FormProvider>                                         ← errors flow to fields
    

    Edit mode

    In edit mode the form is fully interactive. Both validation layers are active and operate in sequence.

    Layer 1 — Field-level validation (resolver)

    Entry point: buildTaskFormResolver(fields, formErrors)

    The resolver is built once per form lifecycle (or whenever allFields or the active locale changes) and wired into useForm as the resolver option. react-hook-form calls it on every blur event (mode "onBlur") and whenever trigger() is called explicitly.

    What it validates

    validateLeaf applies the following checks against each leaf descriptor, in order:

    Check Applies to Rule
    Required all leaf kinds value is non-empty (non-null, non-undefined, non-blank string)
    Type: number NumberField value parses as a finite number
    Type: boolean BooleanField value is a JavaScript boolean
    Enum membership EnumField value is one of field.options
    ISO 8601 format DurationField value matches the full ISO 8601 duration regex

    child-task-list, object, and one-of containers are never validated directly — they are structural nodes, not leaf values.

    Error types

    Every error produced by the resolver carries type: "schema". This distinguishes resolver errors from workflow-level errors (type: "workflow") so future tooling can tell them apart without inspecting the message.

    Locale-aware error messages

    Error messages are not hardcoded. buildTaskFormResolver receives a FormErrors bag built from the active i18n t function via buildFormErrors(t). Each method in FormErrors formats one error category using the locale string and inline {placeholder} interpolation. The resolver itself only calls errors.required(field.label), errors.mustBeNumber(field.label), etc. — it has no knowledge of locale keys or string templates.

    Layer 2 — Workflow-level validation (on Apply)

    Entry point: useApply → handleApply

    Layer 2 runs only when the user presses Apply. It uses the full SDK validator (validateWorkflow) against a draft model that already incorporates the proposed field values, so it catches cross-task constraints, reference integrity issues, and any schema rules the descriptor tree does not expose.

    Flow

    1. trigger()                          → re-run Layer 1 resolver
       └─ invalid? → abort (fields already highlighted by RHF)
    
    2. getValues() → unflattenValues()    → reconstruct nested task object
    3. updateTask(model, nodeId, updated) → produce draft workflow model
    4. validateWorkflow(draftModel)       → full SDK validation
    5. getNodeErrors(errors, taskRef)     → filter to selected task only
       └─ errors found?
           → toast "This task has errors!"
           → for each error: getNodeErrorField → setError(field, {type:"workflow"})
           → abort
       └─ no errors?
           → toast "Changes applied!"
           → (model state update wired here when API is ready)
    

    Errors on other tasks in the draft are intentionally ignored — the form is scoped to the selected task.

    Value reconstruction

    unflattenValues reverses the flattenTask flattening done at form initialisation. It walks the dot-notation keys in the RHF value record and reconstructs the nested task object (e.g. { "for.each": "${items}" } → { for: { each: "${items}" } }). Arrays (child-task-list values) are kept as-is.

    Pre-seeding errors on mount (edit mode)

    Entry point: useWorkflowErrorsForForm

    This hook also runs in edit mode. When a task is selected that already has SDK validation errors (e.g. the workflow was loaded in an invalid state), field highlights appear immediately — before the user touches anything. It calls getNodeErrors to filter the full error list to the selected task, then maps each error to a form field path via getNodeErrorField. Only errors that resolve to a known dot-notation path are set; errors without a resolvable field are left to the ErrorSection above the form, which already renders them as plain text.

    Errors set here carry type: "workflow", matching the type used by Layer 2, so the UI treats them identically.

    Wiring (edit mode):

    TaskForm (isReadOnly = false)
     ├── allFields = getFormFieldsForNodeType(nodeType)    ← full descriptor tree
     ├── visibleFields = allFields                         ← no field pruning
     ├── formErrors = buildFormErrors(t)                   ← locale-aware messages
     ├── resolver = buildTaskFormResolver(allFields, formErrors)
     ├── useForm({ mode:"onBlur", resolver })
     ├── useWorkflowErrorsForForm(...)                     ← seed SDK errors on mount
     └── <FormProvider>                                    ← errors flow to fields
    

    The resolver is rebuilt only when allFields or formErrors (locale) change — not on every render.


    Error type taxonomy

    type Set by Mode When
    "schema" buildTaskFormResolver (Layer 1) edit only on blur / trigger
    "workflow" useWorkflowErrorsForForm both on mount / task change
    "workflow" useApply (Layer 2) edit only on Apply, after draft validation

  7. handreyrc commented on Sep 2, 2026

    @handreyrc
    ContributorAuthor

    @lornakelly, @fantonangeli, @kumaradityaraj,

    I created a PoC that builds the form from the filtered schema and also implements the proposed layered validation strategy. It is not fully implemented, so there are bugs — however, it is good enough for validating the form-building process and form validation.

    Repo:form-resolver

    To explore it, use the "Undo Redo" story.

    Note: I only worked with the callTask defined in the "Undo Redo" workflow, so it is expected to be broken with other task types.

    WDYT?

  8. lornakelly commented on Sep 4, 2026

    @lornakelly
    Collaborator

    Thanks for the detail Handrey and the POC which helped a lot. Hard to forsee all issues but overall looks very good. I think we have already agreed but lets start small with setTask as callTask is a complex case, then we can tackle things bit by bit

    The stages mentioned above look good to me, just a note on Stage 2 though (I noticed when I was working on my last task):

    I think we might have some issues with try/catch because it produces three nodes carrying the same task - the try/catch node plus try and catch. We might be able to address this by using the taskReference rather than the node type. Anyway just highlighting but we can cross the bridge when we come to it.


    "Add Validation Capabilities with a Lightweight Resolver
    Attach a lightweight custom resolver (buildTaskFormResolver) to React Hook Form. It traverses active fields against live form values and enforces essential schema constraints: required presence, type assertions (number, boolean), enum membership, and ISO 8601 duration patterns without heavyweight dependencies."

    I understand the avoiding heavy dependency but keep in mind that AJV already ships in the sdk package so declaring it in the editors package.json adds no bundle weight as far as I know to consumer? Might be able to reduce complexity if we use that as much as we can


    Layer 2 — Workflow-level validation (on Apply)

    I might not have fully understood this but we need to keep in mind here that the sdk validator stops at the first broken task today - it doesnt report every error in workflow. This is a problem when you think about the following use case. You have an error close to the top of the workflow that you havent fixed yet, you edit a task further down and it is actually invalid but you dont see this and apply changes. I know we are probably looking to make improvements on sdk side but just wanted to note it

    Anyway, there is a lot in this, I think validation should come later, right now leave the validation we currently have, get everything else done first i.e schema walker etc and then come to validation separately - I know we have to keep this in mind as we are developing so we know we are going the right direction but lets not tackle everything in one go

  9. fantonangeli commented on Sep 4, 2026

    @fantonangeli
    Member

    Thanks @handreyrc for this investigation. TBH I got a bit lost in some of the implementation details, but overall the approach looks good to me.

    A few comments/questions:

    • I didn't fully understand the part "In read-only mode the form renders the task's current values...". Are we planning to render the forms also in read-only mode, rather than only in edit mode? Maybe use RHF rendering in both view and edit modes, so we keep the same field ordering?

    • Related to that, I don't think we need useWorkflowErrorsForForm in read-only mode, since there is no validation there. Is that correct?

    • If getSchemaForDefinition() already gives us the schema for the selected task definition, I think we should be able to validate the RHF form directly against that schema. Building a full draft model, calling validateWorkflow(), and then filtering the errors back to the current task feels a bit complex for form-level validation.

    • I would avoid pre-seeding workflow errors into RHF. I would suggest validating on Apply and relying on RHF validation. Pre-seeding errors on mount seems to add some extra complexity.

  10. handreyrc commented on Sep 8, 2026

    @handreyrc
    ContributorAuthor

    Thanks for the detail Handrey and the POC which helped a lot. Hard to forsee all issues but overall looks very good. I think we have already agreed but lets start small with setTask as callTask is a complex case, then we can tackle things bit by bit

    The stages mentioned above look good to me, just a note on Stage 2 though (I noticed when I was working on my last task):

    I think we might have some issues with try/catch because it produces three nodes carrying the same task - the try/catch node plus try and catch. We might be able to address this by using the taskReference rather than the node type. Anyway just highlighting but we can cross the bridge when we come to it.

    "Add Validation Capabilities with a Lightweight Resolver Attach a lightweight custom resolver (buildTaskFormResolver) to React Hook Form. It traverses active fields against live form values and enforces essential schema constraints: required presence, type assertions (number, boolean), enum membership, and ISO 8601 duration patterns without heavyweight dependencies."

    I understand the avoiding heavy dependency but keep in mind that AJV already ships in the sdk package so declaring it in the editors package.json adds no bundle weight as far as I know to consumer? Might be able to reduce complexity if we use that as much as we can

    Layer 2 — Workflow-level validation (on Apply)

    I might not have fully understood this but we need to keep in mind here that the sdk validator stops at the first broken task today - it doesnt report every error in workflow. This is a problem when you think about the following use case. You have an error close to the top of the workflow that you havent fixed yet, you edit a task further down and it is actually invalid but you dont see this and apply changes. I know we are probably looking to make improvements on sdk side but just wanted to note it

    Anyway, there is a lot in this, I think validation should come later, right now leave the validation we currently have, get everything else done first i.e schema walker etc and then come to validation separately - I know we have to keep this in mind as we are developing so we know we are going the right direction but lets not tackle everything in one go

    @lornakelly,

    • I agree, we can add AJV as a dependency so we don't need to go around with a lighweight resolver. It would also work with less code.
    • In regards with the validation. Yep it is a complex scenario, I did not went for a full implementation in the PoC, however, it is possible to handle the errors coming from the sdk and the errors caught in the form with resolvers. I agree, lets conve form generation from the schema and then we move forward with the validation.

    Thanks for checking my work in the PoC.

  11. handreyrc commented on Sep 8, 2026

    @handreyrc
    ContributorAuthor

    Thanks @handreyrc for this investigation. TBH I got a bit lost in some of the implementation details, but overall the approach looks good to me.

    A few comments/questions:

    • I didn't fully understand the part "In read-only mode the form renders the task's current values...". Are we planning to render the forms also in read-only mode, rather than only in edit mode? Maybe use RHF rendering in both view and edit modes, so we keep the same field ordering?
    • Related to that, I don't think we need useWorkflowErrorsForForm in read-only mode, since there is no validation there. Is that correct?
    • If getSchemaForDefinition() already gives us the schema for the selected task definition, I think we should be able to validate the RHF form directly against that schema. Building a full draft model, calling validateWorkflow(), and then filtering the errors back to the current task feels a bit complex for form-level validation.
    • I would avoid pre-seeding workflow errors into RHF. I would suggest validating on Apply and relying on RHF validation. Pre-seeding errors on mount seems to add some extra complexity.

    @fantonangeli

    • The idea is to use the RHF for read-only and edit mode. So we have the same view and components. The only difference is that there should be a filter in read-only to show only the defined fields. We should hide everything else that is undefined or null so we can provide a clean picture of the task props. The field ordering should come from the schema. The only ordering there is to add the baseTaskDef props after the specific task definition.
    • About useWorkflowErrorsForForm it is the same component and I did not refined it too much in the PoC, the code was growing too fast. We can definetelly check how to supress/disable it in read-only mode for sure.
    • About the pre-seeding, the scenario is more complex.
      • Validation per field (field lose focus) (RHF): errors on task level and workflow are not detected.
      • Task validation (RHF): validates using the filtered schema for that task (task scope), errors in the workflow are not detected.
        • Alternatively, we could eliminate the task validation level and go for a full validation with the draft model at the end, it would make the code simpler.
      • Workflow validation (SDK): full workflow validation with the changed task in the scope.

    WDYT?

    Thanks for checking my work on the PoC.

  12. fantonangeli commented on Sep 9, 2026

    @fantonangeli
    Member

    @handreyrc thanks a lot for clarifying.

    I think the main point left to decide is whether we really want to implement:

    Workflow validation (SDK): full workflow validation with the changed task in the scope.

    I understand that a task can reference other workflow components, so validating it only against its own schema may not catch everything. However, I would vote for leaving workflow-level validation for a later iteration, if we find that we really need it, and keep the first implementation simpler.

    Form generation plus task-level validation already seems not so easy, so I would prefer to avoid adding more complexity at this stage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions