Version 5 supported

Customising react components

In this tutorial, we'll customise some form elements rendered with React to have some new features.

An enhanced textField

Let's add a character count to the TextField component. TextField is a built-in component in the admin area. Because the TextField component is fetched through Injector, we can override it and augment it with our own functionality.

First, let's create our higher order component.

// my-module/js/components/CharacterCounter.js
import React from 'react';

const CharacterCounter = (TextField) => (props) => (
  <div>
    <TextField {...props} />
    <small>Character count: {props.value.length}</small>
  </div>
);

export default CharacterCounter;

Now let's add this higher order component to Injector.

// my-module/js/main.js
import Injector from 'lib/Injector';
import CharacterCounter from './components/CharacterCounter';

Injector.transform('character-count-transform', (updater) => {
  updater.component('TextField', CharacterCounter);
});

The last thing we'll have to do is transpile our code and load the resulting bundle file into the admin page.

# my-module/_config/config.yml
---
Name: my-module
---
SilverStripe\Admin\LeftAndMain:
  extra_requirements_javascript:
    # The name of this file will depend on how you've configured your build process
    - 'my-module/js/dist/main.bundle.js'

Now that the customisation is applied, our text fields look like this:

a text field with some text in it, and a character count showing "characters count: 7"

More enhancements

Let's add another customisation to TextField. If the text goes beyond a specified length, let's throw a warning in the UI.

// my-module/js/components/TextLengthChecker.js
import React from 'react';
// ...

const TextLengthCheker = (TextField) => (props) => {
  const { limit, value } = props;
  const invalid = limit !== undefined && value.length > limit;

  return (
    <div>
      <TextField {...props} />
      {invalid &&
        <span style={{ color: 'red' }}>
          {`Text is too long! Must be ${limit} characters`}
        </span>
      }
    </div>
  );
};

export default TextLengthChecker;

We'll apply this one to the injector as well, but let's do it under a different name. For the purposes of demonstration, let's imagine this customisation comes from another module.

// my-module/js/main.js
import Injector from 'lib/Injector';
import TextLengthChecker from './components/TextLengthChecker';

Injector.transform('text-length-transform', (updater) => {
  updater.component('TextField', TextLengthChecker);
});

Now, both components have applied themselves to the textfield.

a text field with a lot of text in it, a character count showing "character count: 47", and an error message saying "Text is too long! Must be 40 characters"

Getting the customisations to work together

Both these enhancements are nice, but what would be even better is if they could work together collaboratively so that the character count only appeared when the user input got within a certain range of the limit. In order to do that, we'll need to be sure that the TextLengthChecker customisation is loaded ahead of the CharacterCounter customisation.

First let's update the character counter to show characters remaining, which is much more useful. We'll also update the API to allow a warningBuffer prop. This is the amount of characters the input can be within the limit before the warning shows.

// my-module/js/components/CharacterCounter.js
import React from 'react';

const CharacterCounter = (TextField) => (props) => {
  const { warningBuffer, limit, value: { length } } = props;
  const remainingChars = limit - length;
  const showWarning = length + warningBuffer >= limit;
  return (
    <div>
      <TextField {...props} />
      {showWarning &&
        <small>Characters remaining: {remainingChars}</small>
            }
    </div>
  );
};

export default CharacterCounter;

Now, when we apply this customisation, we need to be sure it loads after the length checker in the middleware chain, as it relies on the prop limit.

For this example, we'll imagine these two enhancements come from different modules.

// module-a/js/main.js
import Injector from 'lib/Injector';
import CharacterCounter from './components/CharacterCounter';

Injector.transform(
  'character-count-transform',
  (update) => update.component('TextField', CharacterCounter),
  { after: 'text-length-transform' }
);
// module-b/js/main.js
import Injector from 'lib/Injector';
import TextLengthChecker from './components/TextLengthChecker';

Injector.transform(
  'text-length-transform',
  (updater) => updater.component('TextField', TextLengthChecker),
  { before: 'character-count-transform' }
);

Now, both components, coming from different modules, play together nicely, in the correct order.

a text field with a lot of text in it, a character count showing "characters remaining: -7", and an error message saying "Text is too long! Must be 40 characters"

Adding context

We've successfully changed the behaviour and UI of our TextField component using two different two separate higher order components. By default, these are global changes. That is, every text field rendered by React will receive the enhancements we've put into the injector. Though this may sometimes be useful, more often than not, we only want to add our enhancements in certain contexts. You may, for instance, only want your character counter to display on one specific field in one specific form.

Let's apply our transformation to just the file edit form in AssetAdmin.

// my-module/js/main.js
import Injector from 'lib/Injector';
import TextLengthChecker from './components/TextLengthChecker';

Injector.transform('text-length-transform', (updater) => {
  updater.component('TextField.AssetAdmin.FileEditForm', TextLengthChecker);
});

A better form action: dealing with events

Let's make a new customisation that customises the behaviour of a button. We'll have all form actions throw a window.confirm() message before executing their action. Further, we'll apply some new style to the button if it is in a loading state.

// my-module/js/components/ConfirmingFormButton.js
import React from 'react';

export default (FormAction) => (props) => {
  const newProps = {
    ...props,
    data: {
      ...props.data,
      buttonStyle: props.loading ? 'danger' : props.data.buttonStyle
    },
    handleClick(e) {
      /* eslint-disable-next-line no-alert */
      if (window.confirm('Did you really mean to click this?')) {
        props.handleClick(e);
      }
    }
  };

  return <FormAction {...newProps} />;
};
// my-module/js/main.js
import ConfirmingFormButton from './components/ConfirmingFormButton';

Injector.transform('confirming-button-transform', (updater) => {
  updater.component('FormAction', ConfirmingFormButton, 'ConfirmingFormButton');
});

Now, when you click on any form action, it will throw a confirm window before firing its given click handler.