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-9: UI Extensibility #4887

Closed
chillu opened this issue Jan 5, 2016 · 16 comments
Closed

RFC-9: UI Extensibility #4887

chillu opened this issue Jan 5, 2016 · 16 comments

Comments

@chillu
Copy link
Member

chillu commented Jan 5, 2016

Author: @flashbackzoo @chillu
Status: Pending review
Version: 0.1

Purpose and Outcome

A SilverStripe CMS powered by a modern frontend JavaScript framework should retain its openness to developers wanting to customise and extend its presentation and UI behaviour. This proposal relies on the acceptance of RFC-8 (“Adding ReactJS to SilverStripe CMS”). It also assumes the use of the Redux state management library. A solid understanding of the Flux architecture approach which has evolved into the Redux library is recommended for reviewing this RFC.

A major goal of a new CMS frontend implementation will be decoupling client-side components from backend components. Most components will be rendered via frontend logic (React components), and consume structured data from the backend (rather than raw HTML generated in SilverStripe templates). Customising the user interface behaviour shouldn’t hence necessitate a PHP subclass or PHP-based modifications. For example a developer should be able to implement a character count in all form fields, or replace all form fields of a certain type with their own implementation and not have to touch server-side code.

Several key parts of the React application need to be extendible. We’ve put together a bare bones PoC so you can see some of these concepts is action.The PoC includes three modules: A common base module with shared functionality, a cms module demonstrating how the core would use these features, and a better-list module which could be a third party addition.

Customise Initial Application State

Developers can override and extend core state keys provided by the CMS and create their own. Note that this likely won’t be required often if we implement a simple configuration system which can be altered via custom static configuration files.

Customise Redux Actions

Developers can override and extend core Redux actions provided by the CMS and create their own. These actions are the only way for UI components to modify state. In order to enforce a well-understood unidirectional data flow, UI components don’t have direct access to the state they rely on (and might share with other components). Example actions are “page data received”, “select row” or “close URL segment edit view”.

  • Example: Action to batch select all rows in a GridField (e.g. through a new checkbox UI element)
  • Example: Add a new dialog with a “save” button which activates after 10 min inactivity. The button triggers the existing SAVE action.
  • Proof of Concept
  • Ticket: Customise Redux state and actions

Customise Redux Reducers

Developers can override and extend core reducers provided by the CMS and create their own. Reducers transform Redux actions (and their optional payload) to new application state, and are the only way for UI components to modify this state. Since Redux aggregates all reducers into one “root reducer” for the application, we propose that reducers can be chained via DIA (see nextReducer() call). The extending code can hence decide to call the original reducer or break the reducer chain for certain actions.

Customise React Components

Developers can override and extend ReactJS components provided by the CMS and create their own.

Customise React forms

SilverStripe can modify forms in PHP via getCMSFields(), but that approach is limited to a narrow band of declarative behaviour changes (e.g. min and max validation), and does not include JavaScript. Use SilverStripe's FormBuilder to modify form presentation and behaviour, and provide access to the underlying redux-forms layer. Details in Use FormBuilder to modify form field presentation.

Customise GraphQL queries and retrieve new data

