Skip to content

Add fieldset formwidget#1435

Open
mjauvin wants to merge 9 commits intodevelopfrom
fieldset-formwidget
Open

Add fieldset formwidget#1435
mjauvin wants to merge 9 commits intodevelopfrom
fieldset-formwidget

Conversation

@mjauvin
Copy link
Member

@mjauvin mjauvin commented Jan 8, 2026

This formwidget wraps form elements with a <fieldset> HTML tag.

The fields are visually grouped but saved as if they were regular fields at the parent level.

The field's label config is used for the <fieldset> legend.

The Form widget's getSaveData() method now checks for a getFormFields() method on widgets in order to pull child fields from the widget.

image

The field config is akin to a nestedform:

myFieldSet:
  type: fieldset
  label: Grouped Fields
  form:
    fields:
      ...

Summary by CodeRabbit

  • New Features

    • Adds a FieldSet form widget to embed a nested form section with an optional legend and an option to hide labels.
  • Style

    • New themed styling for the FieldSet, including dark-mode support and improved tab visuals.
  • Bug Fixes

    • Nested widget fields are now collected and saved correctly during form submission.

@mjauvin mjauvin requested a review from LukeTowers January 8, 2026 15:35
@mjauvin mjauvin self-assigned this Jan 8, 2026
@mjauvin mjauvin added enhancement PRs that implement a new feature or substantial change needs review Issues/PRs that require a review from a maintainer labels Jan 8, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a new FieldSet backend form widget (class, partial, CSS/LESS, asset registration) and updates backend form save logic to collect values from nested widgets that expose getFormFields().

Changes

Cohort / File(s) Summary
FieldSet Form Widget
modules/backend/formwidgets/FieldSet.php
New FormWidget class FieldSet that embeds a Form widget, binds model/data/alias, supports nested/preview mode, exposes getFormFields(), loads assets, and renders via a partial.
FieldSet Template & Assets
modules/backend/formwidgets/fieldset/partials/_fieldset.php, modules/backend/formwidgets/fieldset/assets/css/fieldset.css, modules/backend/formwidgets/fieldset/assets/less/fieldset.less
New partial wrapping the embedded form in a <fieldset> (optional translated legend) and new CSS/LESS for fieldset appearance, tabs, and dark-mode styles.
Service Registration / Assets
modules/backend/ServiceProvider.php
Registers Backend\FormWidgets\FieldSet::class as alias 'fieldset' and adds .../formwidgets/fieldset/assets/less/fieldset.less to backend asset bundles. (Note: duplicate registration line present in the same method.)
Form Save Handling
modules/backend/widgets/Form.php
getSaveData() extended to detect widgets exposing getFormFields(), extract their submitted values (including numeric conversion for number fields), and merge them into the save payload before standard per-field finalization.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant BackendForm as Backend Form
  participant FieldSet as FieldSet Widget
  participant EmbeddedForm as Embedded Form Widget
  participant FormSave as Form.getSaveData()
  participant Model

  User->>BackendForm: open form (render)
  BackendForm->>FieldSet: init()/prepareVars()
  FieldSet->>EmbeddedForm: create & bind Form widget (model/data/alias)
  EmbeddedForm-->>FieldSet: provide fields & render output
  FieldSet-->>BackendForm: render() (includes EmbeddedForm)

  User->>BackendForm: submit form
  BackendForm->>FormSave: getSaveData()
  FormSave->>FieldSet: detect widget with getFormFields()
  FieldSet-->>FormSave: expose nested field definitions
  FormSave->>FormSave: extract nested values, convert types, merge payload
  FormSave->>Model: persist merged save data
  Model-->>User: save result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add fieldset formwidget' clearly and accurately summarizes the main change: introduction of a new fieldset form widget component.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fieldset-formwidget

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @modules/backend/formwidgets/fieldset/assets/less/fieldset.less:
- Line 3: The LESS variable declaration for @panel-bg has a double semicolon
("@panel-bg: #f5f5f5;;"); remove the extra semicolon so the declaration is a
single semicolon terminated statement (update the @panel-bg variable in
fieldset.less).
🧹 Nitpick comments (4)
modules/backend/widgets/Form.php (2)

1224-1239: Consider variable naming to avoid shadowing.

The inner foreach loop reuses the variable name $field (line 1226), which shadows the outer loop variable $field from line 1216. While this doesn't cause functional issues due to scoping, it can reduce code clarity.

