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

POC Add a trait to bootstrap ViewableData into react component #1187

Draft
wants to merge 1 commit into
base: 1
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/boot/registerComponents.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Field as ReduxFormField } from 'redux-form';
import Injector from 'lib/Injector';
import ActionMenu from 'components/ActionMenu/ActionMenu';
import CmsSiteName from 'components/CmsSiteName/CmsSiteName';
import Badge from 'components/Badge/Badge';
import Button from 'components/Button/Button';
import BackButton from 'components/Button/BackButton';
Expand Down Expand Up @@ -100,5 +101,6 @@ export default () => {
NumberField,
PopoverOptionSet,
ToastsContainer,
CmsSiteName
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bits register our demo component in injector. Any new custom component would have to go through this step.

I nice thing here is that a developer could choose to override our component with their own custom implementation.

});
};
1 change: 1 addition & 0 deletions client/src/bundles/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ require('../legacy/AddToCampaignForm');
require('../legacy/SecurityAdmin');
require('../legacy/ModelAdmin');
require('../legacy/ToastsContainer');
require('../legacy/BootstrapComponent');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're registering tho logic to bootstrap the react components. This would only happen once in core.


// Legacy form fields
// Fields used by core legacy UIs, or available to users
Expand Down
21 changes: 21 additions & 0 deletions client/src/components/CmsSiteName/CmsSiteName.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';

const CmsSiteName = ({ title, baseHref }) => (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replicates the existing SS templates but in JSX

<div className="cms-sitename">
<a href="#" className="cms-sitename__link font-icon-silverstripe font-icon-large" target="_blank">
</a>
<a className="cms-sitename__title" href={baseHref} target="_blank">{title}</a>
</div>
);

CmsSiteName.propTypes = {
title: PropTypes.string,
baseHref: PropTypes.string,
};

CmsSiteName.defaultProps = {
};

export default CmsSiteName;
48 changes: 48 additions & 0 deletions client/src/legacy/BootstrapComponent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/* global ss */
import jQuery from 'jquery';
import React from 'react';
import ReactDOM from 'react-dom';
import { loadComponent } from 'lib/Injector';