Retrieve new data on existing GraphQL queries in order to extend the UI. This only refers to the client-side customisations, not the PHP changes required for resolvers in the silverstripe/graphql module. See [GraphQL/Apollo RFC](#6245 for details.

Customise GraphQL mutations and write new data

Similar to GraphQL reads, GraphQL based mutations ("writes") rely on GraphQL fragments. Adding new data requires modification of the underlying mutation queries. Hence the customisation approach should be very similar. Note that forms aren't currently submitted through GraphQL.

Customisation Approach

Static Configuration

Per-module configuration should allow a baseline customisation (e.g. sidebar width or availability of a “preview” panel view). This configuration should take the shape of a nested JavaScript object which is added to the application state on boot.

Composition through React Component Props

React components can be nested, and receive component instances as part of their props. We can use this to our advantage by allowing component composition. For example, a toolbar component could receive a collection of button components via a prop, allowing for addition or removal of buttons (see Building Plugins for React Apps)

Callbacks

React components can receive callback functions as props. This is commonly used for smart/dumb component interactions, e.g. a button click handler.

Registries and Middleware

Registries allow code outside of the core JavaScript bundles to modify behavior on app boot without recompiling these core bundles. They're effectively global state. Registries could take the form of middleware (passing state through a stack of middleware and optionally modifying it).

ES6 Class Extension

Since React components are just ES6 classes, they can be extended through the standard language constructs. Since ES6 classes enforce a single inheritance scheme on JavaScript’s prototypical inheritance, their use is limited (two modules couldn’t extend the same base class for a shared component).

Higher Order Components

The higher order component pattern wraps behaviour around an existing component, making their relationship more explicit and favouring composition over inheritance. This pattern works well for customising core components (e.g. text fields with a character count), but relies on the external component interface (React props). Higher order components can’t override methods, and are dependant on the underlying component exposing behaviour triggers via props and state.

Mixins

React Mixins have been deprecated with the 0.13 release, in favour of using higher order components. Mixins in JavaScript are powerful, but hard to debug and optimise.

Dependency Injection

Most of the above customisations rely on a structured way to hook into instance creation by third party code. For example, the core form builder React component would be in charge of creating a text field if required. If a third party module wants to customise the text field (e.g. replace it with its own React component), it needs a way to influence the created instance without direct access to the core form builder code.

We recommend the bottlejs dependency injection library to make the core application extendible. It can achieve the above extendibility requirements (and more) using out-of-the-box features, is well tested, well documented and relatively lightweight (1500 CLOC).

Service, factory, and provider patterns: Factories can be registered for each ReactJS component class we wish to make extendible. Developers can access these factories, and register their own, via a lightweight wrapper we provide around the bottlejs library.

Decorator pattern: The first time a dependency is requested from the DI container developers have a hook, which can be used to modify behaviour, or completely replace a dependency.

Middleware pattern: Similar to the decorator pattern. The middleware pattern provides a hook each time the dependency is requested from the DI container.

Namespaced containers:
The DI container can be namespaced if required. For example if we assume myService is a singleton then di.container.foo.myservice !== di.container.bar.myservice.

Good writeup about using context for DI. The Griddle component library has some good use cases for deep React customisation via props.

Related

@chillu
Copy link
Member Author

chillu commented Jan 5, 2016

Note that we're preparing a separate RFC for "State management in the CMS" which will get into the Redux vs. Flux rationale a bit more. These three RFCs are quite tightly linked, since you can't suggest customisation options without making some calls on the base you're extending from :)

@assertchris
Copy link
Contributor

Since ES6 classes enforce a single inheritance scheme on JavaScript’s prototypical inheritance, their use is limited (two modules couldn’t extend the same base class for a shared component).

They compile to the same (prototypical inheritance based) code. There is no language limitation on using prototypical inheritance or simulating multiple inheritance/mix-ins.

class Table extends React.Component {
    // ...
}

class DataGrid extends React.Component {
    // ...
}

const hasOwnProperty = Object.prototype.hasOwnProperty;

for (let key in Table) {
    if (hasOwnProperty.call(Table, key) && !hasOwnProperty.call(DataGrid, key)) {
        DataGrid.prototype[key] = Table.prototype[key];
    }
}

...is both allowed and readable. The objection is not syntactic but preferential.

(code edits made all day long...)

@assertchris
Copy link
Contributor

How do we feel about stateless components (export default (props) => { /* ... */ }) by default?

@markguinn
Copy link

+1 on all of this. We're doing a React+Redux app right now and really thoroughly enjoying it. We've arrived at very similar conventions and I'm excited to see the CMS head that way too. Have you thought at all about REST calls vs Relay+GraphQL?

+1 for allowing stateless components as well.

@flashbackzoo
Copy link
Contributor

On the surface it seems reasonable to use stateless components by default and only use 'stateful' components when you need to use life-cycle methods etc. There is the trade-off of having inconsistent syntax between the two component types which I don't really like. But on the other hand Facebook seem to be saying there will be performance optimisations made for stateless components.

I've just got back from holiday today, so will get my head back into this and have a think about what other implications there might be, given the approach we've outlined 😄

@chillu
Copy link
Member Author

chillu commented Jan 11, 2016

Thanks @markguinn ! We haven't actively considered GraphQL, although it definitely looks interesting - we'll need to retrieve a lot of different data models with relatively slow API endpoints to build a feature rich CMS UI (menu, user profile, page tree, page data, allowed workflow actions, batch actions, etc.). Making all these as separate REST calls will get us into performance issues. I'm not convinced we have enough "graph-like" data to warrant GraphQL though. At the moment, our focus is with the CMS frontend. I'd expect API endpoints to be created as required through custom controllers for now - we just have to pick one battle with the 2.5 devs which can focus part-time on CMS UI here at SilverStripe Ltd. On that note, keen to contribute? :) Maybe a GraphQL PoC? Even just sharing knowledge about what worked for you on Redux will help immensely!

@assertchris Can you explain how stateless components relate to UI extensibility? I think I understand why they're a good idea for UI architecture (easier to reason about, see https://facebook.github.io/react/docs/interactivity-and-dynamic-uis.html). But how are they helping extensibility? On ES6 classes: You're right, they're just a thin syntax layer on the usual JS prototypical inheritance, so they don't "enforce" anything :)

@markguinn
Copy link

@chillu - The app we're currently building uses REST endpoints just as you describe. We made that decision to make onboarding easier for new developers. That said, there have been numerous times I've thought "Ah, that's why they built Relay...". Relay allows individual components to declare what bits of data they need and then it composites them into larger queries at the root components. With REST, the server and parent components need to also know what data a child component needs so if that data changes you're potentially opening 3 or 4 files instead of 1. I suspect a manually built API is still the way to go at this stage for the reasons you mentioned and because it's a known quantity for security. If I get time or another paying project using these technologies I'll certainly throw together a PoC but I doubt it will be soon, unfortunately.

