kopia lustrzana https://github.com/wagtail/wagtail
				
				
				
			Refactor Stimulus util & module export
- 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 #10197pull/11067/head
							rodzic
							
								
									5c92c8cb8d
								
							
						
					
					
						commit
						1da3e5fde7
					
				| 
						 | 
				
			
			@ -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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -225,11 +248,3 @@ $(() => {
 | 
			
		|||
      $(this).removeClass('hovered');
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// =============================================================================
 | 
			
		||||
// Wagtail global module, mainly useful for debugging.
 | 
			
		||||
// =============================================================================
 | 
			
		||||
 | 
			
		||||
const wagtail = window.wagtail || {};
 | 
			
		||||
 | 
			
		||||
window.wagtail = wagtail;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,3 @@
 | 
			
		|||
import { Icon, Portal } from '../..';
 | 
			
		||||
import { initTooltips } from '../../includes/initTooltips';
 | 
			
		||||
import { initTabs } from '../../includes/tabs';
 | 
			
		||||
import initSidePanel from '../../includes/sidePanel';
 | 
			
		||||
| 
						 | 
				
			
			@ -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.
 | 
			
		||||
 */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,17 +0,0 @@
 | 
			
		|||
jest.mock('../..');
 | 
			
		||||
 | 
			
		||||
document.addEventListener = jest.fn();
 | 
			
		||||
 | 
			
		||||
require('./wagtailadmin');
 | 
			
		||||
 | 
			
		||||
describe('wagtailadmin', () => {
 | 
			
		||||
  const [event] = document.addEventListener.mock.calls[0];
 | 
			
		||||
 | 
			
		||||
  it('exposes components for reuse', () => {
 | 
			
		||||
    expect(Object.keys(window.wagtail.components)).toEqual(['Icon', 'Portal']);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('DOMContentLoaded', () => {
 | 
			
		||||
    expect(event).toBe('DOMContentLoaded');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -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 } };
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
import { Application } from '@hotwired/stimulus';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 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.
 | 
			
		||||
 * 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;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 }) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Ładowanie…
	
		Reference in New Issue