Skip to content

Commit 589a3ea

Browse files
committed
feature #467 [Live] Rendering any errors in a simple modal (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Live] Rendering any errors in a simple modal | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | #102 Bugs G | License | MIT Beautiful error handling! Inspired by Livewire: <img width="1280" alt="Screen Shot 2022-09-19 at 4 10 35 PM" src="https://user-images.githubusercontent.com/121003/191117592-7a203e6c-0a18-4eb1-aa89-4e07698f5227.png"> You can even `dd()` from your code and that will render in the modal: <img width="1275" alt="Screen Shot 2022-09-19 at 5 10 29 PM" src="https://user-images.githubusercontent.com/121003/191117596-5bcdb75d-42c1-4fae-8af5-2007127c2fac.png"> If an Ajax call returns anything OTHER than a rendered component (i.e. there was SOME sort of error), it's rendered in a modal. This is mostly for development errors. The modal will show on production also (and would show, for example, the 500 production page), but that should not happen in normal situations. Cheers! Commits ------- 24bd3a9 [Live] Rendering any errors in a simple modal
2 parents 217f7eb + 24bd3a9 commit 589a3ea

File tree

7 files changed

+220
-11
lines changed

7 files changed

+220
-11
lines changed

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1434,18 +1434,21 @@ class default_1 extends Controller {
14341434
const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions);
14351435
const reRenderPromise = new ReRenderPromise(thisPromise, this.unsyncedInputs.clone());
14361436
this.renderPromiseStack.addPromise(reRenderPromise);
1437-
thisPromise.then((response) => {
1437+
thisPromise.then(async (response) => {
14381438
if (action) {
14391439
this.isActionProcessing = false;
14401440
}
1441+
const html = await response.text();
1442+
if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') {
1443+
this.renderError(html);
1444+
return;
1445+
}
14411446
if (this.renderDebounceTimeout) {
14421447
return;
14431448
}
14441449
const isMostRecent = this.renderPromiseStack.removePromise(thisPromise);
14451450
if (isMostRecent) {
1446-
response.text().then((html) => {
1447-
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
1448-
});
1451+
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
14491452
}
14501453
});
14511454
}
@@ -1832,6 +1835,48 @@ class default_1 extends Controller {
18321835
clearInterval(interval);
18331836
});
18341837
}
1838+
async renderError(html) {
1839+
let modal = document.getElementById('live-component-error');
1840+
if (modal) {
1841+
modal.innerHTML = '';
1842+
}
1843+
else {
1844+
modal = document.createElement('div');
1845+
modal.id = 'live-component-error';
1846+
modal.style.padding = '50px';
1847+
modal.style.backgroundColor = 'rgba(0, 0, 0, .5)';
1848+
modal.style.zIndex = '100000';
1849+
modal.style.position = 'fixed';
1850+
modal.style.width = '100vw';
1851+
modal.style.height = '100vh';
1852+
}
1853+
const iframe = document.createElement('iframe');
1854+
iframe.style.borderRadius = '5px';
1855+
iframe.style.width = '100%';
1856+
iframe.style.height = '100%';
1857+
modal.appendChild(iframe);
1858+
document.body.prepend(modal);
1859+
document.body.style.overflow = 'hidden';
1860+
if (iframe.contentWindow) {
1861+
iframe.contentWindow.document.open();
1862+
iframe.contentWindow.document.write(html);
1863+
iframe.contentWindow.document.close();
1864+
}
1865+
const closeModal = (modal) => {
1866+
if (modal) {
1867+
modal.outerHTML = '';
1868+
}
1869+
document.body.style.overflow = 'visible';
1870+
};
1871+
modal.addEventListener('click', () => closeModal(modal));
1872+
modal.setAttribute('tabindex', '0');
1873+
modal.addEventListener('keydown', e => {
1874+
if (e.key === 'Escape') {
1875+
closeModal(modal);
1876+
}
1877+
});
1878+
modal.focus();
1879+
}
18351880
}
18361881
default_1.values = {
18371882
url: String,

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -459,21 +459,27 @@ export default class extends Controller implements LiveController {
459459
const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions);
460460
const reRenderPromise = new ReRenderPromise(thisPromise, this.unsyncedInputs.clone());
461461
this.renderPromiseStack.addPromise(reRenderPromise);
462-
thisPromise.then((response) => {
462+
thisPromise.then(async (response) => {
463463
if (action) {
464464
this.isActionProcessing = false;
465465
}
466466

467+
// if the response does not contain a component, render as an error
468+
const html = await response.text();
469+
if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') {
470+
this.renderError(html);
471+
472+
return;
473+
}
474+
467475
// if another re-render is scheduled, do not "run it over"
468476
if (this.renderDebounceTimeout) {
469477
return;
470478
}
471479

472480
const isMostRecent = this.renderPromiseStack.removePromise(thisPromise);
473481
if (isMostRecent) {
474-
response.text().then((html) => {
475-
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
476-
});
482+
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
477483
}
478484
})
479485
}
@@ -1038,6 +1044,56 @@ export default class extends Controller implements LiveController {
10381044
clearInterval(interval);
10391045
});
10401046
}
1047+
1048+
// inspired by Livewire!
1049+
private async renderError(html: string) {
1050+
let modal = document.getElementById('live-component-error');
1051+
if (modal) {
1052+
modal.innerHTML = '';
1053+
} else {
1054+
modal = document.createElement('div');
1055+
modal.id = 'live-component-error';
1056+
modal.style.padding = '50px';
1057+
modal.style.backgroundColor = 'rgba(0, 0, 0, .5)';
1058+
modal.style.zIndex = '100000';
1059+
modal.style.position = 'fixed';
1060+
modal.style.width = '100vw';
1061+
modal.style.height = '100vh';
1062+
}
1063+
1064+
const iframe = document.createElement('iframe');
1065+
iframe.style.borderRadius = '5px';
1066+
iframe.style.width = '100%';
1067+
iframe.style.height = '100%';
1068+
modal.appendChild(iframe);
1069+
1070+
document.body.prepend(modal);
1071+
document.body.style.overflow = 'hidden';
1072+
if (iframe.contentWindow) {
1073+
iframe.contentWindow.document.open();
1074+
iframe.contentWindow.document.write(html);
1075+
iframe.contentWindow.document.close();
1076+
}
1077+
1078+
const closeModal = (modal: HTMLElement|null) => {
1079+
if (modal) {
1080+
modal.outerHTML = ''
1081+
}
1082+
document.body.style.overflow = 'visible'
1083+
}
1084+
1085+
// close on click
1086+
modal.addEventListener('click', () => closeModal(modal));
1087+
1088+
// close on escape
1089+
modal.setAttribute('tabindex', '0');
1090+
modal.addEventListener('keydown', e => {
1091+
if (e.key === 'Escape') {
1092+
closeModal(modal);
1093+
}
1094+
});
1095+
modal.focus();
1096+
}
10411097
}
10421098

