kopia lustrzana https://github.com/wagtail/wagtail
Set up initial stimulus application integration
rodzic
70681ec2bb
commit
ede189ada5
44
.eslintrc.js
44
.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: [
|
||||
|
|
|
@ -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.
|
|
@ -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[] = [
|
||||
/* .. */
|
||||
];
|
|
@ -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')
|
||||
|
|
|
@ -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 });
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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>;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
Ładowanie…
Reference in New Issue