diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 06863ce27af..5ebd8185ca4 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.29.0 + +- Added `JSONResponse` support (LiveAction functions) in LiveComponent. + ## 2.28.0 - Add new modifiers for input validations, useful to prevent unnecessary HTTP requests: diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index c032c778f57..94a1acbdf79 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -7,6 +7,10 @@ declare class export_default$2{ constructor(response: Response); getBody(): Promise; getLiveUrl(): string | null; + checkResponseType(): Promise<{ + type: 'json' | 'html' | 'invalid'; + body: string; + }>; } declare class export_default$1{ @@ -81,6 +85,9 @@ type ComponentHooks = { connect: (component: Component) => MaybePromise; disconnect: (component: Component) => MaybePromise; 'request:started': (requestConfig: any) => MaybePromise; + 'render:started': (backendResponseBody: string, backendResponse: export_default$2, controls: { + shouldRender: boolean; + }) => MaybePromise; 'render:finished': (component: Component) => MaybePromise; 'response:error': (backendResponse: export_default$2, controls: { displayError: boolean; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index b1f8049a6f4..3c2631cb302 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -143,6 +143,24 @@ var BackendResponse_default = class { } return this.liveUrl; } + async checkResponseType() { + const contentType = this.response.headers.get("Content-Type") || ""; + const headers = this.response.headers; + const text = await this.getBody(); + const trimmed = text.trim(); + if (contentType.includes("application/json")) { + try { + JSON.parse(trimmed); + return { type: "json", body: trimmed }; + } catch { + } + } + const isValidHtml = trimmed.length > 0 && (contentType.includes("application/vnd.live-component+html") || headers.get("X-Live-Redirect") !== null); + if (isValidHtml) { + return { type: "html", body: trimmed }; + } + return { type: "invalid", body: trimmed }; + } }; // src/Util/getElementAsTagText.ts @@ -2005,23 +2023,32 @@ var Component = class { this.isRequestPending = false; this.backendRequest.promise.then(async (response) => { const backendResponse = new BackendResponse_default(response); - const html = await backendResponse.getBody(); + const result = await backendResponse.checkResponseType(); + if (result.type === "json") { + this.backendRequest = null; + thisPromiseResolve(backendResponse); + if (this.isRequestPending) { + this.isRequestPending = false; + this.performRequest(); + } + return response; + } for (const input of Object.values(this.pendingFiles)) { input.value = ""; } - const headers = backendResponse.response.headers; - if (!headers.get("Content-Type")?.includes("application/vnd.live-component+html") && !headers.get("X-Live-Redirect")) { + const backendResponseBody = result.body; + if (result.type === "invalid") { const controls = { displayError: true }; this.valueStore.pushPendingPropsBackToDirty(); this.hooks.triggerHook("response:error", backendResponse, controls); if (controls.displayError) { - this.renderError(html); + this.renderError(backendResponseBody); } this.backendRequest = null; thisPromiseResolve(backendResponse); return response; } - this.processRerender(html, backendResponse); + this.processRerender(backendResponseBody, backendResponse); const liveUrl = backendResponse.getLiveUrl(); if (liveUrl) { history.replaceState( @@ -2039,9 +2066,9 @@ var Component = class { return response; }); } - processRerender(html, backendResponse) { + processRerender(backendResponseBody, backendResponse) { const controls = { shouldRender: true }; - this.hooks.triggerHook("render:started", html, backendResponse, controls); + this.hooks.triggerHook("render:started", backendResponseBody, backendResponse, controls); if (!controls.shouldRender) { return; } @@ -2060,7 +2087,7 @@ var Component = class { }); let newElement; try { - newElement = htmlToElement(html); + newElement = htmlToElement(backendResponseBody); if (!newElement.matches("[data-controller~=live]")) { throw new Error("A live component template must contain a single root controller element."); } @@ -2130,7 +2157,7 @@ var Component = class { }, this.calculateDebounce(debounce)); } // inspired by Livewire! - renderError(html) { + renderError(backendResponseBody) { let modal = document.getElementById("live-component-error"); if (modal) { modal.innerHTML = ""; @@ -2156,7 +2183,7 @@ var Component = class { document.body.style.overflow = "hidden"; if (iframe.contentWindow) { iframe.contentWindow.document.open(); - iframe.contentWindow.document.write(html); + iframe.contentWindow.document.write(backendResponseBody); iframe.contentWindow.document.close(); } const closeModal = (modal2) => { diff --git a/src/LiveComponent/assets/src/Backend/BackendResponse.ts b/src/LiveComponent/assets/src/Backend/BackendResponse.ts index c7d6d2cc3b5..3744eec31e7 100644 --- a/src/LiveComponent/assets/src/Backend/BackendResponse.ts +++ b/src/LiveComponent/assets/src/Backend/BackendResponse.ts @@ -22,4 +22,31 @@ export default class { return this.liveUrl; } + + async checkResponseType(): Promise<{ type: 'json' | 'html' | 'invalid'; body: string }> { + const contentType = this.response.headers.get('Content-Type') || ''; + const headers = this.response.headers; + + const text = await this.getBody(); + const trimmed = text.trim(); + + if (contentType.includes('application/json')) { + try { + JSON.parse(trimmed); + return { type: 'json', body: trimmed }; + } catch { + // not valid JSON + } + } + + const isValidHtml = + trimmed.length > 0 && + (contentType.includes('application/vnd.live-component+html') || headers.get('X-Live-Redirect') !== null); + + if (isValidHtml) { + return { type: 'html', body: trimmed }; + } + + return { type: 'invalid', body: trimmed }; + } } diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 2a7decb6ae4..8a901af6e17 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -20,6 +20,11 @@ export type ComponentHooks = { connect: (component: Component) => MaybePromise; disconnect: (component: Component) => MaybePromise; 'request:started': (requestConfig: any) => MaybePromise; + 'render:started': ( + backendResponseBody: string, + backendResponse: BackendResponse, + controls: { shouldRender: boolean } + ) => MaybePromise; 'render:finished': (component: Component) => MaybePromise; 'response:error': (backendResponse: BackendResponse, controls: { displayError: boolean }) => MaybePromise; 'loading.state:started': (element: HTMLElement, request: BackendRequest) => MaybePromise; @@ -300,34 +305,43 @@ export default class Component { this.backendRequest.promise.then(async (response) => { const backendResponse = new BackendResponse(response); - const html = await backendResponse.getBody(); + const result = await backendResponse.checkResponseType(); - // clear sent files inputs + if (result.type === 'json') { + this.backendRequest = null; + thisPromiseResolve(backendResponse); + + if (this.isRequestPending) { + this.isRequestPending = false; + this.performRequest(); + } + return response; + } + + // Clear all file inputs for (const input of Object.values(this.pendingFiles)) { input.value = ''; } + const backendResponseBody = result.body; + // if the response does not contain a component, render as an error - const headers = backendResponse.response.headers; - if ( - !headers.get('Content-Type')?.includes('application/vnd.live-component+html') && - !headers.get('X-Live-Redirect') - ) { + if (result.type === 'invalid') { const controls = { displayError: true }; this.valueStore.pushPendingPropsBackToDirty(); this.hooks.triggerHook('response:error', backendResponse, controls); if (controls.displayError) { - this.renderError(html); + this.renderError(backendResponseBody); } this.backendRequest = null; thisPromiseResolve(backendResponse); - return response; } - this.processRerender(html, backendResponse); + // HTML processing + this.processRerender(backendResponseBody, backendResponse); const liveUrl = backendResponse.getLiveUrl(); if (liveUrl) { history.replaceState( @@ -337,11 +351,9 @@ export default class Component { ); } - // finally resolve this promise this.backendRequest = null; thisPromiseResolve(backendResponse); - // do we already have another request pending? if (this.isRequestPending) { this.isRequestPending = false; this.performRequest(); @@ -351,9 +363,9 @@ export default class Component { }); } - private processRerender(html: string, backendResponse: BackendResponse) { + private processRerender(backendResponseBody: string, backendResponse: BackendResponse) { const controls = { shouldRender: true }; - this.hooks.triggerHook('render:started', html, backendResponse, controls); + this.hooks.triggerHook('render:started', backendResponseBody, backendResponse, controls); // used to notify that the component doesn't live on the page anymore if (!controls.shouldRender) { return; @@ -387,7 +399,7 @@ export default class Component { let newElement: HTMLElement; try { - newElement = htmlToElement(html); + newElement = htmlToElement(backendResponseBody); if (!newElement.matches('[data-controller~=live]')) { throw new Error('A live component template must contain a single root controller element.'); @@ -477,7 +489,7 @@ export default class Component { } // inspired by Livewire! - private renderError(html: string): void { + private renderError(backendResponseBody: string): void { let modal = document.getElementById('live-component-error'); if (modal) { modal.innerHTML = ''; @@ -505,7 +517,7 @@ export default class Component { document.body.style.overflow = 'hidden'; if (iframe.contentWindow) { iframe.contentWindow.document.open(); - iframe.contentWindow.document.write(html); + iframe.contentWindow.document.write(backendResponseBody); iframe.contentWindow.document.close(); } diff --git a/src/LiveComponent/assets/test/Component/index.test.ts b/src/LiveComponent/assets/test/Component/index.test.ts index 865723b3faa..48cfc459b99 100644 --- a/src/LiveComponent/assets/test/Component/index.test.ts +++ b/src/LiveComponent/assets/test/Component/index.test.ts @@ -3,7 +3,7 @@ import { Response } from 'node-fetch'; import { describe, expect, it } from 'vitest'; import type { BackendAction, BackendInterface } from '../../src/Backend/Backend'; import BackendRequest from '../../src/Backend/BackendRequest'; -import type BackendResponse from '../../src/Backend/BackendResponse'; +import BackendResponse from '../../src/Backend/BackendResponse'; import Component, { proxifyComponent } from '../../src/Component'; import { noopElementDriver } from '../tools'; @@ -132,3 +132,121 @@ describe('Component class', () => { }); }); }); + +describe('BackendResponse.checkResponseType', () => { + it('should detect valid JSON response', async () => { + const jsonData = JSON.stringify({ message: 'hello' }); + const response = new Response(jsonData, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + const backendResponse = new BackendResponse(response); + const result = await backendResponse.checkResponseType(); + + expect(result.type).toBe('json'); + expect(result.body).toBe(jsonData); + }); + + it('should detect valid HTML response with correct Content-Type', async () => { + const htmlContent = '
Live component
'; + const response = new Response(htmlContent, { + headers: { + 'Content-Type': 'application/vnd.live-component+html', + }, + }); + + const backendResponse = new BackendResponse(response); + const result = await backendResponse.checkResponseType(); + + expect(result.type).toBe('html'); + expect(result.body).toBe(htmlContent); + }); + + it('should detect valid HTML response with X-Live-Redirect header', async () => { + const htmlContent = '
Redirected HTML
'; + const response = new Response(htmlContent, { + headers: { + 'X-Live-Redirect': '/some/path', + }, + }); + + const backendResponse = new BackendResponse(response); + const result = await backendResponse.checkResponseType(); + + expect(result.type).toBe('html'); + expect(result.body).toBe(htmlContent); + }); + + it('should detect invalid response (not JSON or HTML)', async () => { + const plainText = 'Just a plain response'; + const response = new Response(plainText, { + headers: { + 'Content-Type': 'text/plain', + }, + }); + + const backendResponse = new BackendResponse(response); + const result = await backendResponse.checkResponseType(); + + expect(result.type).toBe('invalid'); + expect(result.body).toBe(plainText); + }); + + it('should detect broken JSON as invalid', async () => { + const brokenJson = '{"invalidJson": '; + const response = new Response(brokenJson, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + const backendResponse = new BackendResponse(response); + const result = await backendResponse.checkResponseType(); + + expect(result.type).toBe('invalid'); + expect(result.body).toBe(brokenJson.trim()); + }); + + it('should detect invalid response with empty body', async () => { + const emptyBody = ''; + const response = new Response(emptyBody, { + headers: { + 'Content-Type': 'application/vnd.live-component+html', + }, + }); + + const backendResponse = new BackendResponse(response); + const result = await backendResponse.checkResponseType(); + + expect(result.type).toBe('invalid'); + expect(result.body).toBe(emptyBody); + }); + + it('should detect invalid response with no Content-Type header', async () => { + const bodyContent = '
Some HTML
'; + const response = new Response(bodyContent, { + headers: { + // no Content-Type header + }, + }); + + const backendResponse = new BackendResponse(response); + const result = await backendResponse.checkResponseType(); + + expect(result.type).toBe('invalid'); + expect(result.body).toBe(bodyContent); + }); + + it('should detect invalid response with empty body and no headers', async () => { + const emptyBody = ''; + const response = new Response(emptyBody); + + const backendResponse = new BackendResponse(response); + const result = await backendResponse.checkResponseType(); + + expect(result.type).toBe('invalid'); + expect(result.body).toBe(emptyBody); + }); +}); diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 9701f13fc82..f6b65b75974 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -981,9 +981,20 @@ component system from Stimulus: The following hooks are available (along with the arguments that are passed): + +.. versionadded:: 2.29 + + ``render:started`` argument changed in 2.29. + +.. code-block:: diff + + - render:started`` args ``(html: string, response: BackendResponse, controls: { shouldRender: boolean }) + + render:started`` args ``(backendResponseBody: string, response: BackendResponse, controls: { shouldRender: boolean }) + * ``connect`` args ``(component: Component)`` * ``disconnect`` args ``(component: Component)`` -* ``render:started`` args ``(html: string, response: BackendResponse, controls: { shouldRender: boolean })`` +* ``request:started`` args ``(requestConfig: any)`` +* ``render:started`` args ``(backendResponseBody: string, response: BackendResponse, controls: { shouldRender: boolean })`` * ``render:finished`` args ``(component: Component)`` * ``response:error`` args ``(backendResponse: BackendResponse, controls: { displayError: boolean })`` * ``loading.state:started`` args ``(element: HTMLElement, request: BackendRequest)`` @@ -1305,11 +1316,123 @@ action:: // ... } + You probably noticed one interesting trick: to make redirecting easier, the component now extends ``AbstractController``! That is totally allowed, and gives you access to all of your normal controller shortcuts. We even added a flash message! +JSONResponse in LiveActions +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.29 + + LiveAction JSONResponse support was added in version 2.29. + +You can now return a ``JsonResponse`` from methods marked with ``LiveAction``. +If your component is connected to a Stimulus controller, you can handle the ``JsonResponse`` on the client side. +Based on the JSON data, you can perform operations or trigger actions within the component. For example: + +Component with a ``JsonResponse`` in a LiveAction: + +:: + + // src/Twig/Components/Posts.php + namespace App\Twig\Components; + + use App\Repository\PostRepository; + use Doctrine\ORM\EntityManagerInterface; + use Exception; + use Symfony\Component\HttpFoundation\JsonResponse; + use Symfony\UX\LiveComponent\Attribute\LiveAction; + use Symfony\UX\LiveComponent\Attribute\LiveArg; + + class Posts + { + public function __construct( + private EntityManagerInterface $entityManager, + private PostRepository $postRepository, + ) {} + + #[LiveAction] + public function deletePost(#[LiveArg] int $id): JsonResponse + { + $post = $this->postRepository->find($id); + if ($post) { + try { + $this->entityManager->remove($post); + $this->entityManager->flush(); + } catch (Exception) { + return new JsonResponse([ + 'status' => 'error', + 'message' => 'Post delete failed.' + ]); + } + + return new JsonResponse([ + 'status' => 'success', + 'message' => 'Post deleted successfully.' + ]); + } + + return new JsonResponse([ + 'status' => 'error', + 'message' => 'Error: Post was not found.' + ]); + } + } + +Triggering the LiveAction from Twig: + +.. code-block:: html+twig + +
+ +
+ +Handling the ``JsonResponse`` in a Stimulus controller: + +.. code-block:: javascript + + // assets/controllers/postactions-controller.js + import { Controller } from '@hotwired/stimulus'; + import { getComponent } from '@symfony/ux-live-component'; + + export default class extends Controller { + async initialize() { + this.component = await getComponent(this.element); + } + + async postDelete(event) { + let id = event.params.id; + + if (id === undefined || id === 0) { + return; + } + + const result = await this.component.action('deletePost', { id: id }); + const response = JSON.parse(result.body); + + if (response.status === "success") { + console.log('Success -> ', response.message) + } else { + // ... + } + + // Note: The component does not re-render automatically after a JsonResponse. + // You must call render() manually if you want to re-render it. + // this.component.render(); + } + } + + .. _files: Uploading files