10431099
/**
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
import { createTest, initComponent, shutdownTest } from '../tools';
13+
import { getByText, waitFor } from '@testing-library/dom';
14+
15+
describe('LiveController Error Handling', () => {
16+
afterEach(() => {
17+
shutdownTest();
18+
})
19+
20+
it('displays an error modal on 500 errors', async () => {
21+
const test = await createTest({ }, (data: any) => `
22+
<div ${initComponent(data)}>
23+
Original component text
24+
<button data-action="live#action" data-action-name="save">Save</button>
25+
</div>
26+
`);
27+
28+
// ONLY a post is sent, not a re-render GET
29+
test.expectsAjaxCall('post')
30+
.expectSentData(test.initialData)
31+
.serverWillReturnCustomResponse(500, `
32+
<html><head><title>Error!</title></head><body><h1>An error occurred</h1></body></html>
33+
`)
34+
.expectActionCalled('save')
35+
.init();
36+
37+
getByText(test.element, 'Save').click();
38+
39+
await waitFor(() => expect(document.getElementById('live-component-error')).not.toBeNull());
40+
// the component did not change or re-render
41+
expect(test.element).toHaveTextContent('Original component text');
42+
const errorContainer = document.getElementById('live-component-error');
43+
if (!errorContainer) {
44+
throw new Error('containing missing');
45+
}
46+
expect(errorContainer.querySelector('iframe')).not.toBeNull();
47+
});
48+
49+
it('displays a modal on any non-component response', async () => {
50+
const test = await createTest({ }, (data: any) => `
51+
<div ${initComponent(data)}>
52+
Original component text
53+
<button data-action="live#action" data-action-name="save">Save</button>
54+
</div>
55+
`);
56+
57+
// ONLY a post is sent, not a re-render GET
58+
test.expectsAjaxCall('post')
59+
.expectSentData(test.initialData)
60+
.serverWillReturnCustomResponse(200, `
61+
<html><head><title>Hi!</title></head><body><h1>I'm a whole page, not a component!</h1></body></html>
62+
`)
63+
.expectActionCalled('save')
64+
.init();
65+
66+
getByText(test.element, 'Save').click();
67+
68+
await waitFor(() => expect(document.getElementById('live-component-error')).not.toBeNull());
69+
// the component did not change or re-render
70+
expect(test.element).toHaveTextContent('Original component text');
71+
});
72+
});

src/LiveComponent/assets/test/tools.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ class MockedAjaxCall {
120120
options: any = {};
121121
fetchMock?: typeof fetchMock;
122122
routeName?: string;
123+
customResponseStatusCode?: number;
124+
customResponseHTML?: string;
123125

124126
constructor(method: string, test: FunctionalTest) {
125127
this.method = method.toUpperCase();
@@ -180,9 +182,24 @@ class MockedAjaxCall {
180182
// use custom template, or the main one
181183
const template = this.template ? this.template : this.test.template;
182184

185+
let response;
186+
if (this.customResponseStatusCode) {
187+
response = {
188+
body: this.customResponseHTML,
189+
status: this.customResponseStatusCode
190+
}
191+
} else {
192+
response = {
193+
body: template(finalServerData),
194+
headers: {
195+
'Content-Type': 'application/vnd.live-component+html'
196+
}
197+
}
198+
}
199+
183200
this.fetchMock = fetchMock.mock(
184201
this.getMockMatcher(),
185-
template(finalServerData),
202+
response,
186203
this.options
187204
);
188205
}
@@ -201,6 +218,14 @@ class MockedAjaxCall {
201218
return this;
202219
}
203220

221+
serverWillReturnCustomResponse(statusCode: number, responseHTML: string): MockedAjaxCall {
222+
this.checkInitialization('serverWillReturnAnError');
223+
this.customResponseStatusCode = statusCode;
224+
this.customResponseHTML = responseHTML;
225+
226+
return this;
227+
}
228+
204229
getVisualSummary(): string {
205230
const requestInfo = [];
206231
requestInfo.push(` METHOD: ${this.method}`);

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
<?php
22

3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
312
namespace Symfony\UX\LiveComponent\EventListener;
413

514
use Psr\Container\ContainerInterface;

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,9 @@ private function createResponse(MountedComponent $mounted): Response
232232
$component->{$method->name}();
233233
}
234234

235-
return new Response($this->container->get(ComponentRenderer::class)->render($mounted));
235+
return new Response($this->container->get(ComponentRenderer::class)->render($mounted), 200, [
236+
'Content-Type' => self::HTML_CONTENT_TYPE,
237+
]);
236238
}
237239

238240
private function isLiveComponentRequest(Request $request): bool

src/LiveComponent/src/LiveComponentHydrator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ private function verifyChecksum(array $data, array $readonlyProperties): void
235235
}
236236

237237
if (!hash_equals($this->computeChecksum($data, $readonlyProperties), $data[self::CHECKSUM_KEY])) {
238-
throw new UnprocessableEntityHttpException('Invalid checksum!');
238+
throw new UnprocessableEntityHttpException('Invalid checksum. This usually means that you tried to change a property that is not writable: true.');
239239
}
240240
}
241241

0 commit comments

Comments
 (0)