Merge pull request #9192 from sminnee/fix-9163

NEW: Support dot syntax in form field names
This commit is contained in:
Ingo Schommer 2021-05-21 10:34:15 +12:00 committed by GitHub
commit ad4e488dcf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 353 additions and 15 deletions

View File

@ -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 ## Validation
Form validation is handled by the [Validator](api:SilverStripe\Forms\Validator) class and the `validator` property on the `Form` object. The validator Form validation is handled by the [Validator](api:SilverStripe\Forms\Validator) class and the `validator` property on the `Form` object. The validator

View File

@ -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
<?php
use SilverStripe\ORM\DataObject;
class Player extends DataObject
{
private static $db = [
'Name' => 'Varchar',
];
private static $has_one = [
'HometownTeam' => Team::class,
];
private static $many_many = [
'Teams' => Team::class,
];
}
```
```
<?php
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
private static $db = [
'Name' => '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
<?php
use SilverStripe\Control\Controller;
use SilverStripe\Forms\CheckboxSetField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\HiddenField;
use SilverStripe\Forms\RequiredFields;
use SilverStripe\Forms\TextField;
class MyController extends Controller
{
private static $allowed_actions = ['Form'];
private static $url_segment = 'MyController';
public function Form()
{
$player = Player::get()->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
<?php
use SilverStripe\Control\Controller;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\HiddenField;
use SilverStripe\Forms\RequiredFields;
use SilverStripe\Forms\TextField;
class MyController extends Controller
{
private static $allowed_actions = ['Form'];
private static $url_segment = 'MyController';
public function Form()
{
return Form::create(
$this,
'Form',
FieldList::create([
TextField::create('Name'),
TextField::create('HometownTeam.Name'),
HiddenField::create('ID'),
]),
FieldList::create([
FormAction::create('doSubmitForm', 'Submit')
]),
RequiredFields::create([
'Name',
'HometownTeam.Name',
'ID',
])
);
}
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() || !$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
```
<?php
use SilverStripe\Control\Controller;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\HiddenField;
use SilverStripe\Forms\RequiredFields;
use SilverStripe\Forms\TextField;
class MyController extends Controller
{
private static $allowed_actions = ['Form'];
private static $url_segment = 'MyController';
public function Form()
{
$player = Player::get()->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();
}
}
```

View File

@ -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)).

View File

@ -511,6 +511,7 @@ class FieldList extends ArrayList
*/ */
public function fieldByName($name) public function fieldByName($name)
{ {
$fullName = $name;
if (strpos($name, '.') !== false) { if (strpos($name, '.') !== false) {
list($name, $remainder) = explode('.', $name, 2); list($name, $remainder) = explode('.', $name, 2);
} else { } else {
@ -518,7 +519,9 @@ class FieldList extends ArrayList
} }
foreach ($this as $child) { 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 ($remainder) {
if ($child instanceof CompositeField) { if ($child instanceof CompositeField) {
return $child->fieldByName($remainder); return $child->fieldByName($remainder);

View File

@ -1462,19 +1462,39 @@ class Form extends ViewableData implements HasRequestHandler
$val = null; $val = null;
if (is_object($data)) { if (is_object($data)) {
$exists = ( // Allow dot-syntax traversal of has-one relations fields
isset($data->$name) || if (strpos($name, '.') !== false) {
$data->hasMethod($name) || $exists = (
($data->hasMethod('hasField') && $data->hasField($name)) $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) { if ($exists) {
$val = $data->__get($name); $val = $data->__get($name);
}
} }
// Regular array access. Note that dot-syntax not supported here
} elseif (is_array($data)) { } elseif (is_array($data)) {
if (array_key_exists($name, $data)) { if (array_key_exists($name, $data)) {
$exists = true; $exists = true;
$val = $data[$name]; $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)) { } elseif (preg_match_all('/(.*)\[(.*)\]/U', $name, $matches)) {
// If field is in array-notation we need to access nested data // If field is in array-notation we need to access nested data
//discard first match which is just the whole string //discard first match which is just the whole string

View File

@ -469,8 +469,18 @@ class FormField extends RequestHandler
*/ */
public function saveInto(DataObjectInterface $record) public function saveInto(DataObjectInterface $record)
{ {
if ($this->name) { $component = $record;
$record->setCastedField($this->name, $this->dataValue()); $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());
} }
} }

View File

@ -61,14 +61,17 @@ class FormTemplateHelper
*/ */
public function generateFieldID($field) public function generateFieldID($field)
{ {
// Don't include '.'s in IDs, they confused JavaScript
$name = str_replace('.', '_', $field->getName());
if ($form = $field->getForm()) { if ($form = $field->getForm()) {
return sprintf( return sprintf(
"%s_%s", "%s_%s",
$this->generateFormID($form), $this->generateFormID($form),
Convert::raw2htmlid($field->getName()) Convert::raw2htmlid($name)
); );
} }
return Convert::raw2htmlid($field->getName()); return Convert::raw2htmlid($name);
} }
} }

View File

@ -1844,6 +1844,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
return $this->components[$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(); $schema = static::getSchema();
if ($class = $schema->hasOneComponent(static::class, $componentName)) { if ($class = $schema->hasOneComponent(static::class, $componentName)) {
$joinField = $componentName . 'ID'; $joinField = $componentName . 'ID';

View File

@ -88,7 +88,8 @@ class FormTest extends FunctionalTest
new TextField('key1'), new TextField('key1'),
new TextField('namespace[key2]'), new TextField('namespace[key2]'),
new TextField('namespace[key3][key4]'), new TextField('namespace[key3][key4]'),
new TextField('othernamespace[key5][key6][key7]') new TextField('othernamespace[key5][key6][key7]'),
new TextField('dot.field')
), ),
new FieldList() new FieldList()
); );
@ -108,7 +109,9 @@ class FormTest extends FunctionalTest
'key7' => 'val7' 'key7' => 'val7'
] ]
] ]
] ],
'dot.field' => 'dot.field val'
]; ];
$form->loadDataFrom($requestData); $form->loadDataFrom($requestData);
@ -118,6 +121,7 @@ class FormTest extends FunctionalTest
$this->assertEquals('val2', $fields->fieldByName('namespace[key2]')->Value()); $this->assertEquals('val2', $fields->fieldByName('namespace[key2]')->Value());
$this->assertEquals('val4', $fields->fieldByName('namespace[key3][key4]')->Value()); $this->assertEquals('val4', $fields->fieldByName('namespace[key3][key4]')->Value());
$this->assertEquals('val7', $fields->fieldByName('othernamespace[key5][key6][key7]')->Value()); $this->assertEquals('val7', $fields->fieldByName('othernamespace[key5][key6][key7]')->Value());
$this->assertEquals('dot.field val', $fields->fieldByName('dot.field')->Value());
} }
public function testSubmitReadonlyFields() public function testSubmitReadonlyFields()
@ -186,7 +190,8 @@ class FormTest extends FunctionalTest
new TextField('Name'), // appears in both Player and Team new TextField('Name'), // appears in both Player and Team
new TextareaField('Biography'), new TextareaField('Biography'),
new DateField('Birthday'), new DateField('Birthday'),
new NumericField('BirthdayYear') // dynamic property new NumericField('BirthdayYear'), // dynamic property
new TextField('FavouriteTeam.Name') // dot syntax
), ),
new FieldList() new FieldList()
); );
@ -200,6 +205,7 @@ class FormTest extends FunctionalTest
'Biography' => 'Bio 1', 'Biography' => 'Bio 1',
'Birthday' => '1982-01-01', 'Birthday' => '1982-01-01',
'BirthdayYear' => '1982', 'BirthdayYear' => '1982',
'FavouriteTeam.Name' => 'Team 1',
], ],
'LoadDataFrom() loads simple fields and dynamic getters' 'LoadDataFrom() loads simple fields and dynamic getters'
); );
@ -213,6 +219,7 @@ class FormTest extends FunctionalTest
'Biography' => null, 'Biography' => null,
'Birthday' => null, 'Birthday' => null,
'BirthdayYear' => 0, 'BirthdayYear' => 0,
'FavouriteTeam.Name' => null,
], ],
'LoadNonBlankDataFrom() loads only fields with values, and doesnt overwrite existing values' 'LoadNonBlankDataFrom() loads only fields with values, and doesnt overwrite existing values'
); );
@ -229,6 +236,7 @@ class FormTest extends FunctionalTest
new TextareaField('Biography'), new TextareaField('Biography'),
new DateField('Birthday'), new DateField('Birthday'),
new NumericField('BirthdayYear'), // dynamic property new NumericField('BirthdayYear'), // dynamic property
new TextField('FavouriteTeam.Name'), // dot syntax
$unrelatedField = new TextField('UnrelatedFormField') $unrelatedField = new TextField('UnrelatedFormField')
//new CheckboxSetField('Teams') // relation editing //new CheckboxSetField('Teams') // relation editing
), ),
@ -245,6 +253,7 @@ class FormTest extends FunctionalTest
'Biography' => 'Bio 1', 'Biography' => 'Bio 1',
'Birthday' => '1982-01-01', 'Birthday' => '1982-01-01',
'BirthdayYear' => '1982', 'BirthdayYear' => '1982',
'FavouriteTeam.Name' => 'Team 1',
'UnrelatedFormField' => 'random value', 'UnrelatedFormField' => 'random value',
], ],
'LoadDataFrom() doesnt overwrite fields not found in the object' 'LoadDataFrom() doesnt overwrite fields not found in the object'
@ -261,6 +270,7 @@ class FormTest extends FunctionalTest
'Biography' => '', 'Biography' => '',
'Birthday' => '', 'Birthday' => '',
'BirthdayYear' => 0, 'BirthdayYear' => 0,
'FavouriteTeam.Name' => null,
'UnrelatedFormField' => null, 'UnrelatedFormField' => null,
], ],
'LoadDataFrom() overwrites fields not found in the object with $clearMissingFields=true' 'LoadDataFrom() overwrites fields not found in the object with $clearMissingFields=true'