Skip to content

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

Merged
eshiota merged 3 commits into
remoteoss:mainfrom
vermaxik:fix/per-row-validation-for-items-conditionals
Sep 11, 2026
Merged

eshiota merged 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
Contributor

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 schema mutation in applySchemaRules, which affects validation and field derivation for nested arrays; behavior change is intentional but could surface edge cases in complex conditional schemas.

Overview
Fixes wrong validation for array row schemas when items uses value-dependent if/then/else rules. Previously applySchemaRules ran against {} for shared items schemas, permanently merging one branch and breaking else/negated if for every row.

The change adds constantIfsOnly (via shouldProcessRule) so only if: true / if: false conditionals are pre-merged into items; value-dependent rules stay on the schema and are evaluated per row during validation. Constant conditionals on nested objects and array items still drive shared field metadata (e.g. visibility/required).

New array tests cover else branches, negated if, nested constant+value-dependent rules, and if: true on objects and items. Per-row field UI mutations for group-arrays remain out of scope.

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

@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
Comment thread test/fields/array.test.ts Outdated
Comment thread test/fields/array.test.ts
@vermaxik

vermaxik commented Sep 1, 2026

Copy link
Copy Markdown
Contributor 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
Comment thread src/mutations.ts

@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 4cff944. Configure here.

Comment thread src/mutations.ts
@vermaxik
vermaxik force-pushed the fix/per-row-validation-for-items-conditionals branch from b27e680 to 4cff944 Compare September 2, 2026 15:26
vermaxik and others added 3 commits September 10, 2026 11:46
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.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@eshiota
eshiota force-pushed the fix/per-row-validation-for-items-conditionals branch from 4cff944 to b1d4962 Compare September 10, 2026 09:46
@eshiota
eshiota merged commit 58ce595 into remoteoss:main Sep 11, 2026
6 checks passed
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