From 02fb7c3b178f85b75365b0efb46ae8b25a204590 Mon Sep 17 00:00:00 2001 From: Sam Minnee Date: Sat, 24 Aug 2019 16:28:09 +1200 Subject: [PATCH 1/8] NEW: Support dot syntax in form field names This change adds support for these in a few places. - Form::saveInto($record) - Form::loadDataForm($record) - Form::loadDataForm($_POST) Fixes https://github.com/silverstripe/silverstripe-framework/issues/9163 --- src/Forms/Form.php | 27 ++++++++++++++++++++------- src/Forms/FormField.php | 14 ++++++++++++-- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 1d3516954..d9e0e91c1 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -1462,16 +1462,29 @@ class Form extends ViewableData implements HasRequestHandler $val = null; if (is_object($data)) { - $exists = ( - isset($data->$name) || - $data->hasMethod($name) || - ($data->hasMethod('hasField') && $data->hasField($name)) - ); + // Allow dot-syntax traversal of has-one relations fields + if (strpos($name, '.') !== false && $data->hasMethod('relField')) { + $val = $data->relField($name); + $exists = true; - if ($exists) { - $val = $data->__get($name); + // Regular ViewableData access + } else { + $exists = ( + isset($data->$name) || + $data->hasMethod($name) || + ($data->hasMethod('hasField') && $data->hasField($name)) + ); + + if ($exists) { + $val = $data->__get($name); + } } + + // Regular array access. Note that dot-syntax not supported here } elseif (is_array($data)) { + // PHP turns the '.'s in POST vars into '_'s + $name = str_replace('.', '_', $name); + if (array_key_exists($name, $data)) { $exists = true; $val = $data[$name]; diff --git a/src/Forms/FormField.php b/src/Forms/FormField.php index 9cad1e8df..d35871c3a 100644 --- a/src/Forms/FormField.php +++ b/src/Forms/FormField.php @@ -469,8 +469,18 @@ class FormField extends RequestHandler */ public function saveInto(DataObjectInterface $record) { - if ($this->name) { - $record->setCastedField($this->name, $this->dataValue()); + $component = $record; + $fieldName = $this->name; + + // Allow for dot syntax + if (($pos = strrpos($this->name, '.')) !== false) { + $relation = substr($this->name, 0, $pos); + $fieldName = substr($this->name, $pos + 1); + $component = $record->relObject($relation); + } + + if ($fieldName) { + $component->setCastedField($fieldName, $this->dataValue()); } } From 6ba7bf7b2f5bda81f50392cf1331a359b779c407 Mon Sep 17 00:00:00 2001 From: Sam Minnee Date: Sat, 24 Aug 2019 17:12:26 +1200 Subject: [PATCH 2/8] =?UTF-8?q?FIX:=20Replace=20=E2=80=98.=E2=80=99s=20wit?= =?UTF-8?q?h=20=E2=80=98=5F=E2=80=99s=20in=20HTML=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The functioning of dot-syntax in form fields mean that .s are more likely to appear in names. This breaks javascript behaviour in HTML IDs and I believe is an invalid character for them. --- src/Forms/FormTemplateHelper.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Forms/FormTemplateHelper.php b/src/Forms/FormTemplateHelper.php index 227e248ed..a487ac44a 100644 --- a/src/Forms/FormTemplateHelper.php +++ b/src/Forms/FormTemplateHelper.php @@ -61,14 +61,17 @@ class FormTemplateHelper */ public function generateFieldID($field) { + // Don't include '.'s in IDs, they confused JavaScript + $name = str_replace('.', '_', $field->getName()); + if ($form = $field->getForm()) { return sprintf( "%s_%s", $this->generateFormID($form), - Convert::raw2htmlid($field->getName()) + Convert::raw2htmlid($name) ); } - return Convert::raw2htmlid($field->getName()); + return Convert::raw2htmlid($name); } } From c7c6bdebdf655261f597b0f563bd11d3494dc9c4 Mon Sep 17 00:00:00 2001 From: Sam Minnee Date: Mon, 26 Aug 2019 12:25:00 +1200 Subject: [PATCH 3/8] FIX: Allow join-object to be referenced as a component This avoids having arbitrary differences between a join object and a has-one relation. --- src/ORM/DataObject.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 0d0910aeb..e67fc8201 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -1844,6 +1844,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return $this->components[$componentName]; } + // The join object can be returned as a component, named for its alias + if (isset($this->record[$componentName]) && $this->record[$componentName] === $this->joinRecord) { + return $this->record[$componentName]; + } + $schema = static::getSchema(); if ($class = $schema->hasOneComponent(static::class, $componentName)) { $joinField = $componentName . 'ID'; From 5dcf5197daa916c5b2126ec4421e35afe869c546 Mon Sep 17 00:00:00 2001 From: Sam Minnee Date: Mon, 26 Aug 2019 12:25:15 +1200 Subject: [PATCH 4/8] FIX: Make the ./_ substitution optional. --- src/Forms/Form.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Forms/Form.php b/src/Forms/Form.php index d9e0e91c1..9ac318515 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -1482,12 +1482,14 @@ class Form extends ViewableData implements HasRequestHandler // Regular array access. Note that dot-syntax not supported here } elseif (is_array($data)) { - // PHP turns the '.'s in POST vars into '_'s - $name = str_replace('.', '_', $name); if (array_key_exists($name, $data)) { $exists = true; $val = $data[$name]; + // PHP turns the '.'s in POST vars into '_'s + } elseif (array_key_exists($altName = str_replace('.', '_', $name), $data)) { + $exists = true; + $val = $data[$altName]; } elseif (preg_match_all('/(.*)\[(.*)\]/U', $name, $matches)) { // If field is in array-notation we need to access nested data //discard first match which is just the whole string From 23ffd2bbd6bd38f553c2675e7351fa8492926508 Mon Sep 17 00:00:00 2001 From: Dan Hensby Date: Mon, 21 Sep 2020 12:02:07 +0100 Subject: [PATCH 5/8] Linting fix --- src/Forms/Form.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 9ac318515..eb9d9b381 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -1482,7 +1482,6 @@ class Form extends ViewableData implements HasRequestHandler // Regular array access. Note that dot-syntax not supported here } elseif (is_array($data)) { - if (array_key_exists($name, $data)) { $exists = true; $val = $data[$name]; From 8806b3befc447c7d6e0a26452bdab314b66229c1 Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Thu, 20 May 2021 20:07:44 +1200 Subject: [PATCH 6/8] Fixes required for dot notation support in fields See #9163 --- .../03_Forms/00_Introduction.md | 2 ++ src/Forms/FieldList.php | 5 ++++- src/Forms/Form.php | 14 ++++++++++---- tests/php/Forms/FormTest.php | 16 +++++++++++++--- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/en/02_Developer_Guides/03_Forms/00_Introduction.md b/docs/en/02_Developer_Guides/03_Forms/00_Introduction.md index fcb6952fa..a2d033127 100644 --- a/docs/en/02_Developer_Guides/03_Forms/00_Introduction.md +++ b/docs/en/02_Developer_Guides/03_Forms/00_Introduction.md @@ -332,6 +332,8 @@ class PageController extends ContentController ``` +See [how_tos/handle_nested_data](How to: Handle nested form data) for more advanced use cases. + ## Validation Form validation is handled by the [Validator](api:SilverStripe\Forms\Validator) class and the `validator` property on the `Form` object. The validator diff --git a/src/Forms/FieldList.php b/src/Forms/FieldList.php index 5ec25798a..1044b64b3 100644 --- a/src/Forms/FieldList.php +++ b/src/Forms/FieldList.php @@ -511,6 +511,7 @@ class FieldList extends ArrayList */ public function fieldByName($name) { + $fullName = $name; if (strpos($name, '.') !== false) { list($name, $remainder) = explode('.', $name, 2); } else { @@ -518,7 +519,9 @@ class FieldList extends ArrayList } foreach ($this as $child) { - if (trim($name) == trim($child->getName()) || $name == $child->id) { + if (trim($fullName) == trim($child->getName()) || $fullName == $child->id) { + return $child; + } elseif (trim($name) == trim($child->getName()) || $name == $child->id) { if ($remainder) { if ($child instanceof CompositeField) { return $child->fieldByName($remainder); diff --git a/src/Forms/Form.php b/src/Forms/Form.php index eb9d9b381..c35561d23 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -1463,10 +1463,16 @@ class Form extends ViewableData implements HasRequestHandler if (is_object($data)) { // Allow dot-syntax traversal of has-one relations fields - if (strpos($name, '.') !== false && $data->hasMethod('relField')) { - $val = $data->relField($name); - $exists = true; - + if (strpos($name, '.') !== false) { + $exists = ( + $data->hasMethod('relField') + ); + try { + $val = $data->relField($name); + } catch (\LogicException $e) { + // There's no other way to tell whether the relation actually exists + $exists = false; + } // Regular ViewableData access } else { $exists = ( diff --git a/tests/php/Forms/FormTest.php b/tests/php/Forms/FormTest.php index b31a4a254..8ee42df4b 100644 --- a/tests/php/Forms/FormTest.php +++ b/tests/php/Forms/FormTest.php @@ -88,7 +88,8 @@ class FormTest extends FunctionalTest new TextField('key1'), new TextField('namespace[key2]'), new TextField('namespace[key3][key4]'), - new TextField('othernamespace[key5][key6][key7]') + new TextField('othernamespace[key5][key6][key7]'), + new TextField('dot.field') ), new FieldList() ); @@ -108,7 +109,9 @@ class FormTest extends FunctionalTest 'key7' => 'val7' ] ] - ] + ], + 'dot.field' => 'dot.field val' + ]; $form->loadDataFrom($requestData); @@ -118,6 +121,7 @@ class FormTest extends FunctionalTest $this->assertEquals('val2', $fields->fieldByName('namespace[key2]')->Value()); $this->assertEquals('val4', $fields->fieldByName('namespace[key3][key4]')->Value()); $this->assertEquals('val7', $fields->fieldByName('othernamespace[key5][key6][key7]')->Value()); + $this->assertEquals('dot.field val', $fields->fieldByName('dot.field')->Value()); } public function testSubmitReadonlyFields() @@ -186,7 +190,8 @@ class FormTest extends FunctionalTest new TextField('Name'), // appears in both Player and Team new TextareaField('Biography'), new DateField('Birthday'), - new NumericField('BirthdayYear') // dynamic property + new NumericField('BirthdayYear'), // dynamic property + new TextField('FavouriteTeam.Name') // dot syntax ), new FieldList() ); @@ -200,6 +205,7 @@ class FormTest extends FunctionalTest 'Biography' => 'Bio 1', 'Birthday' => '1982-01-01', 'BirthdayYear' => '1982', + 'FavouriteTeam.Name' => 'Team 1', ], 'LoadDataFrom() loads simple fields and dynamic getters' ); @@ -213,6 +219,7 @@ class FormTest extends FunctionalTest 'Biography' => null, 'Birthday' => null, 'BirthdayYear' => 0, + 'FavouriteTeam.Name' => null, ], 'LoadNonBlankDataFrom() loads only fields with values, and doesnt overwrite existing values' ); @@ -229,6 +236,7 @@ class FormTest extends FunctionalTest new TextareaField('Biography'), new DateField('Birthday'), new NumericField('BirthdayYear'), // dynamic property + new TextField('FavouriteTeam.Name'), // dot syntax $unrelatedField = new TextField('UnrelatedFormField') //new CheckboxSetField('Teams') // relation editing ), @@ -245,6 +253,7 @@ class FormTest extends FunctionalTest 'Biography' => 'Bio 1', 'Birthday' => '1982-01-01', 'BirthdayYear' => '1982', + 'FavouriteTeam.Name' => 'Team 1', 'UnrelatedFormField' => 'random value', ], 'LoadDataFrom() doesnt overwrite fields not found in the object' @@ -261,6 +270,7 @@ class FormTest extends FunctionalTest 'Biography' => '', 'Birthday' => '', 'BirthdayYear' => 0, + 'FavouriteTeam.Name' => null, 'UnrelatedFormField' => null, ], 'LoadDataFrom() overwrites fields not found in the object with $clearMissingFields=true' From 99c56fc913369f12b33f24cd1bcb8fd261189bb6 Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Thu, 20 May 2021 20:08:06 +1200 Subject: [PATCH 7/8] DOC Handling nested form data Related to #9163 --- .../03_Forms/How_Tos/06_Handle_Nested_data.md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 docs/en/02_Developer_Guides/03_Forms/How_Tos/06_Handle_Nested_data.md diff --git a/docs/en/02_Developer_Guides/03_Forms/How_Tos/06_Handle_Nested_data.md b/docs/en/02_Developer_Guides/03_Forms/How_Tos/06_Handle_Nested_data.md new file mode 100644 index 000000000..e9a6ea15e --- /dev/null +++ b/docs/en/02_Developer_Guides/03_Forms/How_Tos/06_Handle_Nested_data.md @@ -0,0 +1,279 @@ +--- +title: How to handle nested data in forms +summary: Forms can save into arrays, including has_one relations +iconBrand: wpforms +--- + +# How to: Save nested data + +## Overview + +Forms often save into fields `DataObject` records, through [Form::saveInto()](api:Form::saveInto()). +There are a number of ways to save nested data into those records, including their relationships. + +Let's take the following data structure, and walk through different approaches. + +```php + 'Varchar', + ]; + + private static $has_one = [ + 'HometownTeam' => Team::class, + ]; + + private static $many_many = [ + 'Teams' => Team::class, + ]; +} +``` + +``` + 'Varchar', + ]; + + private static $belongs_many_many = [ + 'Players' => Player::class, + ]; +} +``` + +## Form fields + +Some form fields like [MultiSelectField](api:MultiSelectField) and [CheckboxSetField](api:CheckboxSetField) +support saving lists of identifiers into a relation. Naming the field by the relation name will +trigger the form field to write into the relationship. + +Example: Select teams for an existing player + +```php +byID(1); + return Form::create( + $this, + 'Form', + FieldList::create([ + TextField::create('Name'), + CheckboxSetField::create('Teams') + ->setSource(Team::get()->map()), + HiddenField::create('ID'), + ]), + FieldList::create([ + FormAction::create('doSubmitForm', 'Submit') + ]), + RequiredFields::create([ + 'Name', + 'Teams', + 'ID', + ]) + )->loadDataFrom($player); + } + + public function doSubmitForm($data, $form) + { + $player = Player::get()->byID($data['ID']); + + // Only works for updating existing records + if (!$player) { + return false; + } + + // Check permissions for the current user. + if (!$player->canEdit()) { + return false; + } + + // Automatically writes Teams() relationship + $form->saveInto($player); + + $form->sessionMessage('Saved!', 'good'); + + return $this->redirectBack(); + } +} +``` + + +## Dot notation + +For single record relationships (e.g. `has_one`), +forms can automatically traverse into this relationship by using dot notation +in the form field name. This also works with custom getters returning +`DataObject` instances. + +Example: Update team name (via a `has_one` relationship) on an existing player. + +```php +byID($data['ID']); + + // Only works for updating existing records + if (!$player) { + return false; + } + + // Check permissions for the current user. + if (!$player->canEdit() || !$player->HometownTeam()->canEdit()) { + return false; + } + + $form->saveInto($player); + + // Write relationships *before* the original object + // to avoid changes being lost when flush() is called after write(). + // CAUTION: This will create a new record if none is set on the relationship. + // This might or might not be desired behaviour. + $player->HometownTeam()->write(); + $player->write(); + + $form->sessionMessage('Saved!', 'good'); + + return $this->redirectBack(); + } +} +``` + +## Array notation + +This is the most advanced technique, since it works with the form submission directly, +rather than relying on form field logic. + +Example: Create one or more new teams for existing player + +``` +byID(1); + return Form::create( + $this, + 'Form', + FieldList::create([ + TextField::create('Name'), + // The UI could duplicate this field to allow creating multiple fields + TextField::create('NewTeams[]', 'New Teams'), + HiddenField::create('ID'), + ]), + FieldList::create([ + FormAction::create('doSubmitForm', 'Submit') + ]), + RequiredFields::create([ + 'Name', + 'MyTeams[]', + 'ID', + ]) + )->loadDataFrom($player); + } + + public function doSubmitForm($data, $form) + { + $player = Player::get()->byID($data['ID']); + + // Only works for updating existing records + if (!$player) { + return false; + } + + // Check permissions for the current user. + // if (!$player->canEdit()) { + // return false; + // } + + $form->saveInto($player); + + // Manually create teams based on provided data + foreach ($data['NewTeams'] as $teamName) { + // Caution: Requires data validation on model + $team = Team::create()->update(['Name' => $teamName]); + $team->write(); + $player->Teams()->add($team); + } + + $form->sessionMessage('Saved!', 'good'); + + return $this->redirectBack(); + } +} +``` From 8c9e203f1e337f803717f7e6b92587bc8c0ca417 Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Fri, 21 May 2021 10:32:47 +1200 Subject: [PATCH 8/8] DOCS 4.9.0 changelog with dot notation changes [ci-skip] --- docs/en/04_Changelogs/4.9.0.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/en/04_Changelogs/4.9.0.md diff --git a/docs/en/04_Changelogs/4.9.0.md b/docs/en/04_Changelogs/4.9.0.md new file mode 100644 index 000000000..ef2990906 --- /dev/null +++ b/docs/en/04_Changelogs/4.9.0.md @@ -0,0 +1,6 @@ +# 4.9.0 (Unreleased) + + +## New features + +* [Dot notation support in form fields](https://github.com/silverstripe/silverstripe-framework/pull/9192): Save directly into nested has_one relationships (see [docs](/developer_guides/forms/how_tos/handle_nested_data)).