From 24bd3a94472a89a95df61682b9c758ced9806b81 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Mon, 19 Sep 2022 17:10:44 -0400 Subject: [PATCH] [Live] Rendering any errors in a simple modal --- .../assets/dist/live_controller.js | 53 ++++++++++++-- .../assets/src/live_controller.ts | 64 +++++++++++++++-- .../assets/test/controller/error.test.ts | 72 +++++++++++++++++++ src/LiveComponent/assets/test/tools.ts | 27 ++++++- .../AddLiveAttributesSubscriber.php | 9 +++ .../EventListener/LiveComponentSubscriber.php | 4 +- .../src/LiveComponentHydrator.php | 2 +- 7 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 src/LiveComponent/assets/test/controller/error.test.ts diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 8bf88a4f134..935d93d1bf2 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1434,18 +1434,21 @@ class default_1 extends Controller { const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions); const reRenderPromise = new ReRenderPromise(thisPromise, this.unsyncedInputs.clone()); this.renderPromiseStack.addPromise(reRenderPromise); - thisPromise.then((response) => { + thisPromise.then(async (response) => { if (action) { this.isActionProcessing = false; } + const html = await response.text(); + if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') { + this.renderError(html); + return; + } if (this.renderDebounceTimeout) { return; } const isMostRecent = this.renderPromiseStack.removePromise(thisPromise); if (isMostRecent) { - response.text().then((html) => { - this._processRerender(html, response, reRenderPromise.unsyncedInputContainer); - }); + this._processRerender(html, response, reRenderPromise.unsyncedInputContainer); } }); } @@ -1832,6 +1835,48 @@ class default_1 extends Controller { clearInterval(interval); }); } + async renderError(html) { + let modal = document.getElementById('live-component-error'); + if (modal) { + modal.innerHTML = ''; + } + else { + modal = document.createElement('div'); + modal.id = 'live-component-error'; + modal.style.padding = '50px'; + modal.style.backgroundColor = 'rgba(0, 0, 0, .5)'; + modal.style.zIndex = '100000'; + modal.style.position = 'fixed'; + modal.style.width = '100vw'; + modal.style.height = '100vh'; + } + const iframe = document.createElement('iframe'); + iframe.style.borderRadius = '5px'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + modal.appendChild(iframe); + document.body.prepend(modal); + document.body.style.overflow = 'hidden'; + if (iframe.contentWindow) { + iframe.contentWindow.document.open(); + iframe.contentWindow.document.write(html); + iframe.contentWindow.document.close(); + } + const closeModal = (modal) => { + if (modal) { + modal.outerHTML = ''; + } + document.body.style.overflow = 'visible'; + }; + modal.addEventListener('click', () => closeModal(modal)); + modal.setAttribute('tabindex', '0'); + modal.addEventListener('keydown', e => { + if (e.key === 'Escape') { + closeModal(modal); + } + }); + modal.focus(); + } } default_1.values = { url: String, diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index ad7008bba8c..d7f1b4972fc 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -459,11 +459,19 @@ export default class extends Controller implements LiveController { const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions); const reRenderPromise = new ReRenderPromise(thisPromise, this.unsyncedInputs.clone()); this.renderPromiseStack.addPromise(reRenderPromise); - thisPromise.then((response) => { + thisPromise.then(async (response) => { if (action) { this.isActionProcessing = false; } + // if the response does not contain a component, render as an error + const html = await response.text(); + if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') { + this.renderError(html); + + return; + } + // if another re-render is scheduled, do not "run it over" if (this.renderDebounceTimeout) { return; @@ -471,9 +479,7 @@ export default class extends Controller implements LiveController { const isMostRecent = this.renderPromiseStack.removePromise(thisPromise); if (isMostRecent) { - response.text().then((html) => { - this._processRerender(html, response, reRenderPromise.unsyncedInputContainer); - }); + this._processRerender(html, response, reRenderPromise.unsyncedInputContainer); } }) } @@ -1038,6 +1044,56 @@ export default class extends Controller implements LiveController { clearInterval(interval); }); } + + // inspired by Livewire! + private async renderError(html: string) { + let modal = document.getElementById('live-component-error'); + if (modal) { + modal.innerHTML = ''; + } else { + modal = document.createElement('div'); + modal.id = 'live-component-error'; + modal.style.padding = '50px'; + modal.style.backgroundColor = 'rgba(0, 0, 0, .5)'; + modal.style.zIndex = '100000'; + modal.style.position = 'fixed'; + modal.style.width = '100vw'; + modal.style.height = '100vh'; + } + + const iframe = document.createElement('iframe'); + iframe.style.borderRadius = '5px'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + modal.appendChild(iframe); + + document.body.prepend(modal); + document.body.style.overflow = 'hidden'; + if (iframe.contentWindow) { + iframe.contentWindow.document.open(); + iframe.contentWindow.document.write(html); + iframe.contentWindow.document.close(); + } + + const closeModal = (modal: HTMLElement|null) => { + if (modal) { + modal.outerHTML = '' + } + document.body.style.overflow = 'visible' + } + + // close on click + modal.addEventListener('click', () => closeModal(modal)); + + // close on escape + modal.setAttribute('tabindex', '0'); + modal.addEventListener('keydown', e => { + if (e.key === 'Escape') { + closeModal(modal); + } + }); + modal.focus(); + } } /** diff --git a/src/LiveComponent/assets/test/controller/error.test.ts b/src/LiveComponent/assets/test/controller/error.test.ts new file mode 100644 index 00000000000..035ddec8548 --- /dev/null +++ b/src/LiveComponent/assets/test/controller/error.test.ts @@ -0,0 +1,72 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import { createTest, initComponent, shutdownTest } from '../tools'; +import { getByText, waitFor } from '@testing-library/dom'; + +describe('LiveController Error Handling', () => { + afterEach(() => { + shutdownTest(); + }) + + it('displays an error modal on 500 errors', async () => { + const test = await createTest({ }, (data: any) => ` +
+ Original component text + +
+ `); + + // ONLY a post is sent, not a re-render GET + test.expectsAjaxCall('post') + .expectSentData(test.initialData) + .serverWillReturnCustomResponse(500, ` + Error!

An error occurred

+ `) + .expectActionCalled('save') + .init(); + + getByText(test.element, 'Save').click(); + + await waitFor(() => expect(document.getElementById('live-component-error')).not.toBeNull()); + // the component did not change or re-render + expect(test.element).toHaveTextContent('Original component text'); + const errorContainer = document.getElementById('live-component-error'); + if (!errorContainer) { + throw new Error('containing missing'); + } + expect(errorContainer.querySelector('iframe')).not.toBeNull(); + }); + + it('displays a modal on any non-component response', async () => { + const test = await createTest({ }, (data: any) => ` +
+ Original component text + +
+ `); + + // ONLY a post is sent, not a re-render GET + test.expectsAjaxCall('post') + .expectSentData(test.initialData) + .serverWillReturnCustomResponse(200, ` + Hi!

I'm a whole page, not a component!

+ `) + .expectActionCalled('save') + .init(); + + getByText(test.element, 'Save').click(); + + await waitFor(() => expect(document.getElementById('live-component-error')).not.toBeNull()); + // the component did not change or re-render + expect(test.element).toHaveTextContent('Original component text'); + }); +}); diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 26678f841c4..4f40c4936c5 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -120,6 +120,8 @@ class MockedAjaxCall { options: any = {}; fetchMock?: typeof fetchMock; routeName?: string; + customResponseStatusCode?: number; + customResponseHTML?: string; constructor(method: string, test: FunctionalTest) { this.method = method.toUpperCase(); @@ -180,9 +182,24 @@ class MockedAjaxCall { // use custom template, or the main one const template = this.template ? this.template : this.test.template; + let response; + if (this.customResponseStatusCode) { + response = { + body: this.customResponseHTML, + status: this.customResponseStatusCode + } + } else { + response = { + body: template(finalServerData), + headers: { + 'Content-Type': 'application/vnd.live-component+html' + } + } + } + this.fetchMock = fetchMock.mock( this.getMockMatcher(), - template(finalServerData), + response, this.options ); } @@ -201,6 +218,14 @@ class MockedAjaxCall { return this; } + serverWillReturnCustomResponse(statusCode: number, responseHTML: string): MockedAjaxCall { + this.checkInitialization('serverWillReturnAnError'); + this.customResponseStatusCode = statusCode; + this.customResponseHTML = responseHTML; + + return this; + } + getVisualSummary(): string { const requestInfo = []; requestInfo.push(` METHOD: ${this.method}`); diff --git a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php index 5c7f629bc3e..76e6004caed 100644 --- a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php +++ b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\LiveComponent\EventListener; use Psr\Container\ContainerInterface; diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 78d479064bc..9c38f70d018 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -232,7 +232,9 @@ private function createResponse(MountedComponent $mounted): Response $component->{$method->name}(); } - return new Response($this->container->get(ComponentRenderer::class)->render($mounted)); + return new Response($this->container->get(ComponentRenderer::class)->render($mounted), 200, [ + 'Content-Type' => self::HTML_CONTENT_TYPE, + ]); } private function isLiveComponentRequest(Request $request): bool diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php index 907731522ed..6e1e449edd9 100644 --- a/src/LiveComponent/src/LiveComponentHydrator.php +++ b/src/LiveComponent/src/LiveComponentHydrator.php @@ -235,7 +235,7 @@ private function verifyChecksum(array $data, array $readonlyProperties): void } if (!hash_equals($this->computeChecksum($data, $readonlyProperties), $data[self::CHECKSUM_KEY])) { - throw new UnprocessableEntityHttpException('Invalid checksum!'); + throw new UnprocessableEntityHttpException('Invalid checksum. This usually means that you tried to change a property that is not writable: true.'); } }