Merge pull request #5132 from open-sausages/features/form-schema

API Form schema
This commit is contained in:
Damian Mooyman 2016-03-07 14:08:36 +13:00
commit 6d88caa9b3
11 changed files with 619 additions and 66 deletions

View File

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

View File

@ -1,52 +0,0 @@
<?php
/**
* Deals with special form handling in CMS, mainly around
* {@link PjaxResponseNegotiator}
*
* @package framework
* @subpackage admin
*/
class CMSForm extends Form {
/**
* Route validation error responses through response negotiator,
* so they return the correct markup as expected by the requesting client.
*/
protected function getValidationErrorResponse() {
$request = $this->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;
}
}

View File

@ -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');

View File

@ -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');

View File

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

View File

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

View File

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

View File

@ -0,0 +1,174 @@
<?php
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 {
/**
* 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.
*
* @param string $componentType
* @return FormField
*/
public function setSchemaComponent($componentType) {
$this->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' => [],
];
}
}

79
forms/FormSchema.php Normal file
View File

@ -0,0 +1,79 @@
<?php
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 {
/**
* Gets the schema for this form as a nested array.
*
* @param Form $form
* @return array
*/
public function getSchema(Form $form) {
$request = $form->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;
}
}

View File

@ -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']);
}
}
/**

View File

@ -0,0 +1,142 @@
<?php
use SilverStripe\Forms\Schema\FormSchema;
class FormSchemaTest extends SapphireTest {
public function testGetSchema() {
$form = new Form(new Controller(), 'TestForm', new FieldList(), new FieldList());
$formSchema = new FormSchema();
$expected = [
'name' => '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));
}
}