Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: FormField React Integration API #4938

Closed
tractorcow opened this issue Jan 18, 2016 · 6 comments
Closed

RFC: FormField React Integration API #4938

tractorcow opened this issue Jan 18, 2016 · 6 comments

Comments

@tractorcow
Copy link
Contributor

tractorcow commented Jan 18, 2016

https://groups.google.com/forum/#!topic/silverstripe-dev/bHE7sIK-fV8
#1. Introduction

1.1. Problem

When implementing React based front-end components, there is limited ability for forms declared via PHP to influence the front end. Likewise, the development of custom components used in front-end React based forms would require re-implementation of that behaviour via the traditional SilverStripe Forms API.

Since a key benefit of SilverStripe is that it is easy to tailor the UI to fit your site’s custom data model, this isn’t ideal.

It results in a bottleneck in development, where React forms cannot be developed effectively by developers who do not have both a strong front and back-end expertise, as well as the patience and time to solve several integration issues. It also means that client and server code is tightly coupled. Creating a new form field UI shouldn't necessitate creating a PHP subclass. And creating a new form field in PHP shouldn't require developers to create a new React component. The outcomes of this are slower development time, less flexibility, and buggy applications.

1.2. Goals

The goal of this RFC is to present a solution that addresses:

  • Ability for front-end developers to build custom React form components with minimal back-end development work.
  • Ability for back-end developers to build custom forms with no front-end React development work.
  • Ability for developers to implement custom back end form components which may rely on custom front-end components.
  • Define a standard schema for both the declaration, and error processing of form validation rules.
  • Ensure that any FormField components are available to custom extensions on DataObjects, or subclasses of Forms.
  • The solution must be usable within both the CMS and public facing site.
  • The standard form API must work without react enabled, even if components are available
  • Standard form security rules (CSRF / XSS protection, redirection validation, input validation, etc) must be enforced.
  • Maintain separation of schema (structure) from content (current state).
  • Existing mechanisms (e.g. form postback) should be used where possible.

1.3. Proposed Solution

We propose that we create an intermediary data schema, in order to map an abstract representation of the form back-end, to the front-end rendering mechanism. This schema will be JSON structured data which can be extracted both from Form objects, as well as individual FormField instances.

The solution is based on an experimental CMS prototype ("Project Origami") developed at SilverStripe Ltd. https://github.com/silverstripe-supervillains/silverstripe-origami/blob/ac573256c0396f5fb5cf25f46625993651e14f55/code/extensions/OrigamiFormFieldExtension.php
Note: The intention of this prototype was to explore concepts rather than form the basis for a new CMS frontend and backend implementation.

All FormFields will be classified at two levels, in ways that can be interpreted by the front-end:

  • FieldType: At the base data level, this will correspond closely with the underlying DBField or DB Relationship backing each field. The list of values will be limited to a set enumeration.
  • FieldComponent: At the custom template level, this will correspond to a FormField's preferred form control in the front-end. For instance, a multi-select form could be implemented either via a multi select list, or a set of checkboxes. This value is optional, and if omitted will be substituted by the default form control for the given FieldType.

On the front-end, at least one default component will be declared for each FieldType, ensuring that any back-end form which follows this schema will be renderable.

In addition, multiple components can be built for each FieldType, to allow back-end form fields to further customise their appearance via the FieldComponent value.

Although the intention for this solution is to provide compatibility with React-based front-end components in a SilverStripe CMS context, it should also be usable with other javascript frameworks which respect the same schema in other contexts, as well as other front-end renderers such as native mobile apps
#2. Implementation

2.1. Form Field schema PHP API

FormField front-end schema will be applied to the existing Forms API via a set of standard injectable dependencies.

  • All back-end components exclusive to building compatibility with front-end frameworks will be under the SilverStripe\Forms\Schema namespace.
  • Core form API classes not specific to front-end integration (such as those used to render forms server-side) will be under the SilverStripe\Forms namespace.

FormSchema.php

Used by LeftAndMain to return state / schema for a form.

<?php

namespace SilverStripe\Forms\Schema;

class FormSchema {

    /**
     * Return the form schema for this form as a nested array.
     */
    public function getSchema(Form $form);

    /**
     * Retrieves the current state of this form as a nested array.
     * E.g. current value, errors
     */
   public function getState(Form $form);
}

LeftAndMain.php

URLs such as http://localhost/admin/modeladmin/edit/schema/1 (schema to edit object ID = '1') or http://ss40test.loc/admin/modeladmin/edit/schema (schema for creating a new record) can be passed to the frontend application.

