diff --git a/client/src/controllers/BlockController.test.js b/client/src/controllers/BlockController.test.js new file mode 100644 index 0000000000..308fb004d3 --- /dev/null +++ b/client/src/controllers/BlockController.test.js @@ -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 = `
${html}
`; + + 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('
'); + + 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( + `
+
`, + ); + + 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( + `
+
`, + ); + + 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('
'); + + 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('
'); + + expect(errors).toHaveLength(1); + expect(errors).toHaveProperty( + '0.error.message', + '`window.telepath` is not available.', + ); + }); +}); diff --git a/client/src/controllers/BlockController.ts b/client/src/controllers/BlockController.ts new file mode 100644 index 0000000000..a240acf382 --- /dev/null +++ b/client/src/controllers/BlockController.ts @@ -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 + *
+ *
+ * + * @example - with initial arguments + *
+ *
+ */ +export class BlockController extends Controller { + 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; + /** 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); + } + }; + } +} diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts index a5635fbb87..1a19366bb8 100644 --- a/client/src/controllers/index.ts +++ b/client/src/controllers/index.ts @@ -3,6 +3,7 @@ import type { Definition } from '@hotwired/stimulus'; // Order controller imports alphabetically. import { ActionController } from './ActionController'; import { AutosizeController } from './AutosizeController'; +import { BlockController } from './BlockController'; import { BulkController } from './BulkController'; import { ClipboardController } from './ClipboardController'; import { CloneController } from './CloneController'; @@ -34,6 +35,7 @@ export const coreControllerDefinitions: Definition[] = [ // Keep this list in alphabetical order { controllerConstructor: ActionController, identifier: 'w-action' }, { controllerConstructor: AutosizeController, identifier: 'w-autosize' }, + { controllerConstructor: BlockController, identifier: 'w-block' }, { controllerConstructor: BulkController, identifier: 'w-bulk' }, { controllerConstructor: ClipboardController, identifier: 'w-clipboard' }, { controllerConstructor: CloneController, identifier: 'w-clone' },