Skip to content

Commit

Permalink
Refactor Stimulus util & module export
Browse files Browse the repository at this point in the history
- Accessing constructor was confusing and createController is not a core requirement
- Avoid modifying the base Stimulus application class
- Expose `window.StimulusModule` as the module output
- Expose `window.wagtail.app` as the Wagtail Stimulus application instance
- Rename root element variable to `root` in initStimulus (so we do not conflict with potential action option registration that uses `element` variable names)
- Pull in the wagtail.components to the same core.js file and add JSDoc for exposed global
- Relates to wagtail#10197
  • Loading branch information
lb- authored and thibaudcolas committed Oct 19, 2023
1 parent 5c92c8c commit 1da3e5f
Show file tree
Hide file tree
Showing 7 changed files with 71 additions and 206 deletions.
35 changes: 25 additions & 10 deletions client/src/entrypoints/admin/core.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
import $ from 'jquery';
import * as StimulusModule from '@hotwired/stimulus';

import { Icon, Portal } from '../..';
import { coreControllerDefinitions } from '../../controllers';
import { escapeHtml } from '../../utils/text';
import { initStimulus } from '../../includes/initStimulus';

/** initialise Wagtail Stimulus application with core controller definitions */
window.Stimulus = initStimulus({ definitions: coreControllerDefinitions });
/** Expose a global to allow for customisations and packages to build with Stimulus. */
window.StimulusModule = StimulusModule;

/**
* Wagtail global module, useful for debugging and as the exposed
* interface to access the Stimulus application instance and base
* React components.
*
* @type {Object} wagtail
* @property {Object} app - Wagtail's Stimulus application instance.
* @property {Object} components - Exposed components as globals for third-party reuse.
* @property {Object} components.Icon - Icon React component.
* @property {Object} components.Portal - Portal React component.
*/
const wagtail = window.wagtail || {};

/** Initialise Wagtail Stimulus application with core controller definitions. */
wagtail.app = initStimulus({ definitions: coreControllerDefinitions });

/** Expose components as globals for third-party reuse. */
wagtail.components = { Icon, Portal };

window.wagtail = wagtail;

window.escapeHtml = escapeHtml;

Expand Down Expand Up @@ -225,11 +248,3 @@ $(() => {
$(this).removeClass('hovered');
});
});

// =============================================================================
// Wagtail global module, mainly useful for debugging.
// =============================================================================

const wagtail = window.wagtail || {};

window.wagtail = wagtail;
32 changes: 32 additions & 0 deletions client/src/entrypoints/admin/core.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
jest.mock('../..');

document.addEventListener = jest.fn();

require('./core');

describe('core', () => {
const [event] = document.addEventListener.mock.calls[0];

it('exposes the Stimulus application instance for reuse', () => {
expect(Object.keys(window.wagtail.app)).toEqual(
expect.arrayContaining(['debug', 'logger']),
);

expect(window.wagtail.app.load).toBeInstanceOf(Function);
expect(window.wagtail.app.register).toBeInstanceOf(Function);
});

it('exposes components for reuse', () => {
expect(Object.keys(window.wagtail.components)).toEqual(['Icon', 'Portal']);
});

it('exposes the Stimulus module for reuse', () => {
expect(Object.keys(window.StimulusModule)).toEqual(
expect.arrayContaining(['Application', 'Controller']),
);
});

it('DOMContentLoaded', () => {
expect(event).toBe('DOMContentLoaded');
});
});
7 changes: 0 additions & 7 deletions client/src/entrypoints/admin/wagtailadmin.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Icon, Portal } from '../..';
import { initTooltips } from '../../includes/initTooltips';
import { initTabs } from '../../includes/tabs';
import initSidePanel from '../../includes/sidePanel';
Expand All @@ -8,12 +7,6 @@ import {
} from '../../includes/panels';
import { initMinimap } from '../../components/Minimap';

// Expose components as globals for third-party reuse.
window.wagtail.components = {
Icon,
Portal,
};

/**
* Add in here code to run once the page is loaded.
*/
Expand Down
17 changes: 0 additions & 17 deletions client/src/entrypoints/admin/wagtailadmin.test.js

This file was deleted.

105 changes: 2 additions & 103 deletions client/src/includes/initStimulus.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,7 @@ 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
* Example controller.
*/
class WordCountController extends Controller {
static values = { max: { default: 10, type: Number } };
Expand All @@ -51,7 +22,7 @@ class WordCountController extends Controller {
setupOutput() {
if (this.output) return;
const template = document.createElement('template');
template.innerHTML = `<output name='word-count' for='${this.element.id}' style='float: right;'></output>`;
template.innerHTML = `<output name='word-count' for='${this.element.id}' class='output-label'></output>`;
const output = template.content.firstChild;
this.element.insertAdjacentElement('beforebegin', output);
this.output = output;
Expand Down Expand Up @@ -115,52 +86,6 @@ describe('initStimulus', () => {
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';
Expand Down Expand Up @@ -203,30 +128,4 @@ describe('initStimulus', () => {
// 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);
});
});
77 changes: 10 additions & 67 deletions client/src/includes/initStimulus.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,24 @@
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;
};
}
import { Application } from '@hotwired/stimulus';

/**
* Initialises the Wagtail Stimulus application and dispatches and registers
* custom event behaviour.
* Initialises the Wagtail Stimulus application, loads the provided controller
* definitions and returns the app instance.
*
* Loads the supplied core controller definitions into the application.
* Turns on debug mode if in local development (for now).
* Turns on debug mode if in local development.
*/
export const initStimulus = ({
debug = process.env.NODE_ENV === 'development',
definitions = [],
element = document.documentElement,
root = document.documentElement,
}: {
debug?: boolean;
definitions?: Definition[];
element?: HTMLElement;
root?: HTMLElement;
} = {}): Application => {
const application = WagtailApplication.start(element);

application.debug = debug;
application.load(definitions);

return application;
const app = Application.start(root);
app.debug = debug;
app.load(definitions);
return app;
};
4 changes: 2 additions & 2 deletions client/storybook/StimulusWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export class StimulusWrapper extends React.Component<{

componentDidMount() {
const { debug = false, definitions = [] } = this.props;
const element = this.ref.current || document.documentElement;
this.application = initStimulus({ debug, definitions, element });
const root = this.ref.current || document.documentElement;
this.application = initStimulus({ debug, definitions, root });
}

componentDidUpdate({ debug: prevDebug }) {
Expand Down

0 comments on commit 1da3e5f

Please sign in to comment.