Skip to content

Stop a nested form filling the model with its own fields - #1543

Open
AIC-BV wants to merge 4 commits into
wintercms:developfrom
AIC-BV:fix/nested-form-model-scope
Open

AIC-BV wants to merge 4 commits into
wintercms:developfrom
AIC-BV:fix/nested-form-model-scope

Conversation

@AIC-BV

@AIC-BV AIC-BV commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Builds on #1529 — that PR adds the Form::$sharesModelScope discriminator this one guards with, so the diff below includes it. Happy to rebase once #1529 lands, or to fold this into it if you'd rather have one PR.

The problem

Form::setFormValues() fills the model as if the form were about to be saved, so the fields resolve against it:

$this->prepareModelsToSave($this->model, $data);

A repeater item form and a nested form are handed the parent form's model to render against (Repeater::makeItemFormWidget(), NestedForm::init()), but their fields are keys of their own data scope, not attributes of that model. Filling it from there writes foreign values onto the record whenever an item field shares its name with a model attribute — and type, name, title, status are exactly the names repeater groups tend to use.

For a plain attribute it goes unnoticed, since nothing saves the model during a refresh. For a cast attribute it fails outright:

# a model with an enum-cast `status`, and a repeater whose items have their own
status:
    type: text
items:
    type: repeater
    form:
        fields:
            status:
                type: balloon-selector
                options:
                    featured: Featured
                    plain: Plain

Refreshing the repeater (or switching/copying items, which is where we hit it) throws:

ValueError: "featured" is not a valid backing value for enum ...

The value never belonged to the model — it was only ever the item's.

The fix

if (!$this->isNested || $this->sharesModelScope) {
    $this->prepareModelsToSave($this->model, $data);
}

isNested is set in exactly three places in core: Repeater, NestedForm and FieldSet. The first two bring a data scope of their own and must not touch the model; a fieldset groups fields visually and its fields really are the model's attributes, which is what $sharesModelScope already flags. Top-level forms are unaffected, so RelationController and settings forms keep filling the model as before.

Tests

modules/backend/tests/widgets/FormNestedModelScopeTest.php puts a field named status on the model, on a fieldset, on a nested form and in a repeater group, and asserts which of the four are allowed to reach the model's attribute. The two nested cases throw the ValueError above without the guard.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fieldset fields now correctly use the parent model’s data scope when appropriate.
    • Nested forms and repeater items preserve their own data scopes, preventing values from being written to unrelated model attributes.
    • Required-field validation now resolves attribute paths correctly across fieldsets, nested forms, and repeater items.
    • Form value population now leaves parent model attributes unchanged when values belong to an independently scoped nested form.

AIC-BV and others added 4 commits August 25, 2026 11:46
isNested carries two meanings: "this Form widget is a child of another Form
widget", which extension guards use to avoid injecting fields on the wrong
instance, and "this form's data scope differs from the model's attributes",
which core and Winter.Translate use. Both hold for repeater and nestedform;
for fieldset only the first does, since it reuses the parent form's model and
arrayName and merges its save data straight back up.

Conflating them means Winter.Translate skips fieldset fields, so translatable
attributes grouped in a fieldset never get their ml* widget, and the nested
attribute-name prefixing in defineFormField() asks the model for '.field_name'
instead of 'field_name', so required is never auto-detected from model rules.

Keeping isNested true leaves every existing guard behaving as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Guards both halves of the change: the inner form must stay flagged as nested
so that code extending form widgets can still tell it apart from the form it
is nested in, and its fields must resolve required against the plain model
attribute rather than one prefixed with the inherited arrayName. A regular
nested form is asserted to keep prefixing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Suppressing the attribute-name prefixing outright was wrong: a fieldset inside
a repeater item inherits that item's array name, and the prefix derived from it
is exactly what isAttributeRequired() needs. Only the top level case is broken,
where a bare parent array name leaves nothing to prefix with and produced
".field_name". Guard the empty prefix instead, which needs no flag at all.

For the same reason the flag now answers whether the fields resolve on the
model, which a fieldset only does when the form it is nested in does too.
Renamed accordingly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
setFormValues() fills the model as if the form were about to be saved, so that
the fields resolve against it. A repeater item form and a nested form are handed
the parent form's model to render against, but their fields are keys of their own
data scope, not attributes of that model: filling it from there writes foreign
values onto the record whenever a field shares its name with an attribute.

It goes unnoticed for a plain attribute - the model is not saved on a refresh -
but a cast attribute rejects the value outright, so a repeater group holding a
field named after an enum-cast attribute makes refreshing or switching the
repeater throw.

Guarded with the scope discriminator, so fieldsets, whose fields really are the
model's attributes, keep filling it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: a970150b-6e47-418b-9923-3d905ce2b8f3

📥 Commits

Reviewing files that changed from the base of the PR and between 44e9d68 and bbe1709.

📒 Files selected for processing (4)
  • modules/backend/formwidgets/FieldSet.php
  • modules/backend/tests/widgets/FormFieldSetScopeTest.php
  • modules/backend/tests/widgets/FormNestedModelScopeTest.php
  • modules/backend/widgets/Form.php

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


Walkthrough

The Form widget now tracks whether a nested form shares the model scope. FieldSet propagates this value from its parent form. Model preparation skips nested forms with their own scope. Required-field checks avoid an empty attribute prefix. New tests cover fieldset, nested form, and repeater scope behavior.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to bbe17

Independent nested fields no longer populate matching parent-model attributes, while shared fieldsets retain their existing model behavior. No actionable merge risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.39% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 23 functions across 4 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: preventing nested form fields from filling the parent model.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

1 participant