Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/backend/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
102 changes: 102 additions & 0 deletions modules/backend/formwidgets/FieldSet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

namespace Backend\FormWidgets;
Comment thread
mjauvin marked this conversation as resolved.

use Backend\Classes\FormField;
use Backend\Classes\FormWidgetBase;
use Backend\Widgets\Form;

/**
* FieldSet
* Renders a fieldset from multiple form fields.
*
* @package winter\wn-backend-module
* @author Marc Jauvin <marc.jauvin@gmail.com>
*/
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* @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;
}
}
6 changes: 6 additions & 0 deletions modules/backend/formwidgets/fieldset/assets/css/fieldset.css
Original file line number Diff line number Diff line change
@@ -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}
41 changes: 41 additions & 0 deletions modules/backend/formwidgets/fieldset/assets/less/fieldset.less
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
Comment thread
mjauvin marked this conversation as resolved.
}
9 changes: 9 additions & 0 deletions modules/backend/formwidgets/fieldset/partials/_fieldset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php $label = object_get($this->config, 'label'); ?>

<fieldset class="fieldset">
<?php if ($label): ?>
<legend><?= e(trans($label)) ?></legend>
<?php endif ?>

<?= $this->formWidget->render(['section' => 'outside']) ?>
</fieldset>
196 changes: 196 additions & 0 deletions modules/backend/tests/widgets/FormFieldSetSaveTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php

namespace Backend\Tests\Widgets
{
use Backend\Classes\Controller;
use Backend\Classes\FormField;
use Backend\Classes\FormWidgetBase;
use Backend\Classes\WidgetManager;
use Backend\FormWidgets\FieldSet;
use System\Tests\Bootstrap\PluginTestCase;
use Winter\Storm\Database\Model;
use Backend\Widgets\Form;

class FormFieldSetSaveTestModel extends Model
{
}

/**
* A trivial form widget whose getSaveValue() observably transforms the posted value,
* used to prove that a fieldset's nested `widget` fields have getSaveValue() applied.
*/
class FieldSetSaveStubWidget extends FormWidgetBase
{
protected $defaultAlias = 'fieldsetsavestub';

public function getSaveValue($value)
{
return is_string($value) ? strtoupper($value) : $value;
}
}

/**
* A nested form widget that opts out of saving (as e.g. FileUpload does, since its
* relation manages persistence). Its NO_SAVE_DATA sentinel must never reach the model.
*/
class FieldSetNoSaveStubWidget extends FormWidgetBase
{
protected $defaultAlias = 'fieldsetnosavestub';

public function getSaveValue($value)
{
return FormField::NO_SAVE_DATA;
}
}

/**
* Covers Form::getSaveData()'s handling of the `fieldset` form widget: the fieldset
* visually groups fields but they must be saved as if they were regular fields at the
* parent level, while the fieldset container itself saves nothing.
*/
class FormFieldSetSaveTest extends PluginTestCase
{
public function setUp(): void
{
parent::setUp();

// The backend module's form widgets are not auto-registered under PluginTestCase,
// so make the aliases used by the form field configs below resolvable.
WidgetManager::instance()->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);
}
}
}
Loading
Loading