Skip to content

[Live] Rendering any errors in a simple modal #467

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
}
Expand Down Expand Up @@ -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,
Expand Down
64 changes: 60 additions & 4 deletions src/LiveComponent/assets/src/live_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,21 +459,27 @@ 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;
}

const isMostRecent = this.renderPromiseStack.removePromise(thisPromise);
if (isMostRecent) {
response.text().then((html) => {
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
});
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
}
})
}
Expand Down Expand Up @@ -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();
}
}

/**
Expand Down
72 changes: 72 additions & 0 deletions src/LiveComponent/assets/test/controller/error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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) => `
<div ${initComponent(data)}>
Original component text
<button data-action="live#action" data-action-name="save">Save</button>
</div>
`);

// ONLY a post is sent, not a re-render GET
test.expectsAjaxCall('post')
.expectSentData(test.initialData)
.serverWillReturnCustomResponse(500, `
<html><head><title>Error!</title></head><body><h1>An error occurred</h1></body></html>
`)
.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) => `
<div ${initComponent(data)}>
Original component text
<button data-action="live#action" data-action-name="save">Save</button>
</div>
`);

// ONLY a post is sent, not a re-render GET
test.expectsAjaxCall('post')
.expectSentData(test.initialData)
.serverWillReturnCustomResponse(200, `
<html><head><title>Hi!</title></head><body><h1>I'm a whole page, not a component!</h1></body></html>
`)
.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');
});
});
27 changes: 26 additions & 1 deletion src/LiveComponent/assets/test/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
);
}
Expand All @@ -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}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/src/LiveComponentHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
}

Expand Down