From 2d13c27995d3efab635f58ccc5a62f565348ba70 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Tue, 2 May 2023 12:04:24 +0200 Subject: [PATCH 01/20] Allow files to be passed to `RequestBuilder` and move request body under 'data' key --- .../assets/src/Backend/Backend.ts | 4 ++- .../assets/src/Backend/RequestBuilder.ts | 14 +++++++-- .../test/Backend/RequestBuilder.test.ts | 30 ++++++++++++++----- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/LiveComponent/assets/src/Backend/Backend.ts b/src/LiveComponent/assets/src/Backend/Backend.ts index 71002ffb350..98179959114 100644 --- a/src/LiveComponent/assets/src/Backend/Backend.ts +++ b/src/LiveComponent/assets/src/Backend/Backend.ts @@ -34,13 +34,15 @@ export default class implements BackendInterface { updated: {[key: string]: any}, children: ChildrenFingerprints, updatedPropsFromParent: {[key: string]: any}, + files: {[key: string]: FileList} = {}, ): BackendRequest { const { url, fetchOptions } = this.requestBuilder.buildRequest( props, actions, updated, children, - updatedPropsFromParent + updatedPropsFromParent, + files ); return new BackendRequest( diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts index 4007ad0e71f..9ea5874024f 100644 --- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts +++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts @@ -15,6 +15,7 @@ export default class { updated: {[key: string]: any}, children: ChildrenFingerprints, updatedPropsFromParent: {[key: string]: any}, + files: {[key: string]: FileList}, ): { url: string; fetchOptions: RequestInit } { const splitUrl = this.url.split('?'); let [url] = splitUrl; @@ -42,7 +43,6 @@ export default class { fetchOptions.method = 'GET'; } else { fetchOptions.method = 'POST'; - fetchOptions.headers['Content-Type'] = 'application/json'; const requestData: any = { props, updated }; if (Object.keys(updatedPropsFromParent).length > 0) { requestData.propsFromParent = updatedPropsFromParent; @@ -68,7 +68,17 @@ export default class { } } - fetchOptions.body = JSON.stringify(requestData); + const formData = new FormData(); + formData.append('data', JSON.stringify(requestData)); + + for(const [key, value] of Object.entries(files)) { + const length = value.length; + for (let i = 0; i < length ; ++i) { + formData.append(key, value[i]); + } + } + + fetchOptions.body = formData; } const paramsString = params.toString(); diff --git a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts index 936e7557129..4a8b53d77e3 100644 --- a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts +++ b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts @@ -8,6 +8,7 @@ describe('buildRequest', () => { [], { firstName: 'Kevin' }, { 'child-component': {fingerprint: '123', tag: 'div' } }, + {}, {} ); @@ -28,6 +29,7 @@ describe('buildRequest', () => { }], { firstName: 'Kevin' }, { 'child-component': {fingerprint: '123', tag: 'div' } }, + {}, {} ); @@ -35,10 +37,12 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('POST'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', - 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '_the_csrf_token', }); - expect(fetchOptions.body).toEqual(JSON.stringify({ + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + // @ts-ignore body is already asserted to be FormData + expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan' }, updated: { firstName: 'Kevin' }, children: { 'child-component': { fingerprint: '123', tag: 'div' } }, @@ -59,12 +63,16 @@ describe('buildRequest', () => { }], { firstName: 'Kevin' }, {}, + {}, {} ); expect(url).toEqual('/_components/_batch'); expect(fetchOptions.method).toEqual('POST'); - expect(fetchOptions.body).toEqual(JSON.stringify({ + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + // @ts-ignore body is already asserted to be FormData + expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan' }, updated: { firstName: 'Kevin' }, actions: [{ @@ -85,6 +93,7 @@ describe('buildRequest', () => { [], { firstName: 'Kevin'.repeat(1000) }, {}, + {}, {} ); @@ -93,9 +102,11 @@ describe('buildRequest', () => { expect(fetchOptions.headers).toEqual({ // no token Accept: 'application/vnd.live-component+html', - 'Content-Type': 'application/json', }); - expect(fetchOptions.body).toEqual(JSON.stringify({ + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + // @ts-ignore body is already asserted to be FormData + expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan'.repeat(1000) }, updated: { firstName: 'Kevin'.repeat(1000) }, })); @@ -108,7 +119,8 @@ describe('buildRequest', () => { [], { firstName: 'Kevin' }, { }, - { count: 5 } + { count: 5 }, + {} ); expect(url).toEqual('/_components?existing_param=1&props=%7B%22firstName%22%3A%22Ryan%22%7D&updated=%7B%22firstName%22%3A%22Kevin%22%7D&propsFromParent=%7B%22count%22%3A5%7D'); @@ -123,9 +135,13 @@ describe('buildRequest', () => { { firstName: 'Kevin' }, {}, { count: 5 }, + {} ); - expect(fetchOptions.body).toEqual(JSON.stringify({ + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + // @ts-ignore body is already asserted to be FormData + expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan' }, updated: { firstName: 'Kevin' }, propsFromParent: { count: 5 }, From 14a640ba6a2aeb8b1b3412bf03854e3e842389df Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Tue, 2 May 2023 12:21:56 +0200 Subject: [PATCH 02/20] Make files mandatory in Backend.ts and pass empty map from controller --- src/LiveComponent/assets/src/Backend/Backend.ts | 3 ++- src/LiveComponent/assets/src/Component/index.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/LiveComponent/assets/src/Backend/Backend.ts b/src/LiveComponent/assets/src/Backend/Backend.ts index 98179959114..31f2c21b357 100644 --- a/src/LiveComponent/assets/src/Backend/Backend.ts +++ b/src/LiveComponent/assets/src/Backend/Backend.ts @@ -13,6 +13,7 @@ export interface BackendInterface { updated: {[key: string]: any}, children: ChildrenFingerprints, updatedPropsFromParent: {[key: string]: any}, + files: {[key: string]: FileList}, ): BackendRequest; } @@ -34,7 +35,7 @@ export default class implements BackendInterface { updated: {[key: string]: any}, children: ChildrenFingerprints, updatedPropsFromParent: {[key: string]: any}, - files: {[key: string]: FileList} = {}, + files: {[key: string]: FileList}, ): BackendRequest { const { url, fetchOptions } = this.requestBuilder.buildRequest( props, diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 738025bd070..b87c44b8d5b 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -358,6 +358,7 @@ export default class Component { this.valueStore.getDirtyProps(), this.getChildrenFingerprints(), this.valueStore.getUpdatedPropsFromParent(), + {}, // Uploaded files will go here ); this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest); From b5ff362514a236ead8a6aced04aa97f0ba59b5a0 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Tue, 2 May 2023 13:07:09 +0200 Subject: [PATCH 03/20] Add tests for passing files to RequestBuilder and make them pass --- .../assets/src/Backend/RequestBuilder.ts | 16 +++- .../test/Backend/RequestBuilder.test.ts | 80 +++++++++++++++++-- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts index 9ea5874024f..2fe6e3b0254 100644 --- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts +++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts @@ -27,9 +27,15 @@ export default class { Accept: 'application/vnd.live-component+html', }; + const totalFiles = Object.entries(files).reduce( + (total, current) => total + current.length, + 0 + ); + const hasFingerprints = Object.keys(children).length > 0; if ( actions.length === 0 && + totalFiles === 0 && this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent)) ) { params.set('props', JSON.stringify(props)); @@ -51,11 +57,15 @@ export default class { requestData.children = children; } + if ( + this.csrfToken && + (actions.length || totalFiles) + ) { + fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken; + } + if (actions.length > 0) { // one or more ACTIONs - if (this.csrfToken) { - fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken; - } if (actions.length === 1) { // simple, single action diff --git a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts index 4a8b53d77e3..1963a7fbbad 100644 --- a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts +++ b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts @@ -39,9 +39,8 @@ describe('buildRequest', () => { Accept: 'application/vnd.live-component+html', 'X-CSRF-TOKEN': '_the_csrf_token', }); - const body = fetchOptions.body; + const body = fetchOptions.body; expect(body).toBeInstanceOf(FormData); - // @ts-ignore body is already asserted to be FormData expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan' }, updated: { firstName: 'Kevin' }, @@ -69,9 +68,8 @@ describe('buildRequest', () => { expect(url).toEqual('/_components/_batch'); expect(fetchOptions.method).toEqual('POST'); - const body = fetchOptions.body; + const body = fetchOptions.body; expect(body).toBeInstanceOf(FormData); - // @ts-ignore body is already asserted to be FormData expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan' }, updated: { firstName: 'Kevin' }, @@ -103,9 +101,8 @@ describe('buildRequest', () => { // no token Accept: 'application/vnd.live-component+html', }); - const body = fetchOptions.body; + const body = fetchOptions.body; expect(body).toBeInstanceOf(FormData); - // @ts-ignore body is already asserted to be FormData expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan'.repeat(1000) }, updated: { firstName: 'Kevin'.repeat(1000) }, @@ -138,9 +135,8 @@ describe('buildRequest', () => { {} ); - const body = fetchOptions.body; + const body = fetchOptions.body; expect(body).toBeInstanceOf(FormData); - // @ts-ignore body is already asserted to be FormData expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan' }, updated: { firstName: 'Kevin' }, @@ -148,4 +144,72 @@ describe('buildRequest', () => { args: { sendNotification: '1' }, })); }); + + // Helper method for FileList mocking + const getFileList = (length = 1) => { + const blob = new Blob([''], { type: 'text/html' }); + // @ts-ignore This is a mock and those are needed to mock a File object + blob['lastModifiedDate'] = ''; + // @ts-ignore This is a mock and those are needed to mock a File object + blob['name'] = 'filename'; + const file = blob; + const fileList: FileList = { + length: length, + item: () => file + }; + for (let i= 0; i < length; ++i) { + fileList[i] = file; + } + return fileList; + }; + + it('Sends file with request', () => { + const builder = new RequestBuilder('/_components', '_the_csrf_token'); + + const { url, fetchOptions } = builder.buildRequest( + { firstName: 'Ryan' }, + [], + {}, + {}, + {}, + { 'file': getFileList()} + ); + + expect(url).toEqual('/_components'); + expect(fetchOptions.method).toEqual('POST'); + expect(fetchOptions.headers).toEqual({ + Accept: 'application/vnd.live-component+html', + 'X-CSRF-TOKEN': '_the_csrf_token', + }); + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + expect(body.get('file')).toBeInstanceOf(File); + expect(body.getAll('file').length).toEqual(1); + }); + + it('Sends multiple files with request', () => { + const builder = new RequestBuilder('/_components', '_the_csrf_token'); + + const { url, fetchOptions } = builder.buildRequest( + { firstName: 'Ryan' }, + [], + {}, + {}, + {}, + { 'file[]': getFileList(3), 'otherFile': getFileList()} + ); + + expect(url).toEqual('/_components'); + expect(fetchOptions.method).toEqual('POST'); + expect(fetchOptions.headers).toEqual({ + Accept: 'application/vnd.live-component+html', + 'X-CSRF-TOKEN': '_the_csrf_token', + }); + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + expect(body.get('file[]')).toBeInstanceOf(File); + expect(body.getAll('file[]').length).toEqual(3); + expect(body.get('otherFile')).toBeInstanceOf(File); + expect(body.getAll('otherFile').length).toEqual(1); + }); }); From fe6915434a519d02adb204300ddd2602c4db95b6 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Tue, 2 May 2023 13:58:56 +0200 Subject: [PATCH 04/20] Read POST payload from `data` key instead of a request body --- .../EventListener/LiveComponentSubscriber.php | 6 +- .../Controller/BatchActionControllerTest.php | 98 +++++++++++-------- .../LiveComponentSubscriberTest.php | 20 ++-- .../Functional/Form/ComponentWithFormTest.php | 8 +- .../tests/Functional/LiveResponderTest.php | 4 +- .../ValidatableComponentTraitTest.php | 6 +- 6 files changed, 81 insertions(+), 61 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 12ba03115bd..5e545ad9719 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -221,7 +221,11 @@ private static function parseDataFor(Request $request): array 'propsFromParent' => self::parseJsonFromQuery($request, 'propsFromParent'), ]; } else { - $requestData = $request->toArray(); + try { + $requestData = json_decode($request->request->get('data'), true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new JsonException('Could not decode request body.', $e->getCode(), $e); + } $liveRequestData = [ 'props' => $requestData['props'] ?? [], diff --git a/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php b/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php index f6ba2facfb1..2bbf1c0360b 100644 --- a/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php +++ b/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php @@ -31,7 +31,7 @@ public function testCanBatchActions(): void $this->browser() ->throwExceptions() - ->get('/_components/with_actions', ['json' => ['props' => $dehydrated->getProps()]]) + ->get('/_components/with_actions', ['query' => ['props' => json_encode($dehydrated->getProps())]]) ->assertSuccessful() ->assertSee('initial') ->use(function (Crawler $crawler, KernelBrowser $browser) { @@ -39,9 +39,11 @@ public function testCanBatchActions(): void $liveProps = json_decode($rootElement->attr('data-live-props-value'), true); $browser->post('/_components/with_actions/add', [ - 'json' => [ - 'props' => $liveProps, - 'args' => ['what' => 'first'], + 'body' => [ + 'data' => json_encode([ + 'props' => $liveProps, + 'args' => ['what' => 'first'], + ]), ], 'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')], ]); @@ -53,13 +55,15 @@ public function testCanBatchActions(): void $liveProps = json_decode($rootElement->attr('data-live-props-value'), true); $browser->post('/_components/with_actions/_batch', [ - 'json' => [ - 'props' => $liveProps, - 'actions' => [ - ['name' => 'add', 'args' => ['what' => 'second']], - ['name' => 'add', 'args' => ['what' => 'third']], - ['name' => 'add', 'args' => ['what' => 'fourth']], - ], + 'body' => [ + 'data' => json_encode([ + 'props' => $liveProps, + 'actions' => [ + ['name' => 'add', 'args' => ['what' => 'second']], + ['name' => 'add', 'args' => ['what' => 'third']], + ['name' => 'add', 'args' => ['what' => 'fourth']], + ], + ]), ], 'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')], ]); @@ -78,7 +82,7 @@ public function testCanBatchActionsWithAlternateRoute(): void $this->browser() ->throwExceptions() - ->get('/alt/alternate_route', ['json' => ['props' => $dehydrated->getProps()]]) + ->get('/alt/alternate_route', ['query' => ['props' => json_encode($dehydrated->getProps())]]) ->assertSuccessful() ->assertSee('count: 0') ->use(function (Crawler $crawler, KernelBrowser $browser) { @@ -86,13 +90,15 @@ public function testCanBatchActionsWithAlternateRoute(): void $liveProps = json_decode($rootElement->attr('data-live-props-value'), true); $browser->post('/alt/alternate_route/_batch', [ - 'json' => [ - 'props' => $liveProps, - 'actions' => [ - ['name' => 'increase'], - ['name' => 'increase'], - ['name' => 'increase'], - ], + 'body' => [ + 'data' => json_encode([ + 'props' => $liveProps, + 'actions' => [ + ['name' => 'increase'], + ['name' => 'increase'], + ['name' => 'increase'], + ], + ]), ], 'headers' => ['X-CSRF-TOKEN' => $rootElement->attr('data-live-csrf-value')], ]); @@ -122,7 +128,7 @@ public function testRedirect(): void $this->browser() ->throwExceptions() - ->get('/_components/with_actions', ['json' => ['props' => $dehydrated->getProps()]]) + ->get('/_components/with_actions', ['query' => ['props' => json_encode($dehydrated->getProps())]]) ->assertSuccessful() ->interceptRedirects() ->use(function (Crawler $crawler, KernelBrowser $browser) { @@ -130,13 +136,15 @@ public function testRedirect(): void $liveProps = json_decode($rootElement->attr('data-live-props-value'), true); $browser->post('/_components/with_actions/_batch', [ - 'json' => [ - 'props' => $liveProps, - 'actions' => [ - ['name' => 'add', 'args' => ['what' => 'second']], - ['name' => 'redirect'], - ['name' => 'add', 'args' => ['what' => 'fourth']], - ], + 'body' => [ + 'data' => json_encode([ + 'props' => $liveProps, + 'actions' => [ + ['name' => 'add', 'args' => ['what' => 'second']], + ['name' => 'redirect'], + ['name' => 'add', 'args' => ['what' => 'fourth']], + ], + ]), ], 'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')], ]); @@ -150,20 +158,22 @@ public function testException(): void $dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions')); $this->browser() - ->get('/_components/with_actions', ['json' => ['props' => $dehydrated->getProps()]]) + ->get('/_components/with_actions', ['query' => ['props' => json_encode($dehydrated->getProps())]]) ->assertSuccessful() ->use(function (Crawler $crawler, KernelBrowser $browser) { $rootElement = $crawler->filter('ul')->first(); $liveProps = json_decode($rootElement->attr('data-live-props-value'), true); $browser->post('/_components/with_actions/_batch', [ - 'json' => [ - 'props' => $liveProps, - 'actions' => [ - ['name' => 'add', 'args' => ['what' => 'second']], - ['name' => 'exception'], - ['name' => 'add', 'args' => ['what' => 'fourth']], - ], + 'body' => [ + 'data' => json_encode([ + 'props' => $liveProps, + 'actions' => [ + ['name' => 'add', 'args' => ['what' => 'second']], + ['name' => 'exception'], + ['name' => 'add', 'args' => ['what' => 'fourth']], + ], + ]), ], 'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')], ]); @@ -178,20 +188,22 @@ public function testCannotBatchWithNonLiveAction(): void $dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions')); $this->browser() - ->get('/_components/with_actions', ['json' => ['props' => $dehydrated->getProps()]]) + ->get('/_components/with_actions', ['query' => ['props' => json_encode($dehydrated->getProps())]]) ->assertSuccessful() ->use(function (Crawler $crawler, KernelBrowser $browser) { $rootElement = $crawler->filter('ul')->first(); $liveProps = json_decode($rootElement->attr('data-live-props-value'), true); $browser->post('/_components/with_actions/_batch', [ - 'json' => [ - 'props' => $liveProps, - 'actions' => [ - ['name' => 'add', 'args' => ['what' => 'second']], - ['name' => 'nonLive'], - ['name' => 'add', 'args' => ['what' => 'fourth']], - ], + 'body' => [ + 'data' => json_encode([ + 'props' => $liveProps, + 'actions' => [ + ['name' => 'add', 'args' => ['what' => 'second']], + ['name' => 'nonLive'], + ['name' => 'add', 'args' => ['what' => 'fourth']], + ], + ]), ], 'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')], ]); diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index d0647f4221f..843c97433d4 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -85,7 +85,7 @@ public function testCanExecuteComponentActionNormalRoute(): void }) ->post('/_components/component2/increase', [ 'headers' => ['X-CSRF-TOKEN' => $token], - 'body' => json_encode(['props' => $dehydrated->getProps()]), + 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') @@ -109,7 +109,7 @@ public function testCanExecuteComponentActionWithAlternateRoute(): void }) ->post('/alt/alternate_route/increase', [ 'headers' => ['X-CSRF-TOKEN' => $token], - 'body' => json_encode(['props' => $dehydrated->getProps()]), + 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertSuccessful() ->assertOn('/alt/alternate_route/increase') @@ -182,7 +182,7 @@ public function testDisabledCsrfTokenForComponentDoesNotFail(): void ->assertHeaderContains('Content-Type', 'html') ->assertContains('Count: 1') ->post('/_components/disabled_csrf/increase', [ - 'body' => json_encode(['props' => $dehydrated->getProps()]), + 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') @@ -221,7 +221,7 @@ public function testCanRedirectFromComponentAction(): void // with no custom header, it redirects like a normal browser ->post('/_components/component2/redirect', [ 'headers' => ['X-CSRF-TOKEN' => $token], - 'body' => json_encode(['props' => $dehydrated->getProps()]), + 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertRedirectedTo('/') @@ -231,7 +231,7 @@ public function testCanRedirectFromComponentAction(): void 'Accept' => 'application/vnd.live-component+html', 'X-CSRF-TOKEN' => $token, ], - 'body' => json_encode(['props' => $dehydrated->getProps()]), + 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertStatus(204) ->assertHeaderEquals('Location', '/') @@ -260,10 +260,12 @@ public function testInjectsLiveArgs(): void }) ->post('/_components/component6/inject', [ 'headers' => ['X-CSRF-TOKEN' => $token], - 'body' => json_encode([ - 'props' => $dehydrated->getProps(), - 'args' => $arguments, - ]), + 'body' => [ + 'data' => json_encode([ + 'props' => $dehydrated->getProps(), + 'args' => $arguments, + ]), + ], ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php index f58d5f7c331..9908c16b565 100644 --- a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php +++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php @@ -53,7 +53,7 @@ public function testFormValuesRebuildAfterFormChanges(): void $crawler = $browser // post to action, which will add a new embedded comment ->post('/_components/form_with_collection_type/addComment', [ - 'body' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps]), + 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps])], 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) @@ -89,7 +89,7 @@ public function testFormValuesRebuildAfterFormChanges(): void $crawler = $browser // post to action, which will remove the original embedded comment ->post('/_components/form_with_collection_type/removeComment', [ - 'body' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['index' => '0']]), + 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['index' => '0']])], 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) @@ -336,7 +336,7 @@ public function testLiveCollectionTypeFieldsAddedAndRemoved(): void }) // post to action, which will add a new embedded comment ->post('/_components/form_with_live_collection_type/addCollectionItem', [ - 'body' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['name' => 'blog_post_form[comments]']]), + 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['name' => 'blog_post_form[comments]']])], 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) @@ -373,7 +373,7 @@ public function testLiveCollectionTypeFieldsAddedAndRemoved(): void // post to action, which will remove the original embedded comment ->post('/_components/form_with_live_collection_type/removeCollectionItem', [ - 'body' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['name' => 'blog_post_form[comments]', 'index' => '0']]), + 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['name' => 'blog_post_form[comments]', 'index' => '0']])], 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) diff --git a/src/LiveComponent/tests/Functional/LiveResponderTest.php b/src/LiveComponent/tests/Functional/LiveResponderTest.php index 2412dc66fda..833713bf350 100644 --- a/src/LiveComponent/tests/Functional/LiveResponderTest.php +++ b/src/LiveComponent/tests/Functional/LiveResponderTest.php @@ -31,7 +31,7 @@ public function testComponentCanEmitEvents(): void $this->browser() ->throwExceptions() ->post('/_components/component_with_emit/actionThatEmits', [ - 'body' => json_encode(['props' => $dehydrated->getProps()]), + 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertSuccessful() ->assertSee('Event: event1') @@ -46,7 +46,7 @@ public function testComponentCanDispatchBrowserEvents(): void $crawler = $this->browser() ->throwExceptions() ->post('/_components/component_with_emit/actionThatDispatchesABrowserEvent', [ - 'body' => json_encode(['props' => $dehydrated->getProps()]), + 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertSuccessful() ->crawler() diff --git a/src/LiveComponent/tests/Functional/ValidatableComponentTraitTest.php b/src/LiveComponent/tests/Functional/ValidatableComponentTraitTest.php index 2a41577fb23..452115a4c76 100644 --- a/src/LiveComponent/tests/Functional/ValidatableComponentTraitTest.php +++ b/src/LiveComponent/tests/Functional/ValidatableComponentTraitTest.php @@ -62,8 +62,10 @@ public function testFormValuesRebuildAfterFormChanges(): void $browser ->post('/_components/validating_component/resetValidationAction', [ - 'json' => [ - 'props' => $dehydratedProps, + 'body' => [ + 'data' => json_encode([ + 'props' => $dehydratedProps, + ]), ], ]) ->assertSuccessful() From 7fd6e5a2ebd672fcb2fd96bdd1510ac708da36bb Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Tue, 2 May 2023 14:07:03 +0200 Subject: [PATCH 05/20] Rebuild assets --- .../assets/dist/Backend/Backend.d.ts | 4 +++ .../assets/dist/Backend/RequestBuilder.d.ts | 2 ++ .../assets/dist/live_controller.js | 28 +++++++++++++------ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/LiveComponent/assets/dist/Backend/Backend.d.ts b/src/LiveComponent/assets/dist/Backend/Backend.d.ts index c4adb86f1f1..d9dbd309095 100644 --- a/src/LiveComponent/assets/dist/Backend/Backend.d.ts +++ b/src/LiveComponent/assets/dist/Backend/Backend.d.ts @@ -10,6 +10,8 @@ export interface BackendInterface { [key: string]: any; }, children: ChildrenFingerprints, updatedPropsFromParent: { [key: string]: any; + }, files: { + [key: string]: FileList; }): BackendRequest; } export interface BackendAction { @@ -23,5 +25,7 @@ export default class implements BackendInterface { [key: string]: any; }, children: ChildrenFingerprints, updatedPropsFromParent: { [key: string]: any; + }, files: { + [key: string]: FileList; }): BackendRequest; } diff --git a/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts b/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts index fa7c125a5ea..a4aacea9599 100644 --- a/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts +++ b/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts @@ -7,6 +7,8 @@ export default class { [key: string]: any; }, children: ChildrenFingerprints, updatedPropsFromParent: { [key: string]: any; + }, files: { + [key: string]: FileList; }): { url: string; fetchOptions: RequestInit; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 06ee0e11c32..374c4fb0876 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1900,7 +1900,7 @@ class Component { const thisPromiseResolve = this.nextRequestPromiseResolve; this.resetPromise(); this.unsyncedInputsTracker.resetUnsyncedFields(); - this.backendRequest = this.backend.makeRequest(this.valueStore.getOriginalProps(), this.pendingActions, this.valueStore.getDirtyProps(), this.getChildrenFingerprints(), this.valueStore.getUpdatedPropsFromParent()); + this.backendRequest = this.backend.makeRequest(this.valueStore.getOriginalProps(), this.pendingActions, this.valueStore.getDirtyProps(), this.getChildrenFingerprints(), this.valueStore.getUpdatedPropsFromParent(), {}); this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest); this.pendingActions = []; this.valueStore.flushDirtyPropsToPending(); @@ -2130,7 +2130,7 @@ class RequestBuilder { this.url = url; this.csrfToken = csrfToken; } - buildRequest(props, actions, updated, children, updatedPropsFromParent) { + buildRequest(props, actions, updated, children, updatedPropsFromParent, files) { const splitUrl = this.url.split('?'); let [url] = splitUrl; const [, queryString] = splitUrl; @@ -2139,8 +2139,10 @@ class RequestBuilder { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', }; + const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); const hasFingerprints = Object.keys(children).length > 0; if (actions.length === 0 && + totalFiles === 0 && this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))) { params.set('props', JSON.stringify(props)); params.set('updated', JSON.stringify(updated)); @@ -2154,7 +2156,6 @@ class RequestBuilder { } else { fetchOptions.method = 'POST'; - fetchOptions.headers['Content-Type'] = 'application/json'; const requestData = { props, updated }; if (Object.keys(updatedPropsFromParent).length > 0) { requestData.propsFromParent = updatedPropsFromParent; @@ -2162,10 +2163,11 @@ class RequestBuilder { if (hasFingerprints) { requestData.children = children; } + if (this.csrfToken && + (actions.length || totalFiles)) { + fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken; + } if (actions.length > 0) { - if (this.csrfToken) { - fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken; - } if (actions.length === 1) { requestData.args = actions[0].args; url += `/${encodeURIComponent(actions[0].name)}`; @@ -2175,7 +2177,15 @@ class RequestBuilder { requestData.actions = actions; } } - fetchOptions.body = JSON.stringify(requestData); + const formData = new FormData(); + formData.append('data', JSON.stringify(requestData)); + for (const [key, value] of Object.entries(files)) { + const length = value.length; + for (let i = 0; i < length; ++i) { + formData.append(key, value[i]); + } + } + fetchOptions.body = formData; } const paramsString = params.toString(); return { @@ -2193,8 +2203,8 @@ class Backend { constructor(url, csrfToken = null) { this.requestBuilder = new RequestBuilder(url, csrfToken); } - makeRequest(props, actions, updated, children, updatedPropsFromParent) { - const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent); + makeRequest(props, actions, updated, children, updatedPropsFromParent, files) { + const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files); return new BackendRequest(fetch(url, fetchOptions), actions.map((backendAction) => backendAction.name), Object.keys(updated)); } } From 458ba60890f39b2a313206e5b91240843440a2bb Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Tue, 9 May 2023 11:33:46 +0200 Subject: [PATCH 06/20] Add possibility to send files with an action --- .../assets/src/Component/index.ts | 8 +++++- .../assets/src/live_controller.ts | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index b87c44b8d5b..094ca7dee64 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -55,6 +55,8 @@ export default class Component { private backendRequest: BackendRequest|null = null; /** Actions that are waiting to be executed */ private pendingActions: BackendAction[] = []; + /** Files that are waiting to be sent */ + private pendingFiles: {[key: string]: FileList} = {}; /** Is a request waiting to be made? */ private isRequestPending = false; /** Current "timeout" before the pending request should be sent. */ @@ -194,6 +196,10 @@ export default class Component { return promise; } + files(key: string, fileList: FileList): void { + this.pendingFiles[key] = fileList; + } + render(): Promise { const promise = this.nextRequestPromise; this.tryStartingRequest(); @@ -358,7 +364,7 @@ export default class Component { this.valueStore.getDirtyProps(), this.getChildrenFingerprints(), this.valueStore.getUpdatedPropsFromParent(), - {}, // Uploaded files will go here + this.pendingFiles, ); this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 8c7ebcf4f38..3ad0c63959d 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -66,6 +66,7 @@ export default class LiveControllerDefault extends Controller imple { event: 'change', callback: (event) => this.handleChangeEvent(event) }, { event: 'live:connect', callback: (event) => this.handleConnectedControllerEvent(event) }, ]; + private pendingFiles: {[key: string]: FileList} = {}; static componentRegistry = new ComponentRegistry(); @@ -159,6 +160,7 @@ export default class LiveControllerDefault extends Controller imple let debounce: number | boolean = false; directives.forEach((directive) => { + let pendingFiles: {[key: string]: FileList} = {}; const validModifiers: Map void> = new Map(); validModifiers.set('prevent', () => { event.preventDefault(); @@ -174,6 +176,13 @@ export default class LiveControllerDefault extends Controller imple validModifiers.set('debounce', (modifier: DirectiveModifier) => { debounce = modifier.value ? parseInt(modifier.value) : true; }); + validModifiers.set('files', (modifier: DirectiveModifier) => { + if (!modifier.value) { + pendingFiles = this.pendingFiles; + } else if (this.pendingFiles[modifier.value]) { + pendingFiles[modifier.value] = this.pendingFiles[modifier.value]; + } + }); directive.modifiers.forEach((modifier) => { if (validModifiers.has(modifier.name)) { @@ -191,6 +200,10 @@ export default class LiveControllerDefault extends Controller imple ); }); + for (const [key, files] of Object.entries(pendingFiles)) { + this.component.files(key, files); + delete this.pendingFiles[key]; + } this.component.action(directive.action, directive.named, debounce); // possible case where this element is also a "model" element @@ -322,6 +335,20 @@ export default class LiveControllerDefault extends Controller imple throw new Error('Could not update model for non HTMLElement'); } + // file uploads aren't mapped to model, + // but needs to be scheduled for sending + if ( + element instanceof HTMLInputElement + && element.type === 'file' + ) { + const key = element.dataset.model ?? element.name; + if (element.files?.length) { + this.pendingFiles[key] = element.files; + } else if (this.pendingFiles[key]) { + delete this.pendingFiles[key]; + } + } + const modelDirective = getModelDirectiveFromElement(element, false); // if not tied to a model, no more work to be done From ad70f8141f49276ae2f90c0d0242a2a8af7f4ca3 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Tue, 9 May 2023 11:59:42 +0200 Subject: [PATCH 07/20] Hook up files in Component and build assets --- .../assets/dist/Component/index.d.ts | 2 ++ .../assets/dist/live_controller.d.ts | 1 + .../assets/dist/live_controller.js | 31 ++++++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/LiveComponent/assets/dist/Component/index.d.ts b/src/LiveComponent/assets/dist/Component/index.d.ts index c00eb8f670c..f4208976a81 100644 --- a/src/LiveComponent/assets/dist/Component/index.d.ts +++ b/src/LiveComponent/assets/dist/Component/index.d.ts @@ -20,6 +20,7 @@ export default class Component { defaultDebounce: number; private backendRequest; private pendingActions; + private pendingFiles; private isRequestPending; private requestDebounceTimeout; private nextRequestPromise; @@ -40,6 +41,7 @@ export default class Component { set(model: string, value: any, reRender?: boolean, debounce?: number | boolean): Promise; getData(model: string): any; action(name: string, args?: any, debounce?: number | boolean): Promise; + files(key: string, fileList: FileList): void; render(): Promise; getUnsyncedModels(): string[]; addChild(child: Component, modelBindings?: ModelBinding[]): void; diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index 02f2a38912c..37ff9c48cfc 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -48,6 +48,7 @@ export default class LiveControllerDefault extends Controller imple component: Component; pendingActionTriggerModelElement: HTMLElement | null; private elementEventListeners; + private pendingFiles; static componentRegistry: ComponentRegistry; initialize(): void; connect(): void; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 374c4fb0876..e22ca000ed0 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1724,6 +1724,7 @@ class Component { this.defaultDebounce = 150; this.backendRequest = null; this.pendingActions = []; + this.pendingFiles = {}; this.isRequestPending = false; this.requestDebounceTimeout = null; this.children = new Map(); @@ -1801,6 +1802,9 @@ class Component { this.debouncedStartRequest(debounce); return promise; } + files(key, fileList) { + this.pendingFiles[key] = fileList; + } render() { const promise = this.nextRequestPromise; this.tryStartingRequest(); @@ -1900,7 +1904,7 @@ class Component { const thisPromiseResolve = this.nextRequestPromiseResolve; this.resetPromise(); this.unsyncedInputsTracker.resetUnsyncedFields(); - this.backendRequest = this.backend.makeRequest(this.valueStore.getOriginalProps(), this.pendingActions, this.valueStore.getDirtyProps(), this.getChildrenFingerprints(), this.valueStore.getUpdatedPropsFromParent(), {}); + this.backendRequest = this.backend.makeRequest(this.valueStore.getOriginalProps(), this.pendingActions, this.valueStore.getDirtyProps(), this.getChildrenFingerprints(), this.valueStore.getUpdatedPropsFromParent(), this.pendingFiles); this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest); this.pendingActions = []; this.valueStore.flushDirtyPropsToPending(); @@ -2668,6 +2672,7 @@ class LiveControllerDefault extends Controller { { event: 'change', callback: (event) => this.handleChangeEvent(event) }, { event: 'live:connect', callback: (event) => this.handleConnectedControllerEvent(event) }, ]; + this.pendingFiles = {}; } initialize() { this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this); @@ -2716,6 +2721,7 @@ class LiveControllerDefault extends Controller { const directives = parseDirectives(rawAction); let debounce = false; directives.forEach((directive) => { + let pendingFiles = {}; const validModifiers = new Map(); validModifiers.set('prevent', () => { event.preventDefault(); @@ -2731,6 +2737,14 @@ class LiveControllerDefault extends Controller { validModifiers.set('debounce', (modifier) => { debounce = modifier.value ? parseInt(modifier.value) : true; }); + validModifiers.set('files', (modifier) => { + if (!modifier.value) { + pendingFiles = this.pendingFiles; + } + else if (this.pendingFiles[modifier.value]) { + pendingFiles[modifier.value] = this.pendingFiles[modifier.value]; + } + }); directive.modifiers.forEach((modifier) => { var _a; if (validModifiers.has(modifier.name)) { @@ -2740,6 +2754,10 @@ class LiveControllerDefault extends Controller { } console.warn(`Unknown modifier ${modifier.name} in action "${rawAction}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`); }); + for (const [key, files] of Object.entries(pendingFiles)) { + this.component.files(key, files); + delete this.pendingFiles[key]; + } this.component.action(directive.action, directive.named, debounce); if (getModelDirectiveFromElement(event.currentTarget, false)) { this.pendingActionTriggerModelElement = event.currentTarget; @@ -2809,12 +2827,23 @@ class LiveControllerDefault extends Controller { this.updateModelFromElementEvent(target, 'change'); } updateModelFromElementEvent(element, eventName) { + var _a, _b; if (!elementBelongsToThisComponent(element, this.component)) { return; } if (!(element instanceof HTMLElement)) { throw new Error('Could not update model for non HTMLElement'); } + if (element instanceof HTMLInputElement + && element.type === 'file') { + const key = (_a = element.dataset.model) !== null && _a !== void 0 ? _a : element.name; + if ((_b = element.files) === null || _b === void 0 ? void 0 : _b.length) { + this.pendingFiles[key] = element.files; + } + else if (this.pendingFiles[key]) { + delete this.pendingFiles[key]; + } + } const modelDirective = getModelDirectiveFromElement(element, false); if (!modelDirective) { return; From 7ea3fbbd10a1d06222e17f3d7c4f9dce08da80c1 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Tue, 9 May 2023 12:08:12 +0200 Subject: [PATCH 08/20] Add extremely simple upload files example --- .../LiveComponentDemoController.php | 8 ++++++ .../src/Service/LiveDemoRepository.php | 9 +++++++ ux.symfony.com/src/Twig/UploadFiles.php | 27 +++++++++++++++++++ .../components/UploadFiles.html.twig | 26 ++++++++++++++++++ .../live_component_demo/upload.html.twig | 13 +++++++++ 5 files changed, 83 insertions(+) create mode 100644 ux.symfony.com/src/Twig/UploadFiles.php create mode 100644 ux.symfony.com/templates/components/UploadFiles.html.twig create mode 100644 ux.symfony.com/templates/live_component_demo/upload.html.twig diff --git a/ux.symfony.com/src/Controller/LiveComponentDemoController.php b/ux.symfony.com/src/Controller/LiveComponentDemoController.php index f06a8accd58..78c0697961c 100644 --- a/ux.symfony.com/src/Controller/LiveComponentDemoController.php +++ b/ux.symfony.com/src/Controller/LiveComponentDemoController.php @@ -108,4 +108,12 @@ public function productForm(LiveDemoRepository $liveDemoRepository): Response 'demo' => $liveDemoRepository->find('product_form'), ]); } + + #[Route('/upload', name: 'app_live_components_upload')] + public function uploadFiles(LiveDemoRepository $liveDemoRepository): Response + { + return $this->render('live_component_demo/upload.html.twig', parameters: [ + 'demo' => $liveDemoRepository->find('upload'), + ]); + } } diff --git a/ux.symfony.com/src/Service/LiveDemoRepository.php b/ux.symfony.com/src/Service/LiveDemoRepository.php index 6d5961fa0ff..c9c4c57fea2 100644 --- a/ux.symfony.com/src/Service/LiveDemoRepository.php +++ b/ux.symfony.com/src/Service/LiveDemoRepository.php @@ -98,6 +98,15 @@ public function findAll(): array route: 'app_live_components_product_form', longDescription: <<single = $request->files->get('single'); + $this->multiple = $request->files->all('multiple'); + } +} \ No newline at end of file diff --git a/ux.symfony.com/templates/components/UploadFiles.html.twig b/ux.symfony.com/templates/components/UploadFiles.html.twig new file mode 100644 index 00000000000..9afb3782dcc --- /dev/null +++ b/ux.symfony.com/templates/components/UploadFiles.html.twig @@ -0,0 +1,26 @@ +
+

Note: files aren't persisted and will be lost on each rerender if not stored.

+

+ Single file:
+ Current file: + {% if single %} + {{ single.clientOriginalName }} ({{ single.clientMimeType }}) + {% else %} + none + {% endif %} +

+
+ Multiple files:
+ Current files: +
    + {% for file in multiple %} +
  • {{ file.clientOriginalName }} ({{ file.clientMimeType }})
  • + {% else %} +
  • none
  • + {% endfor %} +
+
+ +
\ No newline at end of file diff --git a/ux.symfony.com/templates/live_component_demo/upload.html.twig b/ux.symfony.com/templates/live_component_demo/upload.html.twig new file mode 100644 index 00000000000..14f5960b059 --- /dev/null +++ b/ux.symfony.com/templates/live_component_demo/upload.html.twig @@ -0,0 +1,13 @@ +{% extends 'liveDemoBase.html.twig' %} + +{% block code_block_left %} + +{% endblock %} + +{% block code_block_right %} + +{% endblock %} + +{% block demo_content %} + {{ component('UploadFiles') }} +{% endblock %} From 610b2164f28368eb7eaf9ca87035881846f30fa6 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Tue, 9 May 2023 12:25:03 +0200 Subject: [PATCH 09/20] Add very simple upload files docs --- src/LiveComponent/doc/index.rst | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 57e9a2c5453..7e74c13d49f 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -1153,6 +1153,53 @@ 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! +.. _files + +Uploading files +--------------- + +.. versionadded:: 2.9 + + The ability to pass arguments to actions was added in version 2.9. + +Files aren't send to the component by default. You need to use a live action +to handle the files and tell the component when the file should be sent: + +.. code-block:: html+twig + +

+ + - \ No newline at end of file + + From 380b4929919fe950a1c5d88839d8e01745002ffe Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Mon, 26 Jun 2023 17:21:21 -0400 Subject: [PATCH 19/20] phpcs --- src/TwigComponent/src/Test/InteractsWithTwigComponents.php | 2 +- ux.symfony.com/src/Twig/UploadFiles.php | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/TwigComponent/src/Test/InteractsWithTwigComponents.php b/src/TwigComponent/src/Test/InteractsWithTwigComponents.php index 810e332c1fa..d9a50533dce 100644 --- a/src/TwigComponent/src/Test/InteractsWithTwigComponents.php +++ b/src/TwigComponent/src/Test/InteractsWithTwigComponents.php @@ -30,7 +30,7 @@ protected function mountTwigComponent(string $name, array $data = []): object /** * @param array $blocks */ - protected function renderTwigComponent(string $name, array $data = [], ?string $content = null, array $blocks = []): RenderedComponent + protected function renderTwigComponent(string $name, array $data = [], string $content = null, array $blocks = []): RenderedComponent { if (!$this instanceof KernelTestCase) { throw new \LogicException(sprintf('The "%s" trait can only be used on "%s" classes.', __TRAIT__, KernelTestCase::class)); diff --git a/ux.symfony.com/src/Twig/UploadFiles.php b/ux.symfony.com/src/Twig/UploadFiles.php index 975cddd2fbc..fbb22ee4566 100644 --- a/ux.symfony.com/src/Twig/UploadFiles.php +++ b/ux.symfony.com/src/Twig/UploadFiles.php @@ -11,7 +11,6 @@ use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\DefaultActionTrait; -use Symfony\UX\LiveComponent\ValidatableComponentTrait; #[AsLiveComponent()] class UploadFiles @@ -54,7 +53,7 @@ public function uploadFiles(Request $request): void private function processFileUpload(UploadedFile $file): array { // in a real app, move this file somewhere - //$file->move(...); + // $file->move(...); return [$file->getClientOriginalName(), $file->getSize()]; } @@ -67,7 +66,7 @@ private function validateSingleFile(UploadedFile $singleFileUpload): void ]), ]); - if (count($errors) === 0) { + if (0 === \count($errors)) { return; } From d29df00c6d13fbdfdca40d6cb0027635cb52e5b1 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Fri, 30 Jun 2023 15:04:27 -0400 Subject: [PATCH 20/20] Fixing test --- .../tests/Functional/Form/ComponentWithFormTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php index 9033546e99d..9bb42938966 100644 --- a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php +++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php @@ -296,10 +296,10 @@ public function testResetForm(): void $browser ->post('/_components/form_with_many_different_fields_type/submitAndResetForm', [ - 'body' => json_encode([ + 'body' => ['data' => json_encode([ 'props' => $dehydratedProps, 'updated' => ['form.textarea' => 'short'], - ]), + ])], 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(200) @@ -309,7 +309,7 @@ public function testResetForm(): void // try resetting without submitting $browser ->post('/_components/form_with_many_different_fields_type/resetFormWithoutSubmitting', [ - 'body' => json_encode(['props' => $dehydratedProps]), + 'body' => ['data' => json_encode(['props' => $dehydratedProps])], 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(200)