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 fcb6952fafa..a2d033127c4 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/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 00000000000..e9a6ea15e81 --- /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(); + } +} +``` 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 00000000000..ef299090671 --- /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)). diff --git a/src/Forms/FieldList.php b/src/Forms/FieldList.php index 5ec25798a04..1044b64b30e 100644 --- a/src/Forms/FieldList.php +++ b/src/Forms/FieldList.php @@ -511,6 +511,7 @@ public function findOrMakeTab($tabName, $title = null) */ public function fieldByName($name) { + $fullName = $name; if (strpos($name, '.') !== false) { list($name, $remainder) = explode('.', $name, 2); } else { @@ -518,7 +519,9 @@ public function fieldByName($name) } 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 1d351695417..c35561d23f7 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -1462,19 +1462,39 @@ public function loadDataFrom($data, $mergeStrategy = 0, $fieldList = null) $val = null; if (is_object($data)) { - $exists = ( - isset($data->$name) || - $data->hasMethod($name) || - ($data->hasMethod('hasField') && $data->hasField($name)) - ); - - if ($exists) { - $val = $data->__get($name); + // Allow dot-syntax traversal of has-one relations fields + 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 = ( + 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)) { 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 diff --git a/src/Forms/FormField.php b/src/Forms/FormField.php index 9cad1e8df6c..d35871c3a94 100644 --- a/src/Forms/FormField.php +++ b/src/Forms/FormField.php @@ -469,8 +469,18 @@ public function Value() */ 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()); } } diff --git a/src/Forms/FormTemplateHelper.php b/src/Forms/FormTemplateHelper.php index 227e248ed3f..a487ac44aff 100644 --- a/src/Forms/FormTemplateHelper.php +++ b/src/Forms/FormTemplateHelper.php @@ -61,14 +61,17 @@ public function generateFieldHolderID($field) */ 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); } } diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 0d0910aeb05..e67fc8201c5 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -1844,6 +1844,11 @@ public function getComponent($componentName) 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'; diff --git a/tests/php/Forms/FormTest.php b/tests/php/Forms/FormTest.php index b31a4a25437..8ee42df4bb1 100644 --- a/tests/php/Forms/FormTest.php +++ b/tests/php/Forms/FormTest.php @@ -88,7 +88,8 @@ public function testLoadDataFromRequest() 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 @@ public function testLoadDataFromRequest() 'key7' => 'val7' ] ] - ] + ], + 'dot.field' => 'dot.field val' + ]; $form->loadDataFrom($requestData); @@ -118,6 +121,7 @@ public function testLoadDataFromRequest() $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 @@ public function testLoadDataFromObject() 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 @@ public function testLoadDataFromObject() 'Biography' => 'Bio 1', 'Birthday' => '1982-01-01', 'BirthdayYear' => '1982', + 'FavouriteTeam.Name' => 'Team 1', ], 'LoadDataFrom() loads simple fields and dynamic getters' ); @@ -213,6 +219,7 @@ public function testLoadDataFromObject() 'Biography' => null, 'Birthday' => null, 'BirthdayYear' => 0, + 'FavouriteTeam.Name' => null, ], 'LoadNonBlankDataFrom() loads only fields with values, and doesnt overwrite existing values' ); @@ -229,6 +236,7 @@ public function testLoadDataFromClearMissingFields() 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 @@ public function testLoadDataFromClearMissingFields() '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 @@ public function testLoadDataFromClearMissingFields() 'Biography' => '', 'Birthday' => '', 'BirthdayYear' => 0, + 'FavouriteTeam.Name' => null, 'UnrelatedFormField' => null, ], 'LoadDataFrom() overwrites fields not found in the object with $clearMissingFields=true'