mirror of
https://github.com/wagtail/wagtail.git
synced 2024-11-25 05:02:57 +01:00
Add Stimulus BlockController
This commit is contained in:
parent
6e1bca24e5
commit
d2405eefe8
125
client/src/controllers/BlockController.test.js
Normal file
125
client/src/controllers/BlockController.test.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { Application } from '@hotwired/stimulus';
|
||||||
|
import { BlockController } from './BlockController';
|
||||||
|
|
||||||
|
const render = jest.fn();
|
||||||
|
const unpack = jest.fn(() => ({ render }));
|
||||||
|
window.telepath = { unpack };
|
||||||
|
|
||||||
|
describe('BlockController', () => {
|
||||||
|
const eventNames = ['w-block:ready'];
|
||||||
|
|
||||||
|
const events = {};
|
||||||
|
|
||||||
|
eventNames.forEach((name) => {
|
||||||
|
document.addEventListener(name, (event) => {
|
||||||
|
events[name].push(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let application;
|
||||||
|
let errors = [];
|
||||||
|
|
||||||
|
const setup = (html, { identifier = 'w-block' } = {}) => {
|
||||||
|
document.body.innerHTML = `<main>${html}</main>`;
|
||||||
|
|
||||||
|
application = new Application();
|
||||||
|
|
||||||
|
application.register(identifier, BlockController);
|
||||||
|
|
||||||
|
application.handleError = (error, message) => {
|
||||||
|
errors.push({ error, message });
|
||||||
|
};
|
||||||
|
|
||||||
|
application.start();
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
application?.stop();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
errors = [];
|
||||||
|
eventNames.forEach((name) => {
|
||||||
|
events[name] = [];
|
||||||
|
});
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if block element is not found', async () => {
|
||||||
|
await setup('<div></div>');
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
|
||||||
|
expect(unpack).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(events['w-block:ready']).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render block if element is controlled', async () => {
|
||||||
|
const data = { _args: ['...'], _type: 'wagtail.blocks.StreamBlock' };
|
||||||
|
|
||||||
|
await setup(
|
||||||
|
`<div
|
||||||
|
id="my-element"
|
||||||
|
data-controller="w-block"
|
||||||
|
data-w-block-data-value='${JSON.stringify(data)}'
|
||||||
|
>
|
||||||
|
</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(unpack).toHaveBeenCalledWith(data);
|
||||||
|
expect(render).toHaveBeenCalledWith(
|
||||||
|
document.getElementById('my-element'),
|
||||||
|
'my-element',
|
||||||
|
);
|
||||||
|
expect(events['w-block:ready']).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the unpacked render function with provided initial & error data', async () => {
|
||||||
|
const data = { _args: ['...'], _type: 'wagtail.blocks.StreamBlock' };
|
||||||
|
const initialData = [{ type: 'paragraph_block', value: '...' }];
|
||||||
|
const errorData = { messages: ['An error...'] };
|
||||||
|
|
||||||
|
await setup(
|
||||||
|
`<div
|
||||||
|
id="my-element"
|
||||||
|
data-controller="w-block"
|
||||||
|
data-w-block-arguments-value='${JSON.stringify([initialData, errorData])}'
|
||||||
|
data-w-block-data-value='${JSON.stringify(data)}'
|
||||||
|
>
|
||||||
|
</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(unpack).toHaveBeenCalledWith(data);
|
||||||
|
expect(render).toHaveBeenCalledWith(
|
||||||
|
document.getElementById('my-element'),
|
||||||
|
'my-element',
|
||||||
|
initialData,
|
||||||
|
errorData,
|
||||||
|
);
|
||||||
|
expect(events['w-block:ready']).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if used on an element without an id', async () => {
|
||||||
|
await setup('<div data-controller="w-block"></div>');
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors).toHaveProperty(
|
||||||
|
'0.error.message',
|
||||||
|
'Controlled element needs an id attribute.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if Telepath is not available in the window global', async () => {
|
||||||
|
delete window.telepath;
|
||||||
|
await setup('<div id="my-element" data-controller="w-block"></div>');
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors).toHaveProperty(
|
||||||
|
'0.error.message',
|
||||||
|
'`window.telepath` is not available.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
86
client/src/controllers/BlockController.ts
Normal file
86
client/src/controllers/BlockController.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
initBlockWidget?: (id: string) => void;
|
||||||
|
telepath: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the ability to unpack a Telepath object and render it on the controlled element.
|
||||||
|
* Used to initialize the top-level element of a BlockWidget (the form widget for a StreamField).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <div
|
||||||
|
* id="some-id"
|
||||||
|
* data-controller="w-block"
|
||||||
|
* data-w-block-data-value='{"_args":["..."], "_type": "wagtail.blocks.StreamBlock"}'
|
||||||
|
* >
|
||||||
|
* </div>
|
||||||
|
*
|
||||||
|
* @example - with initial arguments
|
||||||
|
* <div
|
||||||
|
* id="some-id"
|
||||||
|
* data-controller="w-block"
|
||||||
|
* data-w-block-data-value='{"_args":["..."], "_type": "wagtail.blocks.StreamBlock"}'
|
||||||
|
* data-w-block-arguments-value='[[{ type: "paragraph_block", value: "..."}], {messages:["An error..."]}]'
|
||||||
|
* >
|
||||||
|
* </div>
|
||||||
|
*/
|
||||||
|
export class BlockController extends Controller<HTMLElement> {
|
||||||
|
static values = {
|
||||||
|
arguments: { type: Array, default: [] },
|
||||||
|
data: { type: Object, default: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Array of arguments to pass to the render method of the block [initial value, errors]. */
|
||||||
|
declare argumentsValue: Array<string>;
|
||||||
|
/** Block definition to be passed to `telepath.unpack`, used to obtain a JavaScript representation of the block. */
|
||||||
|
declare dataValue: object;
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
const telepath = window.telepath;
|
||||||
|
|
||||||
|
if (!telepath) {
|
||||||
|
throw new Error('`window.telepath` is not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = this.element;
|
||||||
|
const id = element.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new Error('Controlled element needs an id attribute.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = telepath.unpack(this.dataValue);
|
||||||
|
output.render(element, id, ...this.argumentsValue);
|
||||||
|
this.dispatch('ready', { detail: { ...output }, cancelable: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
static afterLoad() {
|
||||||
|
/**
|
||||||
|
* Provide a backwards compatible version of the original window global function.
|
||||||
|
*
|
||||||
|
* @deprecated RemovedInWagtail70
|
||||||
|
*/
|
||||||
|
window.initBlockWidget = (id: string) => {
|
||||||
|
const body = document.querySelector(
|
||||||
|
'#' + id + '[data-block]',
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockDefData = JSON.parse(body.dataset.data as string);
|
||||||
|
if (window.telepath) {
|
||||||
|
const blockDef = window.telepath.unpack(blockDefData);
|
||||||
|
const blockValue = JSON.parse(body.dataset.value as string);
|
||||||
|
const blockError = JSON.parse(body.dataset.error as string);
|
||||||
|
|
||||||
|
blockDef.render(body, id, blockValue, blockError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@ import type { Definition } from '@hotwired/stimulus';
|
|||||||
// Order controller imports alphabetically.
|
// Order controller imports alphabetically.
|
||||||
import { ActionController } from './ActionController';
|
import { ActionController } from './ActionController';
|
||||||
import { AutosizeController } from './AutosizeController';
|
import { AutosizeController } from './AutosizeController';
|
||||||
|
import { BlockController } from './BlockController';
|
||||||
import { BulkController } from './BulkController';
|
import { BulkController } from './BulkController';
|
||||||
import { ClipboardController } from './ClipboardController';
|
import { ClipboardController } from './ClipboardController';
|
||||||
import { CloneController } from './CloneController';
|
import { CloneController } from './CloneController';
|
||||||
@ -34,6 +35,7 @@ export const coreControllerDefinitions: Definition[] = [
|
|||||||
// Keep this list in alphabetical order
|
// Keep this list in alphabetical order
|
||||||
{ controllerConstructor: ActionController, identifier: 'w-action' },
|
{ controllerConstructor: ActionController, identifier: 'w-action' },
|
||||||
{ controllerConstructor: AutosizeController, identifier: 'w-autosize' },
|
{ controllerConstructor: AutosizeController, identifier: 'w-autosize' },
|
||||||
|
{ controllerConstructor: BlockController, identifier: 'w-block' },
|
||||||
{ controllerConstructor: BulkController, identifier: 'w-bulk' },
|
{ controllerConstructor: BulkController, identifier: 'w-bulk' },
|
||||||
{ controllerConstructor: ClipboardController, identifier: 'w-clipboard' },
|
{ controllerConstructor: ClipboardController, identifier: 'w-clipboard' },
|
||||||
{ controllerConstructor: CloneController, identifier: 'w-clone' },
|
{ controllerConstructor: CloneController, identifier: 'w-clone' },
|
||||||
|
Loading…
Reference in New Issue
Block a user