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/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 f1073a6be..c85a33925 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -5,12 +5,16 @@ * @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. * * 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 { @@ -84,7 +88,7 @@ class LeftAndMain extends Controller implements PermissionProvider { /** * @var array */ - private static $allowed_actions = array( + private static $allowed_actions = [ 'index', 'save', 'savetreenode', @@ -97,7 +101,12 @@ class LeftAndMain extends Controller implements PermissionProvider { 'AddForm', 'batchactions', 'BatchActionsForm', - ); + 'schema', + ]; + + private static $dependencies = [ + 'schema' => '%$FormSchema' + ]; /** * @config @@ -169,6 +178,80 @@ class LeftAndMain extends Controller implements PermissionProvider { */ protected $responseNegotiator; + /** + * Gets a JSON schema representing the current edit form. + * + * WARNING: Experimental API. + * + * @return SS_HTTPResponse + */ + 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 = $request->getHeader('X-Formschema-Request')) { + $schemaParts = array_filter(explode(',', $schemaHeader), function($value) { + $validHeaderValues = ['schema', 'state']; + return in_array(trim($value), $validHeaderValues); + }); + } + + 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 + ); + } + + $return = ['id' => $form->getName()]; + + if (in_array('schema', $schemaParts)) { + $return['schema'] = $this->schema->getSchema($form); + } + + if (in_array('state', $schemaParts)) { + $return['state'] = $this->schema->getState($form); + } + + return $return; + } + /** * @param Member $member * @return boolean @@ -1013,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? @@ -1033,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) { @@ -1260,14 +1353,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))) { @@ -1318,7 +1424,7 @@ class LeftAndMain extends Controller implements PermissionProvider { * @return Form */ public function EmptyForm() { - $form = CMSForm::create( + $form = Form::create( $this, "EditForm", new FieldList( @@ -1337,7 +1443,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 diff --git a/forms/FormField.php b/forms/FormField.php index f041af1f8..e324ff78a 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,7 @@ class FormField extends RequestHandler { * * @var array */ - protected $attributes = array(); + protected $attributes = []; /** * 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..f4b3e09d9 --- /dev/null +++ b/forms/FormFieldSchemaTrait.php @@ -0,0 +1,174 @@ +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 + */ + public 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' => [], + ]; + } + + /** + * 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 new file mode 100644 index 000000000..9f2c00dcf --- /dev/null +++ b/forms/FormSchema.php @@ -0,0 +1,79 @@ +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 $schema; + } + + /** + * Gets the current state of this form as a nested array. + * + * @param Form $form + * @return array + */ + public function getState(Form $form) { + $state = [ + 'id' => $form->getName(), + 'fields' => [], + '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 221091dad..b2f235fc5 100644 --- a/tests/forms/FormFieldTest.php +++ b/tests/forms/FormFieldTest.php @@ -241,6 +241,74 @@ 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); + } + + 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 new file mode 100644 index 000000000..4cf1d5564 --- /dev/null +++ b/tests/forms/FormSchemaTest.php @@ -0,0 +1,142 @@ + '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->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(); + $expected = [ + 'id' => 'TestForm', + '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' => [] + ]; + + $state = $formSchema->getState($form); + $this->assertInternalType('array', $state); + $this->assertJsonStringEqualsJsonString(json_encode($expected), json_encode($state)); + } +}