diff --git a/.eslintrc.js b/.eslintrc.js
index 03a4b73846..7814966052 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -95,6 +95,50 @@ module.exports = {
'react/require-default-props': 'off',
},
},
+ // Rules we want to enforce or change for Stimulus Controllers
+ {
+ files: ['*Controller.ts'],
+ rules: {
+ '@typescript-eslint/member-ordering': [
+ 'error',
+ {
+ classes: {
+ memberTypes: ['signature', 'field', 'method'],
+ },
+ },
+ ],
+ '@typescript-eslint/naming-convention': [
+ 'error',
+ {
+ selector: 'method',
+ format: ['camelCase'],
+ custom: {
+ // Use connect or initialize instead of constructor, avoid generic 'render' or 'update' methods and instead be more specific.
+ regex: '^(constructor|render|update)$',
+ match: false,
+ },
+ },
+ {
+ selector: 'property',
+ format: ['camelCase'],
+ custom: {
+ // Use Stimulus values where possible for internal state, avoid a generic state object as these are not reactive.
+ regex: '^(state)$',
+ match: false,
+ },
+ },
+ ],
+ 'no-restricted-properties': [
+ 'error',
+ {
+ object: 'window',
+ property: 'Stimulus',
+ message:
+ "Please import the base Controller or only access the Stimulus instance via the controller's `this.application` attribute.",
+ },
+ ],
+ },
+ },
// Rules we don’t want to enforce for test and tooling code.
{
files: [
diff --git a/client/src/controllers/README.md b/client/src/controllers/README.md
new file mode 100644
index 0000000000..767af56b59
--- /dev/null
+++ b/client/src/controllers/README.md
@@ -0,0 +1,9 @@
+# `src/controllers` folder
+
+**Important:** This is a migration in progress, any large refactors or new code should adopt this approach.
+
+- Wagtail uses [Stimulus](https://stimulus.hotwired.dev/) as a way to attach interactive behaviour to DOM elements.
+- This is a lightweight JavaScript framework that allows a JavaScript class to be attached to any DOM element that adheres to a specific usage of `data-` attributes on the element.
+- Each file within this folder should contain one Stimulus controller class, using a matching file name (for example `class MyAwesomeController, `MyAwesomeController.ts`, all TitleCase).
+- Controllers that are included in the `index.ts` default export will automatically be included in the core bundle and provided by default.
+- Stories need to be written as JavaScript for now - `MyController.stories.js` as the compiled JavaScript from StoryBook conflicts with Stimulus' usage of adding getters only on Controller instantiation.
diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts
new file mode 100644
index 0000000000..df502aa45e
--- /dev/null
+++ b/client/src/controllers/index.ts
@@ -0,0 +1,8 @@
+import type { Definition } from '@hotwired/stimulus';
+
+/**
+ * Important: Only add default core controllers that should load with the base admin JS bundle.
+ */
+export const coreControllerDefinitions: Definition[] = [
+ /* .. */
+];
diff --git a/client/src/entrypoints/admin/core.js b/client/src/entrypoints/admin/core.js
index 0869b987b3..b226b9b0f7 100644
--- a/client/src/entrypoints/admin/core.js
+++ b/client/src/entrypoints/admin/core.js
@@ -1,9 +1,15 @@
import $ from 'jquery';
+
+import { coreControllerDefinitions } from '../../controllers';
import { escapeHtml } from '../../utils/text';
import { initButtonSelects } from '../../includes/initButtonSelects';
+import { initStimulus } from '../../includes/initStimulus';
import { initTagField } from '../../includes/initTagField';
import { initTooltips } from '../../includes/initTooltips';
+/** initialise Wagtail Stimulus application with core controller definitions */
+window.Stimulus = initStimulus({ definitions: coreControllerDefinitions });
+
/* generic function for adding a message to message area through JS alone */
function addMessage(status, text) {
$('.messages')
diff --git a/client/src/includes/initStimulus.stories.js b/client/src/includes/initStimulus.stories.js
new file mode 100644
index 0000000000..534fc59e69
--- /dev/null
+++ b/client/src/includes/initStimulus.stories.js
@@ -0,0 +1,80 @@
+import React from 'react';
+
+import { Controller } from '@hotwired/stimulus';
+import { StimulusWrapper } from '../../storybook/StimulusWrapper';
+
+/**
+ * An example Stimulus controller that allows for an element to have
+ * a random dice value.
+ */
+class ExampleDiceController extends Controller {
+ static targets = ['element'];
+ static values = { number: { type: Number, default: 6 } };
+
+ connect() {
+ this.roll();
+ }
+
+ roll() {
+ const numberValue = this.numberValue;
+ const element = this.elementTarget;
+
+ const result = Math.floor(Math.random() * numberValue) + 1;
+
+ if (numberValue === 6) {
+ element.setAttribute('title', `${result}`);
+ element.textContent = `${['⚀', '⚁', '⚂', '⚃', '⚄', '⚅'][result - 1]}`;
+ return;
+ }
+
+ element.removeAttribute('title');
+ element.textContent = `${result}`;
+ }
+}
+
+const definitions = [
+ { controllerConstructor: ExampleDiceController, identifier: 'dice' },
+];
+
+const Template = ({ debug, number }) => (
+
+
+
+
+
+
+);
+
+export default {
+ title: 'Stimulus/Example',
+ argTypes: {
+ debug: {
+ control: { type: 'boolean' },
+ defaultValue: true,
+ },
+ number: {
+ control: { type: 'select' },
+ description: 'Dice sides',
+ options: [2, 4, 6, 10, 20],
+ },
+ },
+};
+
+export const Base = Template.bind({ debug: true });
diff --git a/client/src/includes/initStimulus.test.js b/client/src/includes/initStimulus.test.js
new file mode 100644
index 0000000000..fe949922a0
--- /dev/null
+++ b/client/src/includes/initStimulus.test.js
@@ -0,0 +1,232 @@
+import { Application, Controller } from '@hotwired/stimulus';
+import { initStimulus } from './initStimulus';
+
+jest.useFakeTimers();
+
+/**
+ * Example controller (shortcut method definitions object) from documentation
+ */
+const wordCountController = {
+ STATIC: {
+ values: { max: { default: 10, type: Number } },
+ },
+ connect() {
+ this.setupOutput();
+ this.updateCount();
+ },
+ setupOutput() {
+ if (this.output) return;
+ const template = document.createElement('template');
+ template.innerHTML = ``;
+ const output = template.content.firstChild;
+ this.element.insertAdjacentElement('beforebegin', output);
+ this.output = output;
+ },
+ updateCount(event) {
+ const value = event ? event.target.value : this.element.value;
+ const words = (value || '').split(' ');
+ this.output.textContent = `${words.length} / ${this.maxValue} words`;
+ },
+ disconnect() {
+ this.output && this.output.remove();
+ },
+};
+
+/**
+ * Example controller from documentation as an ES6 class
+ */
+class WordCountController extends Controller {
+ static values = { max: { default: 10, type: Number } };
+
+ connect() {
+ const output = document.createElement('output');
+ output.setAttribute('name', 'word-count');
+ output.setAttribute('for', this.element.id);
+ output.style.float = 'right';
+ this.element.insertAdjacentElement('beforebegin', output);
+ this.output = output;
+ this.updateCount();
+ }
+
+ setupOutput() {
+ if (this.output) return;
+ const template = document.createElement('template');
+ template.innerHTML = ``;
+ const output = template.content.firstChild;
+ this.element.insertAdjacentElement('beforebegin', output);
+ this.output = output;
+ }
+
+ updateCount(event) {
+ const value = event ? event.target.value : this.element.value;
+ const words = (value || '').split(' ');
+ this.output.textContent = `${words.length} / ${this.maxValue} words`;
+ }
+
+ disconnect() {
+ this.output && this.output.remove();
+ }
+}
+
+describe('initStimulus', () => {
+ const mockControllerConnected = jest.fn();
+
+ class TestMockController extends Controller {
+ static targets = ['item'];
+
+ connect() {
+ mockControllerConnected();
+ this.itemTargets.forEach((item) => {
+ item.setAttribute('hidden', '');
+ });
+ }
+ }
+
+ beforeAll(() => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ });
+
+ let application;
+
+ it('should initialise a stimulus application', () => {
+ const definitions = [
+ { identifier: 'w-test-mock', controllerConstructor: TestMockController },
+ ];
+
+ expect(mockControllerConnected).not.toHaveBeenCalled();
+
+ application = initStimulus({ debug: false, definitions });
+
+ expect(application).toBeInstanceOf(Application);
+ });
+
+ it('should have set the debug value based on the option provided', () => {
+ expect(application.debug).toEqual(false);
+ });
+
+ it('should have loaded the controller definitions supplied', () => {
+ expect(mockControllerConnected).toHaveBeenCalled();
+ expect(application.controllers).toHaveLength(1);
+ expect(application.controllers[0]).toBeInstanceOf(TestMockController);
+ });
+
+ it('should support registering a controller via an object with the createController static method', async () => {
+ const section = document.createElement('section');
+ section.id = 'example-a';
+ section.innerHTML = ``;
+
+ // create a controller and register it
+ application.register(
+ 'example-a',
+ application.constructor.createController(wordCountController),
+ );
+
+ // before controller element added - should not include an `output` element
+ expect(document.querySelector('#example-a > output')).toEqual(null);
+
+ document.querySelector('section').after(section);
+
+ await Promise.resolve({});
+
+ // after controller connected - should have an output element
+ expect(document.querySelector('#example-a > output').innerHTML).toEqual(
+ '2 / 10 words',
+ );
+
+ await Promise.resolve({});
+
+ // should respond to changes on the input
+ const input = document.querySelector('#example-a > input');
+ input.setAttribute('value', 'even more words');
+ input.dispatchEvent(new Event('change'));
+
+ expect(document.querySelector('#example-a > output').innerHTML).toEqual(
+ '3 / 10 words',
+ );
+
+ // removal of the input should also remove the output (disconnect method)
+ input.remove();
+
+ await Promise.resolve({});
+
+ // should call the disconnect method (removal of the injected HTML)
+ expect(document.querySelector('#example-a > output')).toEqual(null);
+
+ // clean up
+ section.remove();
+ });
+
+ it('should support the documented approach for registering a controller via a class with register', async () => {
+ const section = document.createElement('section');
+ section.id = 'example-b';
+ section.innerHTML = ``;
+
+ // register a controller
+ application.register('example-b', WordCountController);
+
+ // before controller element added - should not include an `output` element
+ expect(document.querySelector('#example-b > output')).toEqual(null);
+
+ document.querySelector('section').after(section);
+
+ await Promise.resolve({});
+
+ // after controller connected - should have an output element
+ expect(document.querySelector('#example-b > output').innerHTML).toEqual(
+ '2 / 5 words',
+ );
+
+ await Promise.resolve({});
+
+ // should respond to changes on the input
+ const input = document.querySelector('#example-b > input');
+ input.setAttribute('value', 'even more words');
+ input.dispatchEvent(new Event('change'));
+
+ expect(document.querySelector('#example-b > output').innerHTML).toEqual(
+ '3 / 5 words',
+ );
+
+ // removal of the input should also remove the output (disconnect method)
+ input.remove();
+
+ await Promise.resolve({});
+
+ // should call the disconnect method (removal of the injected HTML)
+ expect(document.querySelector('#example-b > output')).toEqual(null);
+
+ // clean up
+ section.remove();
+ });
+
+ it('should provide access to a base Controller class on the returned application instance', () => {
+ expect(application.constructor.Controller).toEqual(Controller);
+ });
+});
+
+describe('createController', () => {
+ const createController = initStimulus().constructor.createController;
+
+ it('should safely create a Stimulus Controller class if no args provided', () => {
+ const CustomController = createController();
+ expect(CustomController.prototype instanceof Controller).toBeTruthy();
+ });
+
+ it('should create a Stimulus Controller class with static properties', () => {
+ const someMethod = jest.fn();
+
+ const CustomController = createController({
+ STATIC: { targets: ['source'] },
+ someMethod,
+ });
+
+ expect(CustomController.targets).toEqual(['source']);
+ expect(CustomController.someMethod).toBeUndefined();
+ expect(CustomController.prototype.someMethod).toEqual(someMethod);
+ });
+});
diff --git a/client/src/includes/initStimulus.ts b/client/src/includes/initStimulus.ts
new file mode 100644
index 0000000000..617c480584
--- /dev/null
+++ b/client/src/includes/initStimulus.ts
@@ -0,0 +1,81 @@
+import type { Definition } from '@hotwired/stimulus';
+import { Application, Controller } from '@hotwired/stimulus';
+
+type ControllerObjectDefinition = Record void> & {
+ STATIC?: {
+ classes?: string[];
+ targets?: string[];
+ values: typeof Controller.values;
+ };
+};
+
+/**
+ * Extend the Stimulus application class to provide some convenience
+ * static attributes or methods to be accessed globally.
+ */
+class WagtailApplication extends Application {
+ /**
+ * Ensure the base Controller class is available for new controllers.
+ */
+ static Controller = Controller;
+
+ /**
+ * Function that accepts a plain old object and returns a Stimulus Controller.
+ * Useful when ES6 modules with base class being extended not in use
+ * or build tool not in use or for just super convenient class creation.
+ *
+ * Inspired heavily by
+ * https://github.com/StackExchange/Stacks/blob/v1.6.5/lib/ts/stacks.ts#L84
+ *
+ * @example
+ * createController({
+ * STATIC: { targets = ['container'] }
+ * connect() {
+ * console.log('connected', this.element, this.containerTarget);
+ * }
+ * })
+ *
+ */
+ static createController = (
+ controllerDefinition: ControllerObjectDefinition = {},
+ ): typeof Controller => {
+ class NewController extends Controller {}
+
+ const { STATIC = {}, ...controllerDefinitionWithoutStatic } =
+ controllerDefinition;
+
+ // set up static values
+ Object.entries(STATIC).forEach(([key, value]) => {
+ NewController[key] = value;
+ });
+
+ // set up class methods
+ Object.assign(NewController.prototype, controllerDefinitionWithoutStatic);
+
+ return NewController;
+ };
+}
+
+/**
+ * Initialises the Wagtail Stimulus application and dispatches and registers
+ * custom event behaviour.
+ *
+ * Loads the the supplied core controller definitions into the application.
+ * Turns on debug mode if in local development (for now).
+ */
+export const initStimulus = ({
+ debug = process.env.NODE_ENV === 'development',
+ definitions = [],
+ element = document.documentElement,
+}: {
+ debug?: boolean;
+ definitions?: Definition[];
+ element?: HTMLElement;
+} = {}): Application => {
+ const application = WagtailApplication.start(element);
+
+ application.debug = debug;
+ application.load(definitions);
+
+ return application;
+};
diff --git a/client/storybook/StimulusWrapper.tsx b/client/storybook/StimulusWrapper.tsx
new file mode 100644
index 0000000000..71cf658065
--- /dev/null
+++ b/client/storybook/StimulusWrapper.tsx
@@ -0,0 +1,56 @@
+import type { Application, Definition } from '@hotwired/stimulus';
+import React from 'react';
+import { initStimulus } from '../src/includes/initStimulus';
+
+/**
+ * Wrapper around the Stimulus application to ensure that the application
+ * is scoped to only the specific story instance's DOM and also ensure
+ * that the hot-reloader / page switches to not re-instate new applications
+ * each time.
+ *
+ * @example
+ * import { StimulusWrapper } from '../storybook/StimulusWrapper';
+ * const Template = ({ debug }) =>
+ *
+ *
+ *
+ */
+export class StimulusWrapper extends React.Component<{
+ debug?: boolean;
+ definitions?: Definition[];
+}> {
+ ref: React.RefObject;
+ application?: Application;
+
+ constructor(props) {
+ super(props);
+ this.ref = React.createRef();
+ }
+
+ componentDidMount() {
+ const { debug = false, definitions = [] } = this.props;
+ const element = this.ref.current || document.documentElement;
+ this.application = initStimulus({ debug, definitions, element });
+ }
+
+ componentDidUpdate({ debug: prevDebug }) {
+ const { debug } = this.props;
+ if (debug !== prevDebug) {
+ Object.assign(this.application as Application, { debug });
+ }
+ }
+
+ componentWillUnmount() {
+ if (!this.application) return;
+ this.application.stop();
+ delete this.application;
+ }
+
+ render() {
+ const { children } = this.props;
+ return