jQuery.entwine('ss', ($) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bit uses entwine to wire our react component. All Component based off the BootstrapComponent trait would use this generic bootstrapping logic.

Copy link
Contributor

@blueo blueo Mar 4, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good - quite similar to our implementation(below) we've got some extra handling for having an input field that the react component updates

// function to create boilerplate for standard entwine field boostrap
import jQuery from 'jquery';
import React from 'react';
import ReactDOM from 'react-dom';
import { schemaMerge } from 'lib/schemaFieldValues';
import { loadComponent } from 'lib/Injector';

export default function reactFieldBootstrapper(componentName) {
  /**
   * Shiv for inserting react field into entwine forms
   * Also @see LeftAndMain.KeyValueField.js for reloading behaviour after form submission
   */
  jQuery.entwine('ss', ($) => {
    $(`.js-injector-boot .${componentName}`).entwine({
      Timer: null,
      Component: null,
      Value: null,
      Root: null,
      Input: null,

      setValue(value) {
        this.Value = value;
        const input = this.getInput();
        if (input) {
          input.val(value);
        }
      },

      onmatch() {
        this._super();

        const cmsContent = this.closest('.cms-content').attr('id');
        const context = (cmsContent)
          ? { context: cmsContent }
          : {};

        const Field = loadComponent(componentName, context);
        this.setComponent(Field);

        const state = this.data('state') || {};
        this.setValue(state.value ? state.value : {});

        const reactRoot = $(this).find('.react-holder')[0];
        this.setRoot(reactRoot);

        const fieldInput = $(this).find('input');
        this.setInput(fieldInput);

        this.refresh();
      },

      onunmatch() {
        this._super();
        // solves errors given by ReactDOM "no matched root found" error.
        const container = $(this).children('.react-holder')[0];
        if (container) {
          ReactDOM.unmountComponentAtNode(container);
        }
      },

      refresh() {
        const props = this.getAttributes();
        const $field = $(this);

        const onChange = (value) => {
          this.setValue(value);

          // There are instances where updating the input value shouldn't
          // rerender the element
          if (props.dontRefresh === true) {
            return;
          }

          this.refresh();
          // Trigger change detection (see jquery.changetracker.js)
          clearTimeout(this.getTimer());
          const timer = setTimeout(() => {
            $field.trigger('change');
          }, 0);
          this.setTimer(timer);
        };

        const Field = this.getComponent();

        ReactDOM.render(
          <Field
            {...props}
            onChange={onChange}
            value={this.getValue()}
            noHolder
          />,
          this.getRoot()
        );
      },

      /**
       * Find the selected node and get attributes associated to attach the data to the form
       *
       * @returns {Object}
       */
      getAttributes() {
        const state = $(this).data('state');
        const schema = $(this).data('schema');
        return schemaMerge(schema, state);
      },
    });
  });
}

$('.js-injector-boot .bootstrap-component').entwine({

Component: null,

onmatch() {
const cmsContent = this.closest('.cms-content').attr('id');
const context = (cmsContent)
? { context: cmsContent }
: {};

const componentName = this.data('component');
const Component = loadComponent(componentName, context);

this.setComponent(Component);
this._super();
this.refresh();
},

refresh() {
const props = this.getProps();
const Component = this.getComponent();
ReactDOM.render(<Component {...props} />, this[0]);
},

/**
* Find the selected node and get attributes associated to attach the data to the form
*
* @returns {Object}
*/
getProps() {
return $(this).data('props') || {};
},

/**
* Remove the component when unmatching
*/
onunmatch() {
ReactDOM.unmountComponentAtNode(this[0]);
},
});
});
10 changes: 8 additions & 2 deletions code/LeftAndMain.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use InvalidArgumentException;
use LogicException;
use ReflectionClass;
use SilverStripe\Admin\React\SiteName;
use SilverStripe\CMS\Controllers\SilverStripeNavigator;
use SilverStripe\Control\ContentNegotiator;
use SilverStripe\Control\Controller;
Expand Down Expand Up @@ -1110,6 +1111,11 @@ public function Menu()
return $this->renderWith($this->getTemplatesWithSuffix('_Menu'));
}

public function SiteName()
{
return new SiteName();
}

/**
* @todo Wrap in CMSMenu instance accessor
* @return ArrayData A single menu entry (see {@link MainMenu})
Expand Down Expand Up @@ -1658,11 +1664,11 @@ public function currentPageID()
if (isset($this->urlParams['ID']) && is_numeric($this->urlParams['ID'])) {
return $this->urlParams['ID'];
}

if (is_numeric($this->getRequest()->param('ID'))) {
return $this->getRequest()->param('ID');
}

/** @deprecated */
$session = $this->getRequest()->getSession();
return $session->get($this->sessionNamespace() . ".currentPage") ?: null;
Expand Down
152 changes: 152 additions & 0 deletions code/React/BootstrapComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php

namespace SilverStripe\Admin\React;

use SilverStripe\Core\Convert;

trait BootstrapComponent
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This trait contains most of the logic that needs to be added to a ViewableData object to be rendered as a React component.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trait is coupled to ViewableData, which to me implies that this should be a subclass, not a trait. What are the tradeoffs for asking devs to subclass ReactComponent or something?

Copy link
Member

@GuySartorelli GuySartorelli Oct 27, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say the major advantage of keeping is a trait is that developers can add the trait to a DataObject (or any other ViewableData subclass like FormField or the like) if they want to.