As far as our usage of Redux, it looks like we're following many of the same conventions. We're not using DI but I think it's a great idea for the CMS. All of your examples look solid to me.

@assertchris
Copy link
Contributor

@chillu This RFC seems to be all about recommended structure for third-party React CMS modules. Stateless by default is a structural consideration. If the intention of this RFC was only to define how third-party modules interact with an existing CMS, it creeped its scope.
Edit
I'm not saying the RFC should be about third-party React CMS module structure. Just that you need a bounded context between the internal Flux/Redux architecture and the terms you use to define how to override internal behaviour. You can't talk about custom actions and reducers because that implies third-party components will need to think/work in terms of Redux. That's a constraint you place on third-party modules by leaking your abstraction.
If you define these things (actions, reducers etc) in terms of how they affect internal behaviour, in architecturally agnostic language, then this RFC would just be about how to interact with the CMS.
On the other hand, describing the structure of third-party React CMS modules in this document can be beneficial. Neither React RFC recommends Redux as the architectural pattern for third-party modules. Neither defines a React "supported module" standard. This could be a good place for that...

@flashbackzoo
Copy link
Contributor

State management RFC #4911

@chillu
Copy link
Member Author

chillu commented Apr 18, 2017

Some thoughts on DI:

  • Monkeypatching prototypes doesn't really work on React components: The only way you can make use of new functions added to a prototype is by somehow calling them elsewhere. Which would require a DataExtension-like system of callbacks (beforeOnChange etc). This shouldn't be done through prototypes, but rather through HoCs passing in props to the wrapped component. The same applies to middleware-style customisation of components: You can't chain a component's render() in middleware meaningfully, since the call returns VirtualDOM that's effectively readonly to further calls.
  • Extending classes is generally considered bad practice (favouring composition over inheritance). In the case of React, components might not even be classes (stateless components should be pure functions which take state and return virtual DOM)
  • For React components, props are the "public API", not the specific class methods. They can be "extended" by wrapping components in HoCs
  • HoCs still need to be applied by non-core bundles without recompilation - DI factory functions like in bottleJS could help with this.
  • If class extension is the only way to achieve component customisation, it's missing customisation hooks in props (callbacks, accepting child components). The component might also do too much and should be broken down into smaller ones, which then can allow for more focused HoCs
  • To keep class-based dependency injection managable, it's common OOP practice to inject against contracts (interfaces) rather than just replacing concrete implementations. In JavaScript this is only possible via language extensions like Flow and TypeScript (and libraries using it for DI like InversifyJS which heavily relies on TypeScript).
  • It's hard to progressively introduce TypeScript to an existing codebase. On the other hand, Flow can be added to single files.
  • If we only declare props as a "public API" open to customisation, a more lightweight propType based approach can enforce API contracts. That's built into React already, and can be made more explicit with Flow or TypeScript layered on top at a later point - it's not a precondition for DI.
  • It's unclear if Flow or TypeScript can "detect" which component is used when it's returned from a DI layer (since it's just a string argument with a runtime lookup against a map of services)

@assertchris
Copy link
Contributor

assertchris commented Apr 18, 2017

You can't chain a component's render() in middleware meaningfully

You don't need to: https://gist.github.com/assertchris/951058bc2eea72cc417149cd86465975

@chillu
Copy link
Member Author

chillu commented Apr 18, 2017

@assertchris So your example demonstrates that you can modify the DOM of a component by React component refs, right? That's not really influencing render() (e.g. adding another component in the middle of a render() call). React recommends refs for animation, focus management, but not for influencing renders.

@assertchris
Copy link
Contributor

It is absolutely influencing render, but not in a way that the HoC knows anything about what render is supposed to be doing. Let me share another example of HoC influencing behavior, without owning the state or manipulating the decorated component: https://gist.github.com/assertchris/c7338c6499842abe636692deae61166e

Refs are a powerful tool to trigger well-defined behavior from outside (or in HoC) components, but they're not the only tool we could use for decoration and/or modification of decorated components. Proxying between context and props is another way to do things like global plugin registries and plugin hooks...

@chillu
Copy link
Member Author

chillu commented Apr 19, 2017

I've converted this card to an epic to reference the actual implementation cards, and updated this RFC to cross-reference to them. You'll need zenhub.io to see the related cards in the epic.

@chillu chillu added the Epic label Apr 19, 2017
@chillu
Copy link
Member Author

chillu commented Mar 13, 2018

If you followed along here, there's a good chance you're interested in the extensible GridField discussion

@chillu
Copy link
Member Author

chillu commented Feb 24, 2019

We've come a long way with this, all cards associated with this epic are closed, and there's more focused discussion going on in the GridField RFC. Closing this.

@chillu chillu closed this as completed Feb 24, 2019
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

4 participants