0
0
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:
Karthik Ayangar 2024-03-18 13:41:46 +05:30 committed by LB (Ben Johnston)
parent 6e1bca24e5
commit d2405eefe8
3 changed files with 213 additions and 0 deletions

View 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.',
);
});
});

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

View File

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