{

private static $casting = [
'AttributesHTML' => 'HTMLFragment'
];

private $attributes = [];

protected $extraClasses = [];

public function forTemplate()
{
$return = $this->renderWith($this->getTemplates());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be nice to throw somewhere if we aren't on viewable data, just in case someone expects that to work

return $return;
}

public function getTemplates(): array
{
return [self::class, 'SilverStripe\\Admin\\React\\BootstrapComponent'];
}

public function getAttributesHTML($attrs = null)
{
$exclude = (is_string($attrs)) ? func_get_args() : null;

$attrs = $this->getAttributes();

// Remove empty
$attrs = array_filter((array)$attrs, function ($value) {
return ($value || $value === 0);
});

// Remove excluded
if ($exclude) {
$attrs = array_diff_key($attrs, array_flip($exclude));
}

// Prepare HTML-friendly 'method' attribute (lower-case)
if (isset($attrs['method'])) {
$attrs['method'] = strtolower($attrs['method']);
}

// Create markup
$parts = [];
foreach ($attrs as $name => $value) {
if ($value === true) {
$value = $name;
}

$parts[] = sprintf('%s="%s"', Convert::raw2att($name), Convert::raw2att($value));
}

return implode(' ', $parts);
}

/**
* @param string $name
* @param string $value
* @return $this
*/
public function setAttribute($name, $value)
{
$this->attributes[$name] = $value;
return $this;
}

/**
* @param string $name
* @return string
*/
public function getAttribute($name)
{
if (isset($this->attributes[$name])) {
return $this->attributes[$name];
}
return null;
}

/**
* @return array
*/
public function getAttributes()
{
$attrs = [
'class' => $this->extraClass(),
'data-component' => $this->getComponent(),
'data-props' => json_encode($this->getProps()),
];

$attrs = array_merge($attrs, $this->attributes);

return $attrs;
}

/**
* Compiles all CSS-classes.
*
* @return string
*/
public function extraClass()
{
return 'bootstrap-component ' . implode(' ', array_unique($this->extraClasses));
}

/**
* Add a CSS-class to the form-container. If needed, multiple classes can
* be added by delimiting a string with spaces.
*
* @param string $class A string containing a classname or several class
* names delimited by a single space.
* @return $this
*/
public function addExtraClass($class)
{
//split at white space
$classes = preg_split('/\s+/', $class);
foreach ($classes as $class) {
//add classes one by one
$this->extraClasses[$class] = $class;
}
return $this;
}

/**
* Remove a CSS-class from the form-container. Multiple class names can
* be passed through as a space delimited string
*
* @param string $class
* @return $this
*/
public function removeExtraClass($class)
{
//split at white space
$classes = preg_split('/\s+/', $class);
foreach ($classes as $class) {
//unset one by one
unset($this->extraClasses[$class]);
}
return $this;
}

abstract public function getProps(): array;

abstract public function getComponent(): string;
}
27 changes: 27 additions & 0 deletions code/React/SiteName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php
namespace SilverStripe\Admin\React;

use SilverStripe\Admin\LeftAndMain;
use SilverStripe\Control\Director;
use SilverStripe\SiteConfig\SiteConfig;
use SilverStripe\View\ViewableData;


class SiteName extends ViewableData
{
use BootstrapComponent;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks nice and clean :)


public function getProps(): array
{
$config = SiteConfig::current_site_config();
return [
'title' => $config->Title ?? LeftAndMain::config()->get('application_name'),
'baseHref' => Director::absoluteBaseURL()
];
}

public function getComponent(): string
{
return 'CmsSiteName';
}
}
2 changes: 1 addition & 1 deletion templates/SilverStripe/Admin/Includes/LeftAndMain_Menu.ss
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<div class="fill-height cms-menu cms-panel cms-panel-layout" id="cms-menu" data-layout-type="border" aria-expanded="false">
<div class="cms-menu__header">
<% include SilverStripe\\Admin\\LeftAndMain_MenuLogo %>
$SiteName
<% include SilverStripe\\Admin\\LeftAndMain_MenuStatus %>
</div>

Expand Down
5 changes: 0 additions & 5 deletions templates/SilverStripe/Admin/Includes/LeftAndMain_MenuLogo.ss

This file was deleted.

1 change: 1 addition & 0 deletions templates/SilverStripe/Admin/React/BootstrapComponent.ss
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div $AttributesHTML></div>