♻️ Suggested refactor
             // get nested widget fields that should be saved to the model
             if (method_exists($widget, 'getFormFields')) {
-                foreach ($widget->getFormFields() as $field) {
-                    $parts = HtmlHelper::nameToArray($field->fieldName);
-                    if (($value = $this->dataArrayGet($data, $parts)) !== null) {
+                foreach ($widget->getFormFields() as $nestedField) {
+                    $parts = HtmlHelper::nameToArray($nestedField->fieldName);
+                    if (($value = $this->dataArrayGet($data, $parts)) !== null) {
                         /*
                          * Number fields should be converted to integers
                          */
-                        if ($field->type === 'number') {
+                        if ($nestedField->type === 'number') {
                             $value = !strlen(trim($value)) ? null : (float) $value;
                         }
 
                         $this->dataArraySet($result, $parts, $value);
                     }
                 }
             }

1228-1237: Consider extracting field value processing logic.

The field value processing logic (lines 1228-1237) duplicates the same logic from lines 1201-1210. While the duplication is minimal and the context differs slightly, extracting this into a helper method could improve maintainability.

♻️ Optional refactor to reduce duplication

Consider adding a protected helper method:

protected function processFieldSaveValue($field, $data, &$result)
{
    $parts = HtmlHelper::nameToArray($field->fieldName);
    if (($value = $this->dataArrayGet($data, $parts)) !== null) {
        /*
         * Number fields should be converted to integers
         */
        if ($field->type === 'number') {
            $value = !strlen(trim($value)) ? null : (float) $value;
        }

        $this->dataArraySet($result, $parts, $value);
        return true;
    }
    return false;
}

Then both locations could use:

foreach ($this->allFields as $field) {
    if ($field->disabled || $field->hidden) {
        continue;
    }
    $this->processFieldSaveValue($field, $data, $result);
}

// ...and later...

if (method_exists($widget, 'getFormFields')) {
    foreach ($widget->getFormFields() as $nestedField) {
        $this->processFieldSaveValue($nestedField, $data, $result);
    }
}
modules/backend/formwidgets/fieldset/assets/css/fieldset.css (1)

1-9: Note: Recompile after fixing LESS source.

This appears to be the compiled CSS output from the LESS file. After fixing the double semicolon syntax error in fieldset.less (line 3), remember to recompile this CSS file to ensure consistency.

modules/backend/formwidgets/FieldSet.php (1)

21-29: Consider adding type hints to properties.

Adding type declarations would improve IDE support and code clarity.

📝 Suggested type hints
 /**
  * @var array Form configuration
  */
-public $form;
+public array $form;

 /**
  * @var Form form widget reference
  */
-protected $formWidget;
+protected Form $formWidget;
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8a7f74b and 18113c3.

📒 Files selected for processing (6)
  • modules/backend/ServiceProvider.php
  • modules/backend/formwidgets/FieldSet.php
  • modules/backend/formwidgets/fieldset/assets/css/fieldset.css
  • modules/backend/formwidgets/fieldset/assets/less/fieldset.less
  • modules/backend/formwidgets/fieldset/partials/_fieldset.php
  • modules/backend/widgets/Form.php
🧰 Additional context used
🧬 Code graph analysis (3)
modules/backend/formwidgets/fieldset/partials/_fieldset.php (1)
modules/backend/formwidgets/FieldSet.php (1)
  • render (70-74)
modules/backend/widgets/Form.php (1)
modules/backend/formwidgets/FieldSet.php (1)
  • getFormFields (62-65)
modules/backend/ServiceProvider.php (2)
modules/system/classes/CombineAssets.php (1)
  • registerBundle (660-697)
modules/backend/formwidgets/FieldSet.php (1)
  • FieldSet (14-80)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: windows-latest / PHP 8.2
  • GitHub Check: ubuntu-latest / PHP 8.4
  • GitHub Check: ubuntu-latest / PHP 8.2
  • GitHub Check: windows-latest / PHP 8.4
  • GitHub Check: windows-latest / PHP 8.3
  • GitHub Check: ubuntu-latest / PHP 8.1
  • GitHub Check: ubuntu-latest / PHP 8.3
  • GitHub Check: windows-latest / PHP 8.1
  • GitHub Check: windows-latest / JavaScript
🔇 Additional comments (5)
modules/backend/formwidgets/fieldset/assets/less/fieldset.less (1)

1-9: LGTM!

The partial cleanly renders the fieldset with optional translated legend and embedded form widget. The implementation is straightforward and follows Winter CMS conventions.

modules/backend/ServiceProvider.php (1)

100-100: LGTM!

Both registrations follow the existing patterns correctly:

  • The asset bundle is properly registered for the FieldSet widget
  • The form widget registration maintains alphabetical ordering and follows the established convention

Also applies to: 252-252

modules/backend/formwidgets/FieldSet.php (3)

57-65: LGTM!

The loadAssets() and getFormFields() methods are correctly implemented. The getFormFields() method provides the necessary integration point for the Form widget's getSaveData() enhancement mentioned in the PR.


67-79: LGTM!

The render() and prepareVars() methods follow standard form widget patterns. Syncing preview mode in prepareVars() ensures the embedded form respects the current state even if it changes after initialization.


44-46: Verify whether getLoadValue() is appropriate for the parent-level fieldset data structure.

The pattern of setting $config->data = $this->getLoadValue() follows the same approach as NestedForm.php, but since FieldSet saves fields at the parent level (using parent form's arrayName), the data structure implications differ. It's unclear whether getLoadValue() returns a compatible structure for fields saved at the parent level rather than nested. This needs verification against actual form value handling.

Additionally, line 49 directly accesses $this->getParentForm()->arrayName without null safety, unlike the defensive pattern used in DataTable.php (which checks isset($this->getParentForm()->arrayName)). While NestedForm similarly accesses getParentForm() unsafely, consider adding a null check for consistency and robustness.

@damsfx
Copy link
Contributor

damsfx commented Jan 8, 2026

@mjauvin How is the responsive design being managed?
Does the span left/right/auto or custom Css with span:storm still works ?

@mjauvin
Copy link
Member Author

mjauvin commented Jan 8, 2026

@mjauvin How is the responsive design being managed? Does the span left/right/auto or custom Css with span:storm still works ?

It's pretty much derived from a nestedform ... so it renders a form within the <fieldset> tag.

The main difference is that fields are exported to the parent when saving (does not use a JSON field).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @modules/backend/formwidgets/FieldSet.php:
- Line 26: Add a PHPDoc docblock above the public property $showLabels on the
FieldSet class that explains its purpose, type and default value (bool, defaults
to false), describes what enabling it changes (e.g., whether child field labels
within the fieldset are displayed), and any usage notes or expected values;
reference the property name $showLabels and ensure the docblock follows PSR-5
style so IDEs and static analyzers can pick up the type and description.
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b53bc99 and bebc32e.

📒 Files selected for processing (2)
  • modules/backend/formwidgets/FieldSet.php
  • modules/backend/formwidgets/fieldset/partials/_fieldset.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • modules/backend/formwidgets/fieldset/partials/_fieldset.php
🧰 Additional context used
🧬 Code graph analysis (1)
modules/backend/formwidgets/FieldSet.php (3)
modules/backend/widgets/Form.php (7)
  • Form (23-1496)
  • init (127-143)
  • bindToController (152-156)
  • loadAssets (161-164)
  • getFields (1032-1035)
  • render (181-229)
  • prepareVars (303-311)
modules/system/traits/AssetMaker.php (1)
  • addCss (157-176)
modules/system/traits/ViewMaker.php (1)
  • makePartial (100-119)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: ubuntu-latest / PHP 8.1
  • GitHub Check: windows-latest / PHP 8.2
  • GitHub Check: ubuntu-latest / PHP 8.4
  • GitHub Check: windows-latest / PHP 8.1
  • GitHub Check: ubuntu-latest / PHP 8.3
  • GitHub Check: windows-latest / PHP 8.4
  • GitHub Check: ubuntu-latest / PHP 8.2
  • GitHub Check: windows-latest / PHP 8.3
  • GitHub Check: windows-latest / JavaScript
🔇 Additional comments (3)
modules/backend/formwidgets/FieldSet.php (3)

48-48: Verify data initialization for the nested form.

Setting $config->data = $this->getLoadValue() passes the fieldset field's stored value to the nested Form widget. Since fieldset fields are saved at the parent level (not nested), the fieldset itself shouldn't have a stored value. The nested Form should load field values from the model (set on line 47), not from any value stored under the fieldset's name.

If getLoadValue() returns non-null data (e.g., legacy data or misconfiguration), the nested Form might use incorrect values instead of loading from the model.

Consider removing or setting to null:

-        $config->data = $this->getLoadValue();
+        $config->data = null;

Or verify that the Form widget correctly prioritizes model values over the data config when both are provided.


64-67: LGTM!

This delegation method correctly exposes the nested form's fields to the parent form's save process, enabling the fields inside the fieldset to be saved at the parent level.


51-51: No action required.

The getParentForm() method is defined in FormWidgetBase (line 99) as a public method, and FieldSet extends FormWidgetBase, making the method available for use at line 51.

@mjauvin mjauvin marked this pull request as draft January 14, 2026 15:17
@mjauvin mjauvin marked this pull request as ready for review January 14, 2026 22:28
@LukeTowers
Copy link
Member

@mjauvin can you make a PR to the docs and include examples in the nestedform and fieldset sections as to when you should pick one vs the other?

@LukeTowers LukeTowers added this to the 1.3.0 milestone Feb 20, 2026
@mjauvin
Copy link
Member Author

mjauvin commented Feb 20, 2026

@mjauvin can you make a PR to the docs and include examples in the nestedform and fieldset sections as to when you should pick one vs the other?

Done in wintercms/docs#258

$this->dataArraySet($result, $parts, $value);
}
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Does this work with formwidgets that are embedded in the fieldset's form? Do they get added to the $this->formWidgets of the parent form or should we refactor this a bit so that it's a separate method / inline function that can be called again on every fieldset form inside of the the main form recursively?

mjauvin and others added 2 commits February 20, 2026 12:10
Co-authored-by: Luke Towers <git@luketowers.ca>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement PRs that implement a new feature or substantial change needs review Issues/PRs that require a review from a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Comments