Invoking this url will return a json formatted data structure with the top level attributes "schema" for the form schema, and "state" for the initial state of the form.

<?php

class LeftAndMain extends Controller {
    private static $allowed_actions = ['schema'];
    private static $dependencies = ['Schema' => '%$FormSchema'];

    /** @return SS_HTTPResponse containing schema / state as JSON encoded data */
    public function schema() {
        // Will have extra logic here
        return $this->Schema()->getSchema($this->getEditForm());
    }

    public function getEditForm() {
        // …
        // Replaces setResponseNegotiator()
        $form->setValidationResponseCallback($callback);
        // ...
    }
}

Form.php

CMSForm.php will be removed, and custom logic in Form::getValidationErrorResponse will allow a callback to be specified (injected via LeftAndMain.php).

<?php

class Form extends RequestHandler {

    protected $validationResponseCallback;

    public function getValidationResponseCallback() {
        return $this->validationResponseCallback;
    }

    public function setValidationResponseCallback($callback) {
        $this->validationResponseCallback = $callback;
    }

    protected function getValidationErrorResponse() {
        $callback = $this->getValidationResponseCallback();
        if($callback) {
            return $callback();
        }
        // … existing logic
    }
}

FormField.php

In order to support user-code specification of field-specific options, some kind of api will be required on FormField.

Options are listed below in descending order of preference, with Option A being our preferred.

Option A - Option config

A setOption / getOption generic API will be added to FormField.php. This will be used, although not exclusively, by FormSchema to determine (for instance) the specific component to use for any one field.

<?php

class FormField extends RequestHandler {
    protected $options = array();
    public function getOption($option);
    public function setOption($option, $value);
}

For instance, when setting a custom template for a text field you could use the below API in your getCMSFields method:

<?php

use SilverStripe\Forms\Schema\FormSchema;

class MyObject extends DataObject {
    public function getCMSFields() {
        $fields = new FieldList();
        $fields->push(
            TextField::create("Name")
                ->setOption(FormSchema::COMPONENT, 'MyCustomComponent')
        );
        return $fields;
    }
}

Option B - Explicit schema getters / setters

Rather than add a generic options api, explicit getters or setters could be specified for each option. Eg...

  • setSchemaComponent
  • setSchemaData

Option C - Custom html attributes

Instead of adding a new mechanism, use get/setAttributes to hold custom values for the above options.

The weakness in this approach is that it will render any values specified via data- attributes in the resulting HTML.

Option D - Require custom subclasses

Rather than exposing additional customisation mechanisms to user-code, front-end customisation of formfields must instead be done via subclassing of FormFields.

E.g.

<?php

class CustomField extends TextField {
    public function getSchemaComponent() {
        return 'MyCustomComponent';
    }
}

2.2. Schema Declaration

This declaration is based on the experimental schema declared at https://github.com/open-sausages/reactjs-prototype/tree/experiment/form-field-schema

2.2.1. Form schema

This JSON structure represents the schema of the form as a whole. Note that the schema of individual FormFields is included within the "fields" property of the containing form declaration, as well as "children" .

All content in this form is considered cacheable for the purposes of rendering that object's form in the future.

{
  "name": "TheForm",
  "id": "TheForm",
  "action": "http://ss32test.loc/admin/pages/edit/1/EditForm/",
  "method": "POST",
  "schema_url": "http://ss32test.loc/admin/pages/edit/1/EditForm/schema",
  "attributes": {
    "key": "value"
  },
  "data": {
    "key": "value"
  },
  "fields": [
    {
      "type": "Text",
      "component": "",
      "id": "TheForm_TheField",
      "holder_id": "TheForm_TheField_Holder",
      "name": "TheField",
      "title": "",
      "source": [
        {
          "value": "1",
          "title": "Option One",
          "disabled": false
        }
      ],
      "extraClass": "",
      "description": "",
      "rightTitle": "",
      "leftTitle": "",
      "description": "",
      "readOnly": false,
      "disabled": false,
      "customValidationMessage": "",
      "attributes": {
        "key": "value"
      },
      "data": {
        "key": "value"
      }
    },
    {
      "type": "Tabs",
      "name": "Tabs",
      "type": "Structural",
      "component": "TabSet",
      "children": [
        {
          "Name": "Content",
          "type": "Structural",
          "component": "Tab",
          "children": [
            {
              "name": "NestedField"
              "type": "Text",
            }
          ]
        },
        {
          "Name": "Settings",
          "type": "Structural",
          "component": "Tab"
        }
      ]
    }
  ],
  "actions": [
    {
      "id": "TheForm_Publish",
      "name": "action_publish",
      "type": "submit",
      "title": "Publish",
      "extraClass": "",
      "readOnly": false,
      "disabled": false,
      "attributes": []
    }
  ]
}

