diff --git a/client/src/boot/registerComponents.js b/client/src/boot/registerComponents.js
index d01ab3ca5..1ef5d089e 100644
--- a/client/src/boot/registerComponents.js
+++ b/client/src/boot/registerComponents.js
@@ -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';
@@ -100,5 +101,6 @@ export default () => {
NumberField,
PopoverOptionSet,
ToastsContainer,
+ CmsSiteName
});
};
diff --git a/client/src/bundles/bundle.js b/client/src/bundles/bundle.js
index b62fea0c1..1fb85a535 100644
--- a/client/src/bundles/bundle.js
+++ b/client/src/bundles/bundle.js
@@ -100,6 +100,7 @@ require('../legacy/AddToCampaignForm');
require('../legacy/SecurityAdmin');
require('../legacy/ModelAdmin');
require('../legacy/ToastsContainer');
+require('../legacy/BootstrapComponent');
// Legacy form fields
// Fields used by core legacy UIs, or available to users
diff --git a/client/src/components/CmsSiteName/CmsSiteName.js b/client/src/components/CmsSiteName/CmsSiteName.js
new file mode 100644
index 000000000..8df8ff2ae
--- /dev/null
+++ b/client/src/components/CmsSiteName/CmsSiteName.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+
+const CmsSiteName = ({ title, baseHref }) => (
+
+);
+
+CmsSiteName.propTypes = {
+ title: PropTypes.string,
+ baseHref: PropTypes.string,
+};
+
+CmsSiteName.defaultProps = {
+};
+
+export default CmsSiteName;
diff --git a/client/src/legacy/BootstrapComponent.js b/client/src/legacy/BootstrapComponent.js
new file mode 100644
index 000000000..757eef371
--- /dev/null
+++ b/client/src/legacy/BootstrapComponent.js
@@ -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', ($) => {
+ $('.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(, 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]);
+ },
+ });
+});
diff --git a/code/LeftAndMain.php b/code/LeftAndMain.php
index 1ea9a376a..ab5db43e7 100644
--- a/code/LeftAndMain.php
+++ b/code/LeftAndMain.php
@@ -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;
@@ -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})
@@ -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;
diff --git a/code/React/BootstrapComponent.php b/code/React/BootstrapComponent.php
new file mode 100644
index 000000000..145437956
--- /dev/null
+++ b/code/React/BootstrapComponent.php
@@ -0,0 +1,152 @@
+ 'HTMLFragment'
+ ];
+
+ private $attributes = [];
+
+ protected $extraClasses = [];
+
+ public function forTemplate()
+ {
+ $return = $this->renderWith($this->getTemplates());
+ 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;
+}
diff --git a/code/React/SiteName.php b/code/React/SiteName.php
new file mode 100644
index 000000000..03ea639ed
--- /dev/null
+++ b/code/React/SiteName.php
@@ -0,0 +1,27 @@
+ $config->Title ?? LeftAndMain::config()->get('application_name'),
+ 'baseHref' => Director::absoluteBaseURL()
+ ];
+ }
+
+ public function getComponent(): string
+ {
+ return 'CmsSiteName';
+ }
+}
diff --git a/templates/SilverStripe/Admin/Includes/LeftAndMain_Menu.ss b/templates/SilverStripe/Admin/Includes/LeftAndMain_Menu.ss
index 29a3c9b20..a49408068 100644
--- a/templates/SilverStripe/Admin/Includes/LeftAndMain_Menu.ss
+++ b/templates/SilverStripe/Admin/Includes/LeftAndMain_Menu.ss
@@ -2,7 +2,7 @@