Set up initial stimulus application integration

pull/9807/head^2
LB Johnston 2022-08-23 20:43:35 +10:00 zatwierdzone przez Thibaud Colas
rodzic 70681ec2bb
commit ede189ada5
10 zmienionych plików z 528 dodań i 0 usunięć

Wyświetl plik

@ -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 dont want to enforce for test and tooling code.
{
files: [

Wyświetl plik

@ -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.

Wyświetl plik

@ -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[] = [
/* .. */
];

Wyświetl plik

@ -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')

Wyświetl plik

@ -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 });

Wyświetl plik

@ -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);
});
});

Wyświetl plik

@ -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;
};

Wyświetl plik

@ -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
package-lock.json wygenerowano
Wyświetl plik

@ -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,

Wyświetl plik

@ -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",