Skip to content

fix: evaluate value-dependent items conditionals per array row - #271

Open
vermaxik wants to merge 3 commits into
remoteoss:mainfrom
vermaxik:fix/per-row-validation-for-items-conditionals
Open

fix: evaluate value-dependent items conditionals per array row#271
vermaxik wants to merge 3 commits into
remoteoss:mainfrom
vermaxik:fix/per-row-validation-for-items-conditionals

Conversation

@vermaxik

@vermaxik vermaxik commented Aug 19, 2026

Copy link
Copy Markdown

Summary

Conditionals (if/then/else in allOf) inside an array's items schema are
evaluated against an empty object instead of each row's value. Whichever branch
matches {} gets permanently merged into the shared items schema and deleted,
so rules with an else branch (or a negated if) validate wrongly for every
row. This PR pre-applies only constant conditionals inside items and leaves
value-dependent ones intact, so validateSchema evaluates them per row.

Before After
SCR-20260820-ksyz SCR-20260820-ktnq
Json Schema
{
  "title": "Per-row conditionals in nested arrays",
  "type": "object",
  "properties": {
    "discount_rules": {
      "type": "array",
      "title": "Discount rules",
      "x-jsf-presentation": { "inputType": "group-array", "addFieldText": "Add rule" },
      "items": {
        "type": "object",
        "title": "Rule",
        "x-jsf-order": ["name", "mode", "percent", "tiering"],
        "required": ["name", "mode"],
        "x-jsf-presentation": { "inputType": "fieldset" },
        "properties": {
          "name": {
            "type": "string",
            "title": "Name",
            "x-jsf-presentation": { "inputType": "text" }
          },
          "mode": {
            "type": "string",
            "title": "Mode",
            "default": "flat",
            "oneOf": [
              { "const": "flat", "title": "Flat" },
              { "const": "tiered", "title": "Tiered" }
            ],
            "x-jsf-presentation": { "inputType": "radio" }
          },
          "percent": {
            "type": ["string", "null"],
            "title": "Percent",
            "x-jsf-presentation": { "inputType": "text" }
          },
          "tiering": {
            "type": ["object", "null"],
            "title": "Tiering",
            "x-jsf-order": ["period", "tiers"],
            "x-jsf-presentation": { "inputType": "fieldset" },
            "properties": {
              "period": {
                "type": "string",
                "title": "Period",
                "oneOf": [
                  { "const": "monthly", "title": "Monthly" },
                  { "const": "yearly", "title": "Yearly" }
                ],
                "x-jsf-presentation": { "inputType": "select" }
              },
              "tiers": {
                "type": "array",
                "title": "Tiers",
                "x-jsf-presentation": { "inputType": "group-array", "addFieldText": "Add tier" },
                "items": {
                  "type": "object",
                  "title": "Tier",
                  "x-jsf-order": ["up_to", "percent"],
                  "required": ["percent"],
                  "x-jsf-presentation": { "inputType": "fieldset" },
                  "properties": {
                    "up_to": {
                      "type": "integer",
                      "title": "Up to",
                      "x-jsf-presentation": { "inputType": "number" }
                    },
                    "percent": {
                      "type": "string",
                      "title": "Percent",
                      "x-jsf-presentation": { "inputType": "text" }
                    }
                  }
                }
              }
            }
          }
        },
        "allOf": [
          {
            "if": {
              "properties": { "mode": { "const": "tiered" } },
              "required": ["mode"]
            },
            "then": {
              "required": ["tiering"],
              "properties": {
                "tiering": {
                  "type": "object",
                  "required": ["period", "tiers"],
                  "properties": {
                    "tiers": {
                      "minItems": 1,
                      "x-jsf-errorMessage": { "minItems": "Add at least one tier, or switch the mode to flat." }
                    }
                  }
                },
                "percent": {
                  "maxLength": 0,
                  "x-jsf-errorMessage": { "maxLength": "Not used for tiered rules. Clear it or switch the mode to flat." }
                }
              }
            },
            "else": {
              "required": ["percent"],
              "properties": {
                "percent": { "type": "string" },
                "tiering": {
                  "properties": {
                    "tiers": {
                      "maxItems": 0,
                      "x-jsf-errorMessage": { "maxItems": "Tiers only apply to tiered rules. Remove them or switch the mode." }
                    }
                  }
                }
              }
            }
          }
        ]
      }
    }
  }
}

Changes Made

calculateFinalSchema pre-applies conditional rules by merging the matching
branch into the schema and deleting the branch. That is correct at the root,
where values is the real form value, but for items the existing workaround
in applySchemaRules passed a hardcoded {}:

  • a rule with an else branch had the else baked in for all rows (an if
    that requires a field never matches {}), producing wrong validation even
    for rows where the condition is true;
  • a negated if always matches {}, baking the then in for all rows;
  • because the branch is deleted after merging, the per-row conditional
    evaluation in validateCondition (which is correct) never saw it.

