diff --git a/modules/backend/ServiceProvider.php b/modules/backend/ServiceProvider.php index a2b61abb9e..9ff5e505ad 100644 --- a/modules/backend/ServiceProvider.php +++ b/modules/backend/ServiceProvider.php @@ -97,6 +97,7 @@ protected function registerAssetBundles() $combiner->registerBundle('~/modules/backend/widgets/reportcontainer/assets/less/reportcontainer.less'); $combiner->registerBundle('~/modules/backend/widgets/table/assets/less/table.less'); $combiner->registerBundle('~/modules/backend/formwidgets/repeater/assets/less/repeater.less'); + $combiner->registerBundle('~/modules/backend/formwidgets/fieldset/assets/less/fieldset.less'); $combiner->registerBundle('~/modules/backend/formwidgets/fileupload/assets/less/fileupload.less'); $combiner->registerBundle('~/modules/backend/formwidgets/nestedform/assets/less/nestedform.less'); $combiner->registerBundle('~/modules/backend/formwidgets/richeditor/assets/js/build-plugins.js'); @@ -258,6 +259,7 @@ protected function registerBackendWidgets() $manager->registerFormWidget(\Backend\FormWidgets\ColorPicker::class, 'colorpicker'); $manager->registerFormWidget(\Backend\FormWidgets\DataTable::class, 'datatable'); $manager->registerFormWidget(\Backend\FormWidgets\DatePicker::class, 'datepicker'); + $manager->registerFormWidget(\Backend\FormWidgets\FieldSet::class, 'fieldset'); $manager->registerFormWidget(\Backend\FormWidgets\FileUpload::class, 'fileupload'); $manager->registerFormWidget(\Backend\FormWidgets\IconPicker::class, 'iconpicker'); $manager->registerFormWidget(\Backend\FormWidgets\MarkdownEditor::class, 'markdown'); diff --git a/modules/backend/formwidgets/FieldSet.php b/modules/backend/formwidgets/FieldSet.php new file mode 100644 index 0000000000..70da8e0736 --- /dev/null +++ b/modules/backend/formwidgets/FieldSet.php @@ -0,0 +1,102 @@ + + */ +class FieldSet extends FormWidgetBase +{ + /** + * @inheritDoc + */ + protected $defaultAlias = 'fieldset'; + + /** + * @var array Field configuration + */ + public $fields; + + /** + * @var bool Determines if this form field should display comments and labels. + */ + public $showLabels = false; + + /** + * @var Form form widget reference + */ + protected $formWidget; + + /** + * @inheritDoc + */ + public function init() + { + $this->fillFromConfig([ + 'fields', + ]); + + if ($this->formField->disabled) { + $this->previewMode = true; + } + + $config = $this->makeConfig(['fields' => $this->fields]); + $config->model = $this->model; + $config->data = $this->getLoadValue(); + $config->alias = $this->alias . $this->defaultAlias; + // set arrayName from parent form to save fields to the model + $config->arrayName = $this->getParentForm()->arrayName; + $config->isNested = true; + + $widget = $this->formWidget = $this->makeWidget(Form::class, $config); + $widget->previewMode = $this->previewMode; + $widget->bindToController(); + } + + protected function loadAssets() + { + $this->addCss('css/fieldset.css', 'core'); + } + + /** + * Returns the save data for the nested fields, to be merged into the parent + * form's data as if these fields were defined at that level. Reusing the nested + * form's getSaveData() ensures number casting, widget getSaveValue() handling, + * NO_SAVE_DATA exclusion and disabled/hidden skipping all behave identically to + * a regular field. + */ + public function getSaveData(): array + { + return $this->formWidget->getSaveData(); + } + + /** + * @inheritdoc + */ + public function render() + { + $this->prepareVars(); + return $this->makePartial('fieldset'); + } + + public function prepareVars() + { + $this->formWidget->previewMode = $this->previewMode; + } + + /** + * @inheritDoc + */ + public function getSaveValue($value) + { + return FormField::NO_SAVE_DATA; + } +} diff --git a/modules/backend/formwidgets/fieldset/assets/css/fieldset.css b/modules/backend/formwidgets/fieldset/assets/css/fieldset.css new file mode 100644 index 0000000000..933d9b103f --- /dev/null +++ b/modules/backend/formwidgets/fieldset/assets/css/fieldset.css @@ -0,0 +1,6 @@ +.fieldset{position:relative;min-height:30px;border:1px solid #d1d6d9;border-radius:0.5rem;box-shadow:inset 0 1px 0 rgba(209,214,217,0.25),0 1px 0 rgba(255,255,255,.5);margin-top:5px;padding:1em 1.25em 0 1.25em;background:#f5f5f5} +.fieldset>legend{border:none;margin:0;padding-inline:1rem;width:fit-content;font-size:18px;color:inherit} +.fieldset .control-tabs.primary-tabs .nav-tabs{margin:0} +.fieldset .control-tabs.primary-tabs .nav-tabs>li.active>a:before{background-color:#f5f5f5;border-color:#f5f5f5} +.fieldset .control-tabs.primary-tabs .nav-tabs>li>a>span.title:before, +.fieldset .control-tabs.primary-tabs .nav-tabs>li>a>span.title:after{background-color:#f5f5f5} diff --git a/modules/backend/formwidgets/fieldset/assets/less/fieldset.less b/modules/backend/formwidgets/fieldset/assets/less/fieldset.less new file mode 100644 index 0000000000..3ae5a9e261 --- /dev/null +++ b/modules/backend/formwidgets/fieldset/assets/less/fieldset.less @@ -0,0 +1,41 @@ +@import "../../../../assets/less/core/boot.less"; + +@panel-bg: #f5f5f5; + +.fieldset { + position: relative; + min-height: 30px; + + border: 1px solid @input-border; + border-radius: 0.5rem; + box-shadow: @input-box-shadow; + + margin-top: 5px; + padding: 1em 1.25em 0 1.25em; + + background: @panel-bg; + + & > legend { + border: none; + margin: 0; + padding-inline: 1rem; + width: fit-content; + font-size: 18px; + color: inherit; + } + .control-tabs.primary-tabs { + .nav-tabs { + margin: 0; + > li.active > a:before { + background-color: @panel-bg; + border-color: @panel-bg; + } + + > li > a > span.title { + &:before, &:after { + background-color: @panel-bg; + } + } + } + } +} diff --git a/modules/backend/formwidgets/fieldset/partials/_fieldset.php b/modules/backend/formwidgets/fieldset/partials/_fieldset.php new file mode 100644 index 0000000000..6da5db39db --- /dev/null +++ b/modules/backend/formwidgets/fieldset/partials/_fieldset.php @@ -0,0 +1,9 @@ +config, 'label'); ?> + +
diff --git a/modules/backend/tests/widgets/FormFieldSetSaveTest.php b/modules/backend/tests/widgets/FormFieldSetSaveTest.php new file mode 100644 index 0000000000..988cba7f62 --- /dev/null +++ b/modules/backend/tests/widgets/FormFieldSetSaveTest.php @@ -0,0 +1,196 @@ +registerFormWidget(FieldSet::class, 'fieldset'); + WidgetManager::instance()->registerFormWidget(FieldSetSaveStubWidget::class, 'fieldsetsavestub'); + WidgetManager::instance()->registerFormWidget(FieldSetNoSaveStubWidget::class, 'fieldsetnosavestub'); + } + + protected function makeForm(): Form + { + return new Form(new Controller, [ + 'model' => new FormFieldSetSaveTestModel, + 'arrayName' => 'array', + 'fields' => [ + 'top_level' => [ + 'type' => 'text', + ], + 'group' => [ + 'type' => 'fieldset', + 'label' => 'Grouped Fields', + 'fields' => [ + 'nested_text' => [ + 'type' => 'text', + ], + 'nested_number' => [ + 'type' => 'number', + ], + 'nested_widget' => [ + 'type' => 'fieldsetsavestub', + ], + 'nested_nosave' => [ + 'type' => 'fieldsetnosavestub', + ], + 'nested_disabled' => [ + 'type' => 'text', + 'disabled' => true, + ], + ], + ], + ], + ]); + } + + /** + * getSaveData() reads its values with post(), which only returns real data when + * the request is genuinely a POST -- so simulate the postback first. + */ + protected function postForm(array $data): array + { + request()->setMethod('POST'); + request()->request->replace(['array' => $data]); + + return $this->makeForm()->getSaveData(); + } + + public function testNestedFieldSetFieldsAreCollectedToParentLevel() + { + $data = $this->postForm([ + 'top_level' => 'top', + 'nested_text' => 'hello', + 'nested_number' => '42', + ]); + + // Nested fields are hoisted to the parent save data... + $this->assertArrayHasKey('nested_text', $data); + $this->assertArrayHasKey('nested_number', $data); + $this->assertEquals('hello', $data['nested_text']); + + // ...and the top level field is unaffected. + $this->assertEquals('top', $data['top_level']); + + // The fieldset container itself must not be saved. + $this->assertArrayNotHasKey('group', $data); + } + + public function testNestedNumberFieldIsCastToFloat() + { + $data = $this->postForm([ + 'nested_number' => '42', + ]); + + $this->assertSame(42.0, $data['nested_number']); + } + + public function testNestedNumberEmptyStringBecomesNull() + { + $data = $this->postForm([ + 'nested_number' => ' ', + ]); + + $this->assertArrayHasKey('nested_number', $data); + $this->assertNull($data['nested_number']); + } + + public function testNestedWidgetFieldHasSaveValueApplied() + { + $data = $this->postForm([ + 'nested_widget' => 'abc', + ]); + + // The nested widget's getSaveValue() must be applied (stub upper-cases it), + // rather than the raw posted value being stored. + $this->assertArrayHasKey('nested_widget', $data); + $this->assertSame('ABC', $data['nested_widget']); + } + + public function testNestedWidgetReturningNoSaveDataDoesNotLeakSentinel() + { + $data = $this->postForm([ + 'nested_nosave' => 'ignore me', + ]); + + // A nested widget that returns NO_SAVE_DATA must behave exactly as it would + // at the top level of a form: the NO_SAVE_DATA sentinel (-1) must never be + // written to the model. + $this->assertNotSame(FormField::NO_SAVE_DATA, $data['nested_nosave'] ?? null); + } + + public function testDisabledNestedFieldIsNotSaved() + { + $data = $this->postForm([ + 'nested_text' => 'hello', + 'nested_disabled' => 'tampered', + ]); + + // Disabled fields are omitted from the save data, just like top-level fields. + $this->assertArrayHasKey('nested_text', $data); + $this->assertArrayNotHasKey('nested_disabled', $data); + } + + public function testMissingNestedValuesAreOmitted() + { + $data = $this->postForm([ + 'top_level' => 'top', + ]); + + $this->assertArrayNotHasKey('nested_text', $data); + $this->assertArrayNotHasKey('nested_number', $data); + } + } +} diff --git a/modules/backend/widgets/Form.php b/modules/backend/widgets/Form.php index 5598810d93..2fafa88a2f 100644 --- a/modules/backend/widgets/Form.php +++ b/modules/backend/widgets/Form.php @@ -1,17 +1,20 @@ -getSaveData() as $key => $value) { + $this->dataArraySet($result, HtmlHelper::nameToArray($key), $value); + } + continue; + } + // Exclude fields that didn't provide any value $fieldValue = $this->dataArrayGet($result, $parts, FormField::NO_SAVE_DATA); if ($fieldValue === FormField::NO_SAVE_DATA) { @@ -1419,8 +1432,8 @@ protected function objectMethodExists($object, $method) * * @param array $array * @param array $parts - * @param null $default - * @return array|null + * @param mixed $default + * @return mixed */ protected function dataArrayGet(array $array, array $parts, $default = null) {