Browse Source

Set up initial stimulus application integration

LB Johnston 2 years ago
parent
commit
ede189ada5

+ 44 - 0
.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: [

+ 9 - 0
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.

+ 8 - 0
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[] = [
+  /* .. */
+];

+ 6 - 0
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')

+ 80 - 0
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 }) => (
+  <StimulusWrapper
+    debug={debug}
+    definitions={[
+      { controllerConstructor: ExampleDiceController, identifier: 'dice' },
+    ]}
+  >
+    <p
+      data-controller="dice"
+      {...(number && { 'data-dice-number-value': number })}
+    >
+      <button type="button" className="button w-mr-3" data-action="dice#roll">
+        Roll the dice
+      </button>
+      <kbd
+        data-dice-target="element"
+        style={{
+          display: 'inline-block',
+          minWidth: '4ch',
+          textAlign: 'center',
+        }}
+      />
+    </p>
+  </StimulusWrapper>
+);
+
+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 });

+ 232 - 0
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 = `<output name='word-count' for='${this.element.id}'></output>`;
+    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 = `<output name='word-count' for='${this.element.id}' style='float: right;'></output>`;
+    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 = `
+    <main>
+      <section data-controller="w-test-mock">
+        <div id="item" data-w-test-mock-target="item"></div>
+      </section>
+    </main>`;
+  });
+
+  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 = `<input value="some words" id="example-a-input" data-controller="example-a" data-action="change->example-a#updateCount" />`;
+
+    // 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 = `<input value="some words" id="example-b-input" data-controller="example-b" data-action="change->example-b#updateCount" data-example-b-max-value="5" />`;
+
+    // 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);
+  });
+});

+ 81 - 0
client/src/includes/initStimulus.ts

@@ -0,0 +1,81 @@
+import type { Definition } from '@hotwired/stimulus';
+import { Application, Controller } from '@hotwired/stimulus';
+
+type ControllerObjectDefinition = Record<string, () => 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<X extends Element> extends Controller<X> {}
+
+    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;
+};

+ 56 - 0
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 }) =>
+ *   <StimulusWrapper
+ *     definitions={[{ controllerConstructor: AutoFieldController, identifier: 'w-something' }]}
+ *     debug={debug}
+ *   >
+ *     <form data-controller="w-something" />
+ *   </StimulusWrapper>
+ */
+export class StimulusWrapper extends React.Component<{
+  debug?: boolean;
+  definitions?: Definition[];
+}> {
+  ref: React.RefObject<HTMLDivElement>;
+  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 <div ref={this.ref}>{children}</div>;
+  }
+}

+ 11 - 0
package-lock.json

@@ -8,6 +8,7 @@
       "name": "wagtail",
       "version": "1.0.0",
       "dependencies": {
+        "@hotwired/stimulus": "^3.2.1",
         "@tippyjs/react": "^4.2.6",
         "a11y-dialog": "^7.4.0",
         "draft-js": "^0.10.5",
@@ -2052,6 +2053,11 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@hotwired/stimulus": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.1.tgz",
+      "integrity": "sha512-HGlzDcf9vv/EQrMJ5ZG6VWNs8Z/xMN+1o2OhV1gKiSG6CqZt5MCBB1gRg5ILiN3U0jEAxuDTNPRfBcnZBDmupQ=="
+    },
     "node_modules/@humanwhocodes/config-array": {
       "version": "0.9.3",
       "dev": true,
@@ -32867,6 +32873,11 @@
       "version": "1.1.3",
       "dev": true
     },
+    "@hotwired/stimulus": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.1.tgz",
+      "integrity": "sha512-HGlzDcf9vv/EQrMJ5ZG6VWNs8Z/xMN+1o2OhV1gKiSG6CqZt5MCBB1gRg5ILiN3U0jEAxuDTNPRfBcnZBDmupQ=="
+    },
     "@humanwhocodes/config-array": {
       "version": "0.9.3",
       "dev": true,

+ 1 - 0
package.json

@@ -102,6 +102,7 @@
     "webpack-cli": "^4.9.1"
   },
   "dependencies": {
+    "@hotwired/stimulus": "^3.2.1",
     "@tippyjs/react": "^4.2.6",
     "a11y-dialog": "^7.4.0",
     "draft-js": "^0.10.5",