From ede189ada51b32847e9a35021963c0a24d7c2630 Mon Sep 17 00:00:00 2001 From: LB Johnston Date: Tue, 23 Aug 2022 20:43:35 +1000 Subject: [PATCH] Set up initial stimulus application integration --- .eslintrc.js | 44 ++++ client/src/controllers/README.md | 9 + client/src/controllers/index.ts | 8 + client/src/entrypoints/admin/core.js | 6 + client/src/includes/initStimulus.stories.js | 80 +++++++ client/src/includes/initStimulus.test.js | 232 ++++++++++++++++++++ client/src/includes/initStimulus.ts | 81 +++++++ client/storybook/StimulusWrapper.tsx | 56 +++++ package-lock.json | 11 + package.json | 1 + 10 files changed, 528 insertions(+) create mode 100644 client/src/controllers/README.md create mode 100644 client/src/controllers/index.ts create mode 100644 client/src/includes/initStimulus.stories.js create mode 100644 client/src/includes/initStimulus.test.js create mode 100644 client/src/includes/initStimulus.ts create mode 100644 client/storybook/StimulusWrapper.tsx 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
{children}
; + } +} diff --git a/package-lock.json b/package-lock.json index bcda1a9f07..561f1deeb6 100644 --- a/package-lock.json +++ b/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, diff --git a/package.json b/package.json index cddc9da8df..20470ee46e 100644 --- a/package.json +++ b/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",