Notes on the above properties:

  • id and holder_id should respect the naming conventions of core silverstripe form fields.
  • attributes property may be used for passing custom properties which should be added to the FormField. Attributes specified in the field schema is considered fixed, and may not be overridden by the field state. (see below).
  • data can be used to pass custom properties (including nested json content) which might be useful for the form or FormField elements, but is not intended to be embedded directly into the html. e.g. callbacks for dynamically generated lists, or for setting expected content/type, etc.. Data specified in the field schema is considered fixed, and may not be overridden by the field state. (see below).
  • children property is typically used by composite fields which simply wrap other form fields. E.g. FieldGroup, TabSet.
  • type of each field corresponds to the FieldType, and represents the data type backing that field, and represents the type of value the form expects to receive via a postback. The values allowed in this list include:
    • String - Single line text
    • Hidden - Hidden field which is posted back without modification
    • Text - Multi line text
    • HTML - Rich html text
    • Integer - Whole number value
    • Decimal - Decimal value
    • MultiSelect - Select many from source
    • SingleSelect - Select one from source
    • Date - Date only
    • DateTime - Date and time
    • Time - Time only
    • Boolean - Yes or no
    • Custom - Custom type declared by the front-end component. For fields with a type: Custom, the component property is mandatory, and will determine the posted value for this field.
    • Structural - Represents a field that is NOT posted back. This may contain other fields, or simply be a block of stand-alone content. As with 'Custom', the component property is mandatory if this is assigned.
  • component Determines the the front-end component that should explicitly be used for this FormField. In most cases it's not necessary to provide this, as a default value for this field can normally be inferred from the field's type. However, this property is mandatory if the type is either 'Custom' or 'Structural'. Note that the component can be customised via dependency injection in frontend code, e.g. if a third-party module wants to replace all date pickers. In the cases that, for instance, Multi/SingleSelect fields may be developed for specific data types, then it is the responsibility of the custom component to provide that model-specific behaviour. E.g. Managing a list of images, beyond that provided by simple model relation editing field.

2.2.2. Form state

In addition to form schema, current state of the form must be representable on demand. This content is not cacheable, and covers:

  • Pre-set field values (including defaults and user-assigned values)
  • Error messages
  • State (rejected, valid, new)
  • Redirect requests

The schema for the current form state is as below:

{
  "id": "TheForm",
  "fields": [
    {
      "id": "TheForm_TheField",
      "value": "field value",
      "message": {
        "value": "text message",
        "type": "error",
        "extraClass": "customClass"
      },
      "valid": false,
      "data": {
        "key": "value"
      }
    }
  ],
  "messages": [
    {
      "value": "text message",
      "type": "error",
      "extraClass": "customClass"
    }
  ]
}

Notes on the above properties:

  • id property is used to uniquely identify both form and form fields
  • *_messages.type *_valid values include "message", "error", "info", "warning".
  • data Specified in the form state includes data which should be used in addition to the fixed data (as specified in the form schema), but values which are specific to the current state. For instance, thumbnail preview urls for a selected image.

2.2.3. Routing behaviour

The above schema / state information will be emitted to the front end application in the following use cases:

  • On initial load (when editing or creating a new record). This behaviour is provided by the 'schema' action on 'LeftAndMain'.
  • On submission when a validation issue changes the form state. This behaviour is provided by a custom getValidationErrorResponse method on Form (which is injected with a custom callback by LeftAndMain).
  • Result returned (potentially) on success by custom actions on LeftAndMain.

What is actually returned from each of the above actions is determined by the value of X-FormSchema-Request http header. This header is a comma separated list (similar to X-Pjax) which lists one or both of 'schema' or 'state'. The resulting http response generated will return the requested value in the format below:

{
    "id": "TheForm",
    "state": {},
    "schema": {}
}

If only one of 'state' or 'schema' is requested, then no value for the omitted property will be returned.

In addition, the front-end application should respect responses containing the "Location" header, and should redirect the user (potentially discarding the current form) if requested.

2.2.4. Form validation declaration

Rules for form validation (e.g. for declaring certain fields as accepting urls only) is explicitly not declared as a part of this schema. It's possible in the future that a concise validation definition schema could be developed.

Within the bounds of the current schema, the following field attributes could still be explicitly assigned to make use of built in HTML5 field validation:

  • aria-required
  • required
  • min
  • max
  • type (email, text, etc)

All other validation must still be performed and respected on the server-side during form postback.

2.2.5. Structural elements

Structural elements include (but are not limited to):

  • Labels
  • Field groups
  • Tabs and Tabsets
  • Headers
  • Literal HTML blocks

While structural elements may contain other data elements, they themselves have no underlying data property.

These field should have the type property specified as Structural, and must specify explicitly the front-end component used to represent it. E.g. 'Header', 'HTMLBlock', 'FieldGroup'.

Structural elements may, but are not required to, have nested fields declared under the children property.

Current composite fields (such as CurrencyField) could be implemented as structural components, with nested data components.

2.3. Text cast control

In certain cases, the back-end for form handlers may need to declare content as either plain text, or html. For example, any of the following properties could be either plain text or html:

  • title
  • description
  • rightTitle
  • message.value
  • customValidationMessage

By default, a literal value passed for any of these properties must be treated as raw text, and must be xml encoded before being put onto any template.

In order to declare a cast type (e.g. "html") then this can be specified using a nested json object. Note that newlines will be converted to HTML link breaks (
).

For instance, the following field has plain text title and rightTitle properties, but a html description property

{
  "type": "text",
  "id": "TheForm_Name",
  "name": "Name",
  "title": "Name",
  "rightTitle": {"text": "Enter your full name"},
  "description": {"html": "<a href='forgot.html'>Click here if you forgot your name</a>"}
}

2.4. Form Customisation

2.4.1. Customisation via PHP only

Within the front-end of the React application, the logic is able to infer from these data types which react component should be used. For instance, for a field specifying a type value "SingleSelect", a react "DropdownComponent" could be used by default. For any given react application, it should be possible to override the "default" component to use for each of the above types.

Furthermore, for any given type property there could be more than one available component. For example in the above case, if a "DropdownComponent" isn't the appropriate field template, the FormField PHP class could specify a component value of "RadioComponent". However, this assignment would result in a higher degree of coupling of the back-end with the front-end.

Back-end developers writing forms will not need to be concerned too much with the way the form appears, as the front-end promises to provide the necessary component scaffolding for the schema they choose.

Developers who are interested in tailoring the UI to be as optimal as possible should expect to specify "component" values that pick appropriate React-implemented fields, and/or to create new custom components for the UI.

SilverStripe templates will still be declared for form fields, but will only be used in UIs that are not based on React. React-driven UIs (initially the assets area, and in time likely the full CMS, as well as front-end applications that developers elect to build in React), won’t make reference to the .SS form field templates.

2.4.2. Customisation via both PHP and React

In order to better support advanced flexibility, it will be possible for the formfield schema to request a specific component that is custom built for a specific application. This would require the following:

  • A FormField subclass which extends getSchemaComponent() to return the new component name. (see notes on FormField.php above).
  • A custom React component developed and loaded via dependency injection.

2.4.3. Customisation via React only

Front-end developers should be able to develop new components, and for each specify the following:

  • The data type or types these components provide functionality for. (e.g. MyAwesomeCheckbox is the Boolean type).
  • Optionally flag this new component as the "default" for that field type (e.g. MyBetterPicker set as default for MultiSelect)
  • Optionally flag this component as a replacement to override any existing component (e.g. ChosenDropdown replaces Dropdown).

If a new React component is developed to replace an existing component or abstract type, then any form field which previously specified the corresponding **type _or _component values would subsequently use the new component instead.

For a higher level overview of extending React components in SilverStripe see #4887

2.5. Security Considerations

2.5.1. CSRF

If the above form schema is adopted, it should automatically be included in any generated form. In addition, given that form handling for both React and traditional SilverStripe forms will use the same form action, it's expected that server-side validation will automatically ensure the token works consistently.

@tractorcow tractorcow added this to the 4.0-alpha1 milestone Jan 18, 2016
@markguinn
Copy link

I really like this direction. For the FormField configuration my vote would be option B (explicit getters and setters). +1 for me on everything here.

@kinglozzer
Copy link
Member

Read through this yesterday and have the exact same opinion as @markguinn - everything outlined here looks good, and explicit getters/setters are my preference too.

I’m still a bit fuzzy on the React side of things as I’m very early in that learning process, but my assumption is that this would allow you to create a new component and do:

$field = TextField::create('MyTextField')
    ->setSchemaComponent('MySuperAwesomeTextFieldComponent')

How do (or rather, “do”) schema components tie in with specific FormField classes? E.g.

$fieldA = DropdownField::create('FieldA', null, ['A' => 'A', 'B' => 'B'])
    ->setSchemaComponent('OptionButtons');
