From afccef718c01f02c14e977d1b7c28dc7afd78172 Mon Sep 17 00:00:00 2001 From: David Craig Date: Mon, 22 Feb 2016 14:13:35 +1300 Subject: [PATCH 1/5] Initial Form Field Schema implementation - Adds FormSchema class - Adds FormSchema dependency to LeftAndMain via Injector - Adds schema allowed_action method to LeftAndMain for generating Form schemas - Adds FormFieldSchemaTrait to for schema getters and setters on FormFields --- _config/config.yml | 3 ++ admin/code/LeftAndMain.php | 20 ++++++++- forms/FormField.php | 21 ++++++++- forms/FormFieldSchemaTrait.php | 81 ++++++++++++++++++++++++++++++++++ forms/FormSchema.php | 56 +++++++++++++++++++++++ tests/forms/FormFieldTest.php | 37 ++++++++++++++++ tests/forms/FormSchemaTest.php | 62 ++++++++++++++++++++++++++ 7 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 forms/FormFieldSchemaTrait.php create mode 100644 forms/FormSchema.php create mode 100644 tests/forms/FormSchemaTest.php diff --git a/_config/config.yml b/_config/config.yml index 42dc3103c..77ff116b9 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -1,6 +1,9 @@ --- Name: coreconfig --- +Injector: + FormSchema: + class: SilverStripe\Forms\Schema\FormSchema Upload: # Replace an existing file rather than renaming the new one. replaceFile: false diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php index b651ac957..ec4c09145 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -5,6 +5,8 @@ * @subpackage admin */ +use SilverStripe\Forms\Schema\FormSchema; + /** * LeftAndMain is the parent class of all the two-pane views in the CMS. * If you are wanting to add more areas to the CMS, you can do it by subclassing LeftAndMain. @@ -84,7 +86,7 @@ class LeftAndMain extends Controller implements PermissionProvider { /** * @var array */ - private static $allowed_actions = array( + private static $allowed_actions = [ 'index', 'save', 'savetreenode', @@ -97,7 +99,12 @@ class LeftAndMain extends Controller implements PermissionProvider { 'AddForm', 'batchactions', 'BatchActionsForm', - ); + 'schema', + ]; + + private static $dependencies = [ + 'schema' => '%$FormSchema' + ]; /** * @config @@ -169,6 +176,15 @@ class LeftAndMain extends Controller implements PermissionProvider { */ protected $responseNegotiator; + /** + * Gets a JSON schema representing the current edit form. + * + * @return SS_HTTPResponse + */ + public function schema() { + return $this->schema->getSchema($this->getEditForm()); + } + /** * @param Member $member * @return boolean diff --git a/forms/FormField.php b/forms/FormField.php index f041af1f8..239dba2cb 100644 --- a/forms/FormField.php +++ b/forms/FormField.php @@ -25,6 +25,8 @@ */ class FormField extends RequestHandler { + use SilverStripe\Forms\Schema\FormFieldSchemaTrait; + /** * @var Form */ @@ -80,7 +82,7 @@ class FormField extends RequestHandler { * @config * @var array $default_classes The default classes to apply to the FormField */ - private static $default_classes = array(); + private static $default_classes = []; /** @@ -162,7 +164,22 @@ class FormField extends RequestHandler { * * @var array */ - protected $attributes = array(); + protected $attributes = []; + + /** + * The type of front-end component to render the FormField as. + * + * @var string + */ + protected $schemaComponent; + + /** + * Structured schema data representing the FormField. + * Used to render the FormField as a ReactJS Component on the front-end. + * + * @var array + */ + protected $schemaData = []; /** * Takes a field name and converts camelcase to spaced words. Also resolves combined field diff --git a/forms/FormFieldSchemaTrait.php b/forms/FormFieldSchemaTrait.php new file mode 100644 index 000000000..f78209f5f --- /dev/null +++ b/forms/FormFieldSchemaTrait.php @@ -0,0 +1,81 @@ +schemaComponent = $componentType; + return $this; + } + + /** + * Gets the type of front-end component the FormField will be rendered as. + * + * @return string + */ + public function getSchemaComponent() { + return $this->schemaComponent; + } + + /** + * Sets the schema data used for rendering the field on the front-end. + * Merges the passed array with the current `$schemaData` or {@link getSchemaDataDefaults()}. + * Any passed keys that are not defined in {@link getSchemaDataDefaults()} are ignored. + * If you want to pass around ad hoc data use the `data` array e.g. pass `['data' => ['myCustomKey' => 'yolo']]`. + * + * @param array $schemaData - The data to be merged with $this->schemaData. + * @return FormField + * + * @todo Add deep merging of arrays like `data` and `attributes`. + */ + public function setSchemaData($schemaData = []) { + $current = $this->getSchemaData(); + + $this->schemaData = array_merge($current, array_intersect_key($schemaData, $current)); + return $this; + } + + /** + * Gets the schema data used to render the FormField on the front-end. + * + * @return array + */ + public function getSchemaData() { + return array_merge($this->getSchemaDataDefaults(), $this->schemaData); + } + + /** + * Gets the defaults for $schemaData. + * The keys defined here are immutable, meaning undefined keys passed to {@link setSchemaData()} are ignored. + * Instead the `data` array should be used to pass around ad hoc data. + * + * @return array + */ + function getSchemaDataDefaults() { + return [ + 'type' => $this->class, + 'component' => $this->getSchemaComponent(), + 'id' => $this->ID, + 'holder_id' => null, + 'name' => $this->getName(), + 'title' => $this->Title(), + 'source' => null, + 'extraClass' => $this->ExtraClass(), + 'description' => $this->getDescription(), + 'rightTitle' => $this->RightTitle(), + 'leftTitle' => $this->LeftTitle(), + 'readOnly' => $this->isReadOnly(), + 'disabled' => $this->isDisabled(), + 'customValidationMessage' => $this->getCustomValidationMessage(), + 'attributes' => [], + 'data' => [], + ]; + } +} diff --git a/forms/FormSchema.php b/forms/FormSchema.php new file mode 100644 index 000000000..b3d61a44e --- /dev/null +++ b/forms/FormSchema.php @@ -0,0 +1,56 @@ +controller()->getRequest(); + $params = $request->AllParams(); + + $schema = [ + 'name' => $form->getName(), + 'id' => isset($params['ID']) ? $params['ID'] : null, + 'action' => isset($params['Action']) ? $params['Action'] : null, + 'method' => $form->controller()->getRequest()->HttpMethod(), + 'schema_url' => $request->getUrl(), + 'attributes' => $form->getAttributes(), + 'data' => [], + 'fields' => [], + 'actions' => [] + ]; + + foreach ($form->Actions() as $action) { + $schema['actions'][] = $action->getSchemaData(); + } + + foreach ($form->Fields() as $fieldList) { + foreach ($fieldList->getForm()->fields()->dataFields() as $field) { + $schema['fields'][] = $field->getSchemaData(); + } + } + + return Convert::raw2json($schema); + } + + /** + * Gets the current state of this form as a nested array. + * + * @param From $form + * @return string + */ + public function getState(Form $form) { + $state = ['state' => []]; + + return Convert::raw2json($state); + } +} diff --git a/tests/forms/FormFieldTest.php b/tests/forms/FormFieldTest.php index 221091dad..7ab132327 100644 --- a/tests/forms/FormFieldTest.php +++ b/tests/forms/FormFieldTest.php @@ -241,6 +241,43 @@ class FormFieldTest extends SapphireTest { $this->assertArrayHasKey('extended', $field->getAttributes()); } + public function testSetSchemaComponent() { + $field = new FormField('MyField'); + $field = $field->setSchemaComponent('MyComponent'); + $component = $field->getSchemaComponent(); + $this->assertEquals('MyComponent', $component); + } + + public function testGetSchemaDataDefaults() { + $field = new FormField('MyField'); + $schema = $field->getSchemaDataDefaults(); + $this->assertInternalType('array', $schema); + } + + public function testGetSchemaData() { + $field = new FormField('MyField'); + $schema = $field->getSchemaData(); + $this->assertEquals('MyField', $schema['name']); + + // Make sure the schema data is up-to-date with object properties. + $field->setName('UpdatedField'); + $schema = $field->getSchemaData(); + $this->assertEquals($field->getName(), $schema['name']); + } + + public function testSetSchemaData() { + $field = new FormField('MyField'); + + // Make sure the user can update values. + $field = $field->setSchemaData(['name' => 'MyUpdatedField']); + $schema = $field->getSchemaData(); + $this->assertEquals($schema['name'], 'MyUpdatedField'); + + // Make user the user can't define custom keys on the schema. + $field = $field->setSchemaData(['myCustomKey' => 'yolo']); + $schema = $field->getSchemaData(); + $this->assertEquals(array_key_exists('myCustomKey', $schema), false); + } } /** diff --git a/tests/forms/FormSchemaTest.php b/tests/forms/FormSchemaTest.php new file mode 100644 index 000000000..11b7f3697 --- /dev/null +++ b/tests/forms/FormSchemaTest.php @@ -0,0 +1,62 @@ + 'TestForm', + 'id' => null, + 'action' => null, + 'method' => '', + 'schema_url' => '', + 'attributes' => [ + 'id' => 'Form_TestForm', + 'action' => 'Controller/TestForm', + 'method' => 'POST', + 'enctype' => 'application/x-www-form-urlencoded', + 'target' => null, + 'class' => '' + ], + 'data' => [], + 'fields' => [ + [ + 'type' => "HiddenField", + 'component' => null, + 'id' => null, + 'holder_id' => null, + 'name' => 'SecurityID', + 'title' => 'Security ID', + 'source' => null, + 'extraClass' => 'hidden', + 'description' => null, + 'rightTitle' => null, + 'leftTitle' => null, + 'readOnly' => false, + 'disabled' => false, + 'customValidationMessage' => '', + 'attributes' => [], + 'data' => [] + ], + ], + 'actions' => [] + ]); + + $schema = $formSchema->getSchema($form); + + $this->assertJsonStringEqualsJsonString($expectedJSON, $schema); + } + + public function testGetState() { + $form = new Form(new Controller(), 'TestForm', new FieldList(), new FieldList()); + $formSchema = new FormSchema(); + $expectedJSON = json_encode(['state' => []]); + + $state = $formSchema->getState($form); + + $this->assertJsonStringEqualsJsonString($expectedJSON, $state); + } +} From 404ac4ae43d15e957510f5f4764273d3433dea51 Mon Sep 17 00:00:00 2001 From: David Craig Date: Mon, 29 Feb 2016 13:50:59 +1300 Subject: [PATCH 2/5] Initial schemaState implementation --- admin/code/LeftAndMain.php | 38 +++++++++++++++++++++++++++++++++- forms/FormSchema.php | 14 ++++++++----- tests/forms/FormSchemaTest.php | 18 +++++++++------- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php index ec4c09145..a64dac490 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -182,7 +182,43 @@ class LeftAndMain extends Controller implements PermissionProvider { * @return SS_HTTPResponse */ public function schema() { - return $this->schema->getSchema($this->getEditForm()); + $req = $this->getRequest(); + $res = $this->getResponse(); + $schemaParts = []; + + // Valid values for the "X-Formschema-Request" header are "schema" and "state". + // If either of these values are set they will be stored in the $schemaParst array + // and used to construct the response body. + if ($schemaHeader = $req->getHeader('X-Formschema-Request')) { + $schemaParts = array_filter(explode(',', $schemaHeader), function($value) { + $validHeaderValues = ['schema', 'state']; + return in_array(trim($value), $validHeaderValues); + }); + } + + // Make sure it's an AJAX GET request with a valid "X-Formschema-Request" header value. + if (!$req->isAjax() || !$req->isGET() || !count($schemaParts)) { + throw new SS_HTTPResponse_Exception( + 'Invalid request. Check you\'ve set a "X-Formschema-Request" header with "schema" or "state" values.', + 400 + ); + } + + $form = $this->getEditForm(); + $responseBody = ['id' => $form->getName()]; + + if (in_array('schema', $schemaParts)) { + $responseBody['schema'] = $this->schema->getSchema($form); + } + + if (in_array('state', $schemaParts)) { + $responseBody['state'] = $this->schema->getState($form); + } + + $res->addHeader('Content-Type', 'application/json'); + $res->setBody(Convert::raw2json($responseBody)); + + return $res; } /** diff --git a/forms/FormSchema.php b/forms/FormSchema.php index b3d61a44e..2b7335b70 100644 --- a/forms/FormSchema.php +++ b/forms/FormSchema.php @@ -11,7 +11,7 @@ class FormSchema { * Gets the schema for this form as a nested array. * * @param Form $form - * @return string + * @return array */ public function getSchema(Form $form) { $request = $form->controller()->getRequest(); @@ -39,18 +39,22 @@ class FormSchema { } } - return Convert::raw2json($schema); + return $schema; } /** * Gets the current state of this form as a nested array. * * @param From $form - * @return string + * @return array */ public function getState(Form $form) { - $state = ['state' => []]; + $state = [ + 'id' => $form->getName(), + 'fields' => [], + 'messages' => [] + ]; - return Convert::raw2json($state); + return $state; } } diff --git a/tests/forms/FormSchemaTest.php b/tests/forms/FormSchemaTest.php index 11b7f3697..3aaf36533 100644 --- a/tests/forms/FormSchemaTest.php +++ b/tests/forms/FormSchemaTest.php @@ -7,7 +7,7 @@ class FormSchemaTest extends SapphireTest { public function testGetSchema() { $form = new Form(new Controller(), 'TestForm', new FieldList(), new FieldList()); $formSchema = new FormSchema(); - $expectedJSON = json_encode([ + $expected = [ 'name' => 'TestForm', 'id' => null, 'action' => null, @@ -43,20 +43,24 @@ class FormSchemaTest extends SapphireTest { ], ], 'actions' => [] - ]); + ]; $schema = $formSchema->getSchema($form); - - $this->assertJsonStringEqualsJsonString($expectedJSON, $schema); + $this->assertInternalType('array', $schema); + $this->assertJsonStringEqualsJsonString(json_encode($expected), json_encode($schema)); } public function testGetState() { $form = new Form(new Controller(), 'TestForm', new FieldList(), new FieldList()); $formSchema = new FormSchema(); - $expectedJSON = json_encode(['state' => []]); + $expected = [ + 'id' => 'TestForm', + 'fields' => [], + 'messages' => [] + ]; $state = $formSchema->getState($form); - - $this->assertJsonStringEqualsJsonString($expectedJSON, $state); + $this->assertInternalType('array', $state); + $this->assertJsonStringEqualsJsonString(json_encode($expected), json_encode($state)); } } From 746322a9f18365bff554e72faa9789d890e1512d Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Tue, 1 Mar 2016 16:15:15 +1300 Subject: [PATCH 3/5] Replace CMSForm use with new setValidationResponseCallback() API Preparing for form schema API, see https://github.com/silverstripe/silverstripe-framework/issues/4938 --- admin/code/CMSForm.php | 52 ------------------------------------ admin/code/LeftAndMain.php | 24 +++++++++++++---- admin/code/ModelAdmin.php | 3 +-- admin/code/SecurityAdmin.php | 3 +-- forms/Form.php | 36 ++++++++++++++++++++++++- 5 files changed, 56 insertions(+), 62 deletions(-) delete mode 100644 admin/code/CMSForm.php diff --git a/admin/code/CMSForm.php b/admin/code/CMSForm.php deleted file mode 100644 index 563a2d821..000000000 --- a/admin/code/CMSForm.php +++ /dev/null @@ -1,52 +0,0 @@ -getRequest(); - $negotiator = $this->getResponseNegotiator(); - - if($request->isAjax() && $negotiator) { - $this->setupFormErrors(); - $result = $this->forTemplate(); - - return $negotiator->respond($request, array( - 'CurrentForm' => function() use($result) { - return $result; - } - )); - } else { - return parent::getValidationErrorResponse(); - } - } - - /** - * Sets the response negotiator - * @param ResponseNegotiator $negotiator The response negotiator to use - * @return Form The current form - */ - public function setResponseNegotiator($negotiator) { - $this->responseNegotiator = $negotiator; - return $this; - } - - /** - * Gets the current response negotiator - * @return ResponseNegotiator|null - */ - public function getResponseNegotiator() { - return $this->responseNegotiator; - } - -} diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php index a64dac490..f20e5aceb 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -13,6 +13,8 @@ use SilverStripe\Forms\Schema\FormSchema; * * This is essentially an abstract class which should be subclassed. * See {@link CMSMain} for a good example. + * + * @property FormSchema $schema */ class LeftAndMain extends Controller implements PermissionProvider { @@ -197,7 +199,7 @@ class LeftAndMain extends Controller implements PermissionProvider { } // Make sure it's an AJAX GET request with a valid "X-Formschema-Request" header value. - if (!$req->isAjax() || !$req->isGET() || !count($schemaParts)) { + if (!$req->isGET() || !count($schemaParts)) { throw new SS_HTTPResponse_Exception( 'Invalid request. Check you\'ve set a "X-Formschema-Request" header with "schema" or "state" values.', 400 @@ -1312,14 +1314,27 @@ class LeftAndMain extends Controller implements PermissionProvider { $actionsFlattened = $actions->dataFields(); if($actionsFlattened) foreach($actionsFlattened as $action) $action->setUseButtonTag(true); - $form = CMSForm::create( + $negotiator = $this->getResponseNegotiator(); + $form = Form::create( $this, "EditForm", $fields, $actions )->setHTMLID('Form_EditForm'); - $form->setResponseNegotiator($this->getResponseNegotiator()); $form->addExtraClass('cms-edit-form'); $form->loadDataFrom($record); $form->setTemplate($this->getTemplatesWithSuffix('_EditForm')); $form->setAttribute('data-pjax-fragment', 'CurrentForm'); + $form->setValidationResponseCallback(function() use ($negotiator, $form) { + $request = $this->getRequest(); + if($request->isAjax() && $negotiator) { + $form->setupFormErrors(); + $result = $form->forTemplate(); + + return $negotiator->respond($request, array( + 'CurrentForm' => function() use($result) { + return $result; + } + )); + } + }); // Announce the capability so the frontend can decide whether to allow preview or not. if(in_array('CMSPreviewable', class_implements($record))) { @@ -1368,7 +1383,7 @@ class LeftAndMain extends Controller implements PermissionProvider { * @return Form */ public function EmptyForm() { - $form = CMSForm::create( + $form = Form::create( $this, "EditForm", new FieldList( @@ -1387,7 +1402,6 @@ class LeftAndMain extends Controller implements PermissionProvider { ), new FieldList() )->setHTMLID('Form_EditForm'); - $form->setResponseNegotiator($this->getResponseNegotiator()); $form->unsetValidator(); $form->addExtraClass('cms-edit-form'); $form->addExtraClass('root-form'); diff --git a/admin/code/ModelAdmin.php b/admin/code/ModelAdmin.php index cc41d7bce..3ff8ef89c 100644 --- a/admin/code/ModelAdmin.php +++ b/admin/code/ModelAdmin.php @@ -138,13 +138,12 @@ abstract class ModelAdmin extends LeftAndMain { $listField->getConfig()->getComponentByType('GridFieldDetailForm')->setValidator($detailValidator); } - $form = CMSForm::create( + $form = Form::create( $this, 'EditForm', new FieldList($listField), new FieldList() )->setHTMLID('Form_EditForm'); - $form->setResponseNegotiator($this->getResponseNegotiator()); $form->addExtraClass('cms-edit-form cms-panel-padded center'); $form->setTemplate($this->getTemplatesWithSuffix('_EditForm')); $editFormAction = Controller::join_links($this->Link($this->sanitiseClassName($this->modelClass)), 'EditForm'); diff --git a/admin/code/SecurityAdmin.php b/admin/code/SecurityAdmin.php index cfbf4fe3d..923f9d4f9 100755 --- a/admin/code/SecurityAdmin.php +++ b/admin/code/SecurityAdmin.php @@ -177,13 +177,12 @@ class SecurityAdmin extends LeftAndMain implements PermissionProvider { $actions = new FieldList(); - $form = CMSForm::create( + $form = Form::create( $this, 'EditForm', $fields, $actions )->setHTMLID('Form_EditForm'); - $form->setResponseNegotiator($this->getResponseNegotiator()); $form->addExtraClass('cms-edit-form'); $form->setTemplate($this->getTemplatesWithSuffix('_EditForm')); // Tab nav in CMS is rendered through separate template diff --git a/forms/Form.php b/forms/Form.php index 112d8b3b7..0523c14a4 100644 --- a/forms/Form.php +++ b/forms/Form.php @@ -79,6 +79,11 @@ class Form extends RequestHandler { */ protected $validator; + /** + * @var callable {@see setValidationResponseCallback()} + */ + protected $validationResponseCallback; + /** * @var string */ @@ -479,16 +484,45 @@ class Form extends RequestHandler { ); } + /** + * @return callable + */ + public function getValidationResponseCallback() { + return $this->validationResponseCallback; + } + + /** + * Overrules validation error behaviour in {@link httpSubmission()} + * when validation has failed. Useful for optional handling of a certain accepted content type. + * + * The callback can opt out of handling specific responses by returning NULL, + * in which case the default form behaviour will kick in. + * + * @param $callback + * @return self + */ + public function setValidationResponseCallback($callback) { + $this->validationResponseCallback = $callback; + + return $this; + } + /** * Returns the appropriate response up the controller chain * if {@link validate()} fails (which is checked prior to executing any form actions). * By default, returns different views for ajax/non-ajax request, and * handles 'application/json' requests with a JSON object containing the error messages. - * Behaviour can be influenced by setting {@link $redirectToFormOnValidationError}. + * Behaviour can be influenced by setting {@link $redirectToFormOnValidationError}, + * and can be overruled by setting {@link $validationResponseCallback}. * * @return SS_HTTPResponse|string */ protected function getValidationErrorResponse() { + $callback = $this->getValidationResponseCallback(); + if($callback && $callbackResponse = $callback()) { + return $callbackResponse; + } + $request = $this->getRequest(); if($request->isAjax()) { // Special case for legacy Validator.js implementation From e1fcc64c41c6e2c44f7a088b72f0c0fb5577f12f Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Tue, 1 Mar 2016 16:15:47 +1300 Subject: [PATCH 4/5] Form field schema state See https://github.com/silverstripe/silverstripe-framework/issues/4938 --- forms/FormField.php | 15 ------ forms/FormFieldSchemaTrait.php | 84 +++++++++++++++++++++++++++++++++- forms/FormSchema.php | 13 +++++- tests/forms/FormFieldTest.php | 31 +++++++++++++ tests/forms/FormSchemaTest.php | 78 ++++++++++++++++++++++++++++++- 5 files changed, 203 insertions(+), 18 deletions(-) diff --git a/forms/FormField.php b/forms/FormField.php index 239dba2cb..e324ff78a 100644 --- a/forms/FormField.php +++ b/forms/FormField.php @@ -166,21 +166,6 @@ class FormField extends RequestHandler { */ protected $attributes = []; - /** - * The type of front-end component to render the FormField as. - * - * @var string - */ - protected $schemaComponent; - - /** - * Structured schema data representing the FormField. - * Used to render the FormField as a ReactJS Component on the front-end. - * - * @var array - */ - protected $schemaData = []; - /** * Takes a field name and converts camelcase to spaced words. Also resolves combined field * names with dot syntax to spaced words. diff --git a/forms/FormFieldSchemaTrait.php b/forms/FormFieldSchemaTrait.php index f78209f5f..ab9e40115 100644 --- a/forms/FormFieldSchemaTrait.php +++ b/forms/FormFieldSchemaTrait.php @@ -4,6 +4,29 @@ namespace SilverStripe\Forms\Schema; trait FormFieldSchemaTrait { + /** + * The type of front-end component to render the FormField as. + * + * @var string + */ + protected $schemaComponent; + + /** + * Structured schema data representing the FormField. + * Used to render the FormField as a ReactJS Component on the front-end. + * + * @var array + */ + protected $schemaData = []; + + /** + * Structured schema state representing the FormField's current data and validation. + * Used to render the FormField as a ReactJS Component on the front-end. + * + * @var array + */ + protected $schemaState = []; + /** * Sets the component type the FormField will be rendered as on the front-end. * @@ -58,7 +81,7 @@ trait FormFieldSchemaTrait { * * @return array */ - function getSchemaDataDefaults() { + public function getSchemaDataDefaults() { return [ 'type' => $this->class, 'component' => $this->getSchemaComponent(), @@ -78,4 +101,63 @@ trait FormFieldSchemaTrait { 'data' => [], ]; } + + /** + * Sets the schema data used for rendering the field on the front-end. + * Merges the passed array with the current `$schemaData` or {@link getSchemaDataDefaults()}. + * Any passed keys that are not defined in {@link getSchemaDataDefaults()} are ignored. + * If you want to pass around ad hoc data use the `data` array e.g. pass `['data' => ['myCustomKey' => 'yolo']]`. + * + * @param array $schemaData - The data to be merged with $this->schemaData. + * @return FormField + * + * @todo Add deep merging of arrays like `data` and `attributes`. + */ + public function setSchemaState($schemaState = []) { + $current = $this->getSchemaState(); + + $this->schemaState = array_merge($current, array_intersect_key($schemaState, $current)); + return $this; + } + + /** + * Gets the schema state used to render the FormField on the front-end. + * + * @return array + */ + public function getSchemaState() { + return array_merge($this->getSchemaStateDefaults(), $this->schemaState); + } + + /** + * Gets the defaults for $schemaState. + * The keys defined here are immutable, meaning undefined keys passed to {@link setSchemaState()} are ignored. + * Instead the `data` array should be used to pass around ad hoc data. + * Includes validation data if the field is associated to a {@link Form}, + * and {@link Form->validate()} has been called. + * + * @return array + */ + public function getSchemaStateDefaults() { + $field = $this; + $form = $this->getForm(); + $validator = $form ? $form->getValidator() : null; + $errors = $validator ? (array)$validator->getErrors() : []; + $messages = array_filter(array_map(function($error) use ($field) { + if($error['fieldName'] === $field->getName()) { + return [ + 'value' => $error['message'], + 'type' => $error['messageType'] + ]; + } + }, $errors)); + + return [ + 'id' => $this->ID(), + 'value' => $this->Value(), + 'valid' => (count($messages) === 0), + 'messages' => (array)$messages, + 'data' => [], + ]; + } } diff --git a/forms/FormSchema.php b/forms/FormSchema.php index 2b7335b70..17307193f 100644 --- a/forms/FormSchema.php +++ b/forms/FormSchema.php @@ -45,7 +45,7 @@ class FormSchema { /** * Gets the current state of this form as a nested array. * - * @param From $form + * @param Form $form * @return array */ public function getState(Form $form) { @@ -55,6 +55,17 @@ class FormSchema { 'messages' => [] ]; + foreach ($form->Fields()->dataFields() as $field) { + $state['fields'][] = $field->getSchemaState(); + } + + if($form->Message()) { + $state['messages'][] = [ + 'value' => $form->Message(), + 'type' => $form->MessageType(), + ]; + } + return $state; } } diff --git a/tests/forms/FormFieldTest.php b/tests/forms/FormFieldTest.php index 7ab132327..b2f235fc5 100644 --- a/tests/forms/FormFieldTest.php +++ b/tests/forms/FormFieldTest.php @@ -278,6 +278,37 @@ class FormFieldTest extends SapphireTest { $schema = $field->getSchemaData(); $this->assertEquals(array_key_exists('myCustomKey', $schema), false); } + + public function testGetSchemaState() { + $field = new FormField('MyField'); + $field->setValue('My value'); + $schema = $field->getSchemaState(); + $this->assertEquals('My value', $schema['value']); + } + + public function testSetSchemaState() { + $field = new FormField('MyField'); + + // Make sure the user can update values. + $field = $field->setSchemaState(['value' => 'My custom value']); + $schema = $field->getSchemaState(); + $this->assertEquals($schema['value'], 'My custom value'); + + // Make user the user can't define custom keys on the schema. + $field = $field->setSchemaState(['myCustomKey' => 'yolo']); + $schema = $field->getSchemaState(); + $this->assertEquals(array_key_exists('myCustomKey', $schema), false); + } + + public function testGetSchemaStateWithFormValidation() { + $field = new FormField('MyField'); + $validator = new RequiredFields('MyField'); + $form = new Form(new Controller(), 'TestForm', new FieldList($field), new FieldList(), $validator); + $validator->validationError('MyField', 'Something is wrong', 'error'); + $schema = $field->getSchemaState(); + $this->assertEquals(count($schema['messages']), 1); + $this->assertEquals('Something is wrong', $schema['messages'][0]['value']); + } } /** diff --git a/tests/forms/FormSchemaTest.php b/tests/forms/FormSchemaTest.php index 3aaf36533..4cf1d5564 100644 --- a/tests/forms/FormSchemaTest.php +++ b/tests/forms/FormSchemaTest.php @@ -55,7 +55,83 @@ class FormSchemaTest extends SapphireTest { $formSchema = new FormSchema(); $expected = [ 'id' => 'TestForm', - 'fields' => [], + 'fields' => [ + [ + 'id' => 'Form_TestForm_SecurityID', + 'value' => $form->getSecurityToken()->getValue(), + 'messages' => [], + 'valid' => true, + 'data' => [] + ] + ], + 'messages' => [] + ]; + + $state = $formSchema->getState($form); + $this->assertInternalType('array', $state); + $this->assertJsonStringEqualsJsonString(json_encode($expected), json_encode($state)); + } + + public function testGetStateWithFormMessages() { + $fields = new FieldList(); + $actions = new FieldList(); + $form = new Form(new Controller(), 'TestForm', $fields, $actions); + $form->sessionMessage('All saved', 'good'); + $formSchema = new FormSchema(); + $expected = [ + 'id' => 'TestForm', + 'fields' => [ + [ + 'id' => 'Form_TestForm_SecurityID', + 'value' => $form->getSecurityToken()->getValue(), + 'messages' => [], + 'valid' => true, + 'data' => [] + ] + ], + 'messages' => [ + [ + 'value' => 'All saved', + 'type' => 'good' + ] + ] + ]; + + $state = $formSchema->getState($form); + $this->assertInternalType('array', $state); + $this->assertJsonStringEqualsJsonString(json_encode($expected), json_encode($state)); + } + + public function testGetStateWithFieldValidationErrors() { + $fields = new FieldList(new TextField('Title')); + $actions = new FieldList(); + $validator = new RequiredFields('Title'); + $form = new Form(new Controller(), 'TestForm', $fields, $actions, $validator); + $form->loadDataFrom([ + 'Title' => 'My Title' + ]); + $validator->validationError('Title', 'Title is invalid', 'error'); + $formSchema = new FormSchema(); + $expected = [ + 'id' => 'TestForm', + 'fields' => [ + [ + 'id' => 'Form_TestForm_Title', + 'value' => 'My Title', + 'messages' => [ + ['value' => 'Title is invalid', 'type' => 'error'] + ], + 'valid' => false, + 'data' => [] + ], + [ + 'id' => 'Form_TestForm_SecurityID', + 'value' => $form->getSecurityToken()->getValue(), + 'messages' => [], + 'valid' => true, + 'data' => [] + ] + ], 'messages' => [] ]; From 61a8529d7622184785dea35bd6cae78215d234fd Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Thu, 3 Mar 2016 12:03:38 +1300 Subject: [PATCH 5/5] More reuseable FormSchema logic, return on save --- admin/code/LeftAndMain.php | 69 ++++++++++++++++++++++++++-------- forms/FormFieldSchemaTrait.php | 11 ++++++ forms/FormSchema.php | 8 ++++ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php index f20e5aceb..cdeae2a36 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -181,46 +181,75 @@ class LeftAndMain extends Controller implements PermissionProvider { /** * Gets a JSON schema representing the current edit form. * + * WARNING: Experimental API. + * * @return SS_HTTPResponse */ - public function schema() { - $req = $this->getRequest(); - $res = $this->getResponse(); + public function schema($request) { + $response = $this->getResponse(); + $formName = $request->param('ID'); + + if(!$this->hasMethod("get{$formName}")) { + throw new SS_HTTPResponse_Exception( + 'Form not found', + 400 + ); + } + + if(!$this->hasAction($formName)) { + throw new SS_HTTPResponse_Exception( + 'Form not accessible', + 401 + ); + } + + $form = $this->{"get{$formName}"}(); + $response->addHeader('Content-Type', 'application/json'); + $response->setBody(Convert::raw2json($this->getSchemaForForm($form))); + + return $response; + } + + /** + * Returns a representation of the provided {@link Form} as structured data, + * based on the request data. + * + * @param Form $form + * @return array + */ + protected function getSchemaForForm(Form $form) { + $request = $this->getRequest(); $schemaParts = []; + $return = null; // Valid values for the "X-Formschema-Request" header are "schema" and "state". // If either of these values are set they will be stored in the $schemaParst array // and used to construct the response body. - if ($schemaHeader = $req->getHeader('X-Formschema-Request')) { + if ($schemaHeader = $request->getHeader('X-Formschema-Request')) { $schemaParts = array_filter(explode(',', $schemaHeader), function($value) { $validHeaderValues = ['schema', 'state']; return in_array(trim($value), $validHeaderValues); }); } - // Make sure it's an AJAX GET request with a valid "X-Formschema-Request" header value. - if (!$req->isGET() || !count($schemaParts)) { + if (!count($schemaParts)) { throw new SS_HTTPResponse_Exception( 'Invalid request. Check you\'ve set a "X-Formschema-Request" header with "schema" or "state" values.', 400 ); } - $form = $this->getEditForm(); - $responseBody = ['id' => $form->getName()]; + $return = ['id' => $form->getName()]; if (in_array('schema', $schemaParts)) { - $responseBody['schema'] = $this->schema->getSchema($form); + $return['schema'] = $this->schema->getSchema($form); } if (in_array('state', $schemaParts)) { - $responseBody['state'] = $this->schema->getState($form); + $return['state'] = $this->schema->getState($form); } - $res->addHeader('Content-Type', 'application/json'); - $res->setBody(Convert::raw2json($responseBody)); - - return $res; + return $return; } /** @@ -1067,6 +1096,7 @@ class LeftAndMain extends Controller implements PermissionProvider { * Save handler */ public function save($data, $form) { + $request = $this->getRequest(); $className = $this->stat('tree_class'); // Existing or new record? @@ -1087,7 +1117,16 @@ class LeftAndMain extends Controller implements PermissionProvider { $this->setCurrentPageID($record->ID); $this->getResponse()->addHeader('X-Status', rawurlencode(_t('LeftAndMain.SAVEDUP', 'Saved.'))); - return $this->getResponseNegotiator()->respond($this->getRequest()); + + if($request->getHeader('X-Formschema-Request')) { + $data = $this->getSchemaForForm($form); + $response = new SS_HTTPResponse(Convert::raw2json($data)); + $response->addHeader('Content-Type', 'application/json'); + } else { + $response = $this->getResponseNegotiator()->respond($request); + } + + return $response; } public function delete($data, $form) { diff --git a/forms/FormFieldSchemaTrait.php b/forms/FormFieldSchemaTrait.php index ab9e40115..f4b3e09d9 100644 --- a/forms/FormFieldSchemaTrait.php +++ b/forms/FormFieldSchemaTrait.php @@ -2,6 +2,17 @@ namespace SilverStripe\Forms\Schema; +/** + * Class FormFieldSchemaTrait + * @package SilverStripe\Forms\Schema + * + * Allows {@link FormField} to be represented as structured data, + * including both structure (name, id, attributes, etc.) and state (field value). + * Can be used by {@link FormSchema} to represent a form in JSON, + * to be consumed by a front-end application. + * + * WARNING: Experimental API. + */ trait FormFieldSchemaTrait { /** diff --git a/forms/FormSchema.php b/forms/FormSchema.php index 17307193f..9f2c00dcf 100644 --- a/forms/FormSchema.php +++ b/forms/FormSchema.php @@ -5,6 +5,14 @@ namespace SilverStripe\Forms\Schema; use Convert; use Form; +/** + * Class FormSchema + * @package SilverStripe\Forms\Schema + * + * Represents a {@link Form} as structured data which allows a frontend library to render it. + * Includes information about the form as well as its fields. + * Can create a "schema" (structure only) as well as "state" (data only). + */ class FormSchema { /**