The change threads a constantIfsOnly flag through applySchemaRules. Inside
items, only conditionals whose if is a boolean (if: true / if: false)
are pre-applied — their branch is row-independent, which is what the original
workaround supported (schema-driven visibility inside items, covered by the
existing "with constant logic" tests). Value-dependent rules keep their
then/else and are evaluated per item with the row's actual value.

Known limitation, unchanged by this PR: per-row FIELD state (visibility,
required flags, titles) is still not representable, since all rows of a
group-array share a single fields array. This PR fixes validation only; the
describe.skip('with logic based on answers') for group-array field visibility
stays skipped.


Note

Medium Risk
Touches core conditional schema mutation in applySchemaRules; behavior change is scoped to array items but affects all forms with row-level JSON Schema conditionals.

Overview
Fixes incorrect validation when array items schemas use value-dependent if/then/else rules. Previously, applySchemaRules ran against {} for every shared items schema, permanently merging one branch and stripping the rule so all rows validated the same way—especially wrong for else branches and negated if conditions.

Now applySchemaRules takes a constantIfsOnly flag (via shouldProcessRule). For array items, only boolean if (true/false) is pre-applied into the schema so row-independent visibility/required metadata still works. Value-dependent conditionals stay on the schema and are evaluated per row during validation.

Adds array tests for else branches, negated conditionals, and constant if: true on nested objects and item schemas. Per-row field UI (visibility/required per group-array row) is unchanged and still out of scope.

Reviewed by Cursor Bugbot for commit 8238cac. Bugbot is set up for automated code reviews on this repo. Configure here.

calculateFinalSchema pre-applies conditional rules by merging the matching
branch into the schema and deleting it. For array items it evaluated every
rule against an empty object, so the {}-matching branch was permanently
baked into the one shared items schema: rules with an else branch (or a
negated if) were wrong for every row, in both validation and field state.

Only constant conditionals (if: true / if: false) are pre-applied now --
their branch is row-independent, which is what the workaround originally
protected (schema-driven visibility inside items). Value-dependent rules
keep their then/else so validateSchema evaluates them per row against the
row's actual value.

Per-row FIELD mutations (visibility / required flags) remain unsupported
for group-array items: all rows share a single fields array.
@eshiota
eshiota self-requested a review August 31, 2026 14:59

@eshiota eshiota left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything looks solid! I left two nits as comments, and a request to add additional tests 😄

Comment thread src/mutations.ts Outdated
return
}

const shouldProcessRule = (ifNode: JsfSchema | undefined): boolean =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this could be moved outside of the applySchemaRules's scope by injecting constantIfsOnly as an argument

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed in 590d4f7

Comment thread test/fields/array.test.ts Outdated
})

it('evaluates negated conditionals per array item', () => {
// A negated `if` matches the empty object, so the old pre-processing baked

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we don't need most of these comments, the code should be self-explanatory

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed in 590d4f7

Comment thread test/fields/array.test.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add additional tests that cover some of the changes directly?

  • An if: true clause inside an allOf at the level of a property with type: 'object'
address: {
  'type': 'object',
  'x-jsf-presentation': { inputType: 'fieldset' },
  'properties': { city: {...}, zip: {...} },
  'allOf': [{
    if: true,
    then: { required: ['city'], properties: { zip: false } },
    else: { required: ['zip'], properties: { city: false } },
  }],
}
  • An if: true clause directly at the level of a property with type: 'object'
items: {
  type: 'object',
  properties: { a: {...}, b: {...} },
  if: true,
  then: { required: ['a'] },
  else: { required: ['b'] },
}
  • The same as above, but with a property with items as an array

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added in 590d4f7

@vermaxik

vermaxik commented Sep 1, 2026

Copy link
Copy Markdown
Author

Everything looks solid! I left two nits as comments, and a request to add additional tests 😄

thanks for the review, addressed feedback in 590d4f7

@vermaxik
vermaxik requested a review from eshiota September 1, 2026 15:13

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 8238cac. Configure here.

Comment thread src/mutations.ts
* required flags) remain unsupported for group-array items.
*/
applySchemaRules(propertySchema.items as JsfObjectSchema, {}, options, jsonLogicContext)
applySchemaRules(propertySchema.items as JsfObjectSchema, {}, options, jsonLogicContext, true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flag dropped in branch processing

Low Severity

processBranch calls applySchemaRules without forwarding constantIfsOnly. A constant if on array items can still pre-apply nested value-dependent if/then/else against {} and delete those branches, so they never run per row.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8238cac. Configure here.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants