silverstripe / silverstripe-framework

Silverstripe Framework, the MVC framework that powers Silverstripe CMS
https://www.silverstripe.org
BSD 3-Clause "New" or "Revised" License
721 stars 821 forks source link

RFC: FormField React Integration API #4938

Closed tractorcow closed 8 years ago

tractorcow commented 8 years ago

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:

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:

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.

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

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:

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

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:

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

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:

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):

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:

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:

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

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 https://github.com/silverstripe/silverstripe-framework/issues/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.

markguinn commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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.

sminnee commented 8 years ago

Is this one completed now?

hafriedlander commented 8 years ago

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.