$fieldB = OptionsetField::create('FieldB', null, ['A' => 'A', 'B' => 'B'])
    ->setSchemaComponent('OptionButtons');

Would both of these work and appear the same in the CMS? My guess is yes, as both field classes would likely provide the same schema info that the component could digest. Would the “Type” parameter for the schema be different for each of these two example classes, so they can have different default components?

@sminnee
Copy link
Member

sminnee commented Jan 20, 2016

In order to make explicit setters, this means that setSchemaComponent() needs to be a method on FormField.

Although we could add that as an Extension, in practise it will be an Extension that is applied to every FormField on every SS installation that uses the CMS, which is most of them.

Rather than using an Extension, maybe a Trait is better?

@tractorcow
Copy link
Contributor Author

Would both of these work and appear the same in the CMS?

yes, although, if you're using that code you may as well just use two OptionsetFields with the default component (which I assume is OptionButtons).

I think using a trait is fine, but it won't be overridable (or removable) as an extension is.

chillu added a commit to open-sausages/silverstripe-cms that referenced this issue Mar 1, 2016
chillu added a commit to open-sausages/silverstripe-framework that referenced this issue Mar 1, 2016
chillu added a commit to open-sausages/silverstripe-framework that referenced this issue Mar 1, 2016
chillu added a commit to open-sausages/silverstripe-framework that referenced this issue Mar 1, 2016
chillu added a commit to open-sausages/silverstripe-framework that referenced this issue Mar 1, 2016
chillu added a commit to open-sausages/silverstripe-cms that referenced this issue Mar 2, 2016
chillu added a commit to open-sausages/silverstripe-framework that referenced this issue Mar 2, 2016
chillu added a commit to open-sausages/silverstripe-framework that referenced this issue Mar 2, 2016
This was referenced Mar 2, 2016
chillu added a commit to open-sausages/silverstripe-framework that referenced this issue Mar 3, 2016
tractorcow pushed a commit to open-sausages/silverstripe-cms that referenced this issue Mar 7, 2016
chillu added a commit to open-sausages/silverstripe-framework that referenced this issue Mar 28, 2016
The RFC requires a FormField implementation to override $schemaDataType,
but its defined on the trait - which can't be redefined by a field subclass.

In the end, the trait was never designed to be reuseable on classes other than FormField.
We need to admit that architecturally, we'll have to add all that API weight to the base FormField
class because of the way forms are structured in SilverStripe (mainly due to a missing layer
of indirection in getCMSFields implementations).

Also implemented the $schemaDataType on fields where its known.

See silverstripe#4938
See http://php.net/manual/en/language.oop5.traits.php#language.oop5.traits.properties.example
chillu added a commit to open-sausages/silverstripe-framework that referenced this issue Mar 28, 2016
The RFC requires a FormField implementation to override $schemaDataType,
but its defined on the trait - which can't be redefined by a field subclass.

In the end, the trait was never designed to be reuseable on classes other than FormField.
We need to admit that architecturally, we'll have to add all that API weight to the base FormField
class because of the way forms are structured in SilverStripe (mainly due to a missing layer
of indirection in getCMSFields implementations).

Also implemented the $schemaDataType on fields where its known.

See silverstripe#4938
See http://php.net/manual/en/language.oop5.traits.php#language.oop5.traits.properties.example
tractorcow pushed a commit to open-sausages/silverstripe-framework that referenced this issue Mar 28, 2016
The RFC requires a FormField implementation to override $schemaDataType,
but its defined on the trait - which can't be redefined by a field subclass.

In the end, the trait was never designed to be reuseable on classes other than FormField.
We need to admit that architecturally, we'll have to add all that API weight to the base FormField
class because of the way forms are structured in SilverStripe (mainly due to a missing layer
of indirection in getCMSFields implementations).

Also implemented the $schemaDataType on fields where its known.

See silverstripe#4938
See http://php.net/manual/en/language.oop5.traits.php#language.oop5.traits.properties.example
@sminnee sminnee added the ready label Apr 11, 2016
@sminnee
Copy link
Member

sminnee commented Apr 27, 2016

Is this one completed now?

@hafriedlander hafriedlander modified the milestones: 4.0.0-alpha2, 4.0.0-alpha1 May 2, 2016
@hafriedlander
Copy link
Contributor

This has been partially implemented, to the state required for alpha 1. It hasn't been completely implemented yet. I've moved milestone to alpha 2 to track remaining portion, and will raise the process for closing an RFC with core team.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

7 participants