From 6d786628e744d1a573aaaabf3a3fe710bce7076e Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 11 Mar 2022 13:26:16 +0100 Subject: [PATCH 01/29] Fix code style --- src/LiveComponent/tests/Fixtures/Component/Component2.php | 2 +- src/LiveComponent/tests/Fixtures/Component/Component4.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/LiveComponent/tests/Fixtures/Component/Component2.php b/src/LiveComponent/tests/Fixtures/Component/Component2.php index b495f950521..4a78bf3bc66 100644 --- a/src/LiveComponent/tests/Fixtures/Component/Component2.php +++ b/src/LiveComponent/tests/Fixtures/Component/Component2.php @@ -14,11 +14,11 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; -use Symfony\UX\LiveComponent\Attribute\PreReRender; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Attribute\PostHydrate; use Symfony\UX\LiveComponent\Attribute\PreDehydrate; +use Symfony\UX\LiveComponent\Attribute\PreReRender; /** * @author Kevin Bond diff --git a/src/LiveComponent/tests/Fixtures/Component/Component4.php b/src/LiveComponent/tests/Fixtures/Component/Component4.php index 37698dd0b5f..fbbc4fb4f13 100644 --- a/src/LiveComponent/tests/Fixtures/Component/Component4.php +++ b/src/LiveComponent/tests/Fixtures/Component/Component4.php @@ -11,11 +11,11 @@ namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; -use Symfony\UX\LiveComponent\Attribute\PreReRender; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Attribute\PostHydrate; use Symfony\UX\LiveComponent\Attribute\PreDehydrate; +use Symfony\UX\LiveComponent\Attribute\PreReRender; /** * @author Kevin Bond From 84c59e0776509985d95af3260d0fbb8b70dd5d5f Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 11 Mar 2022 14:17:37 +0100 Subject: [PATCH 02/29] Pass data as parameter within body to allow multipart data --- src/LiveComponent/assets/src/live_controller.ts | 5 +++-- .../src/EventListener/LiveComponentSubscriber.php | 6 ++++-- .../EventListener/LiveComponentSubscriberTest.php | 8 ++++---- .../tests/Functional/Form/ComponentWithFormTest.php | 4 ++-- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 0c46700dfb2..e289ab7b2dc 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -353,9 +353,10 @@ export default class extends Controller { // if GET can't be used, fallback to POST if (!dataAdded) { + const formData = new FormData(); + formData.append('data', JSON.stringify(this.dataValue)); fetchOptions.method = 'POST'; - fetchOptions.body = JSON.stringify(this.dataValue); - fetchOptions.headers['Content-Type'] = 'application/json'; + fetchOptions.body = formData; } this._onLoadingStart(); diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 78d479064bc..7624703c600 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -121,9 +121,11 @@ public function onKernelController(ControllerEvent $event): void if ($request->query->has('data')) { // ?data= $data = json_decode($request->query->get('data'), true, 512, \JSON_THROW_ON_ERROR); - } else { + } elseif ($request->request->has('data')) { // OR body of the request is JSON - $data = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR); + $data = json_decode($request->request->get('data'), true, 512, \JSON_THROW_ON_ERROR); + } else { + $data = $request->query->all(); } if (!\is_array($controller = $event->getController()) || 2 !== \count($controller)) { diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index aac4337f6ba..42da02763b3 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -71,7 +71,7 @@ public function testCanExecuteComponentAction(): void }) ->post('/_components/component2/increase', [ 'headers' => ['X-CSRF-TOKEN' => $token], - 'body' => json_encode($dehydrated), + 'body' => ['data' => json_encode($dehydrated)], ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') @@ -183,7 +183,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($dehydrated), + 'body' => ['data' => json_encode($dehydrated)], ]) ->assertRedirectedTo('/') @@ -193,7 +193,7 @@ public function testCanRedirectFromComponentAction(): void 'Accept' => 'application/vnd.live-component+html', 'X-CSRF-TOKEN' => $token, ], - 'body' => json_encode($dehydrated), + 'body' => ['data' => json_encode($dehydrated)], ]) ->assertStatus(204) ->assertHeaderEquals('Location', '/') @@ -220,7 +220,7 @@ public function testInjectsLiveArgs(): void }) ->post('/_components/component6/inject?'.$argsQueryParams, [ 'headers' => ['X-CSRF-TOKEN' => $token], - 'body' => json_encode($dehydrated), + 'body' => ['data' => json_encode($dehydrated)], ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php index 565dfb0ec2a..fdcac18a909 100644 --- a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php +++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php @@ -50,7 +50,7 @@ public function testFormValuesRebuildAfterFormChanges(): void // post to action, which will add a new embedded comment ->post('/_components/form_with_collection_type/addComment', [ - 'body' => json_encode($dehydrated), + 'body' => ['data' => json_encode($dehydrated)], 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) @@ -86,7 +86,7 @@ public function testFormValuesRebuildAfterFormChanges(): void // post to action, which will remove the original embedded comment ->post('/_components/form_with_collection_type/removeComment?'.http_build_query(['args' => 'index=0']), [ - 'body' => json_encode($dehydrated), + 'body' => ['data' => json_encode($dehydrated)], 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) From 8b0762f416d3ebfdb844860f9704710924c2e95c Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 11 Mar 2022 15:26:08 +0100 Subject: [PATCH 03/29] Add file upload to _makeRequest --- src/LiveComponent/assets/dist/live_controller.js | 13 ++++++++++--- src/LiveComponent/assets/src/live_controller.ts | 9 ++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 6bc2d23fb15..7f3e443444a 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1182,7 +1182,7 @@ class default_1 extends Controller { }, this.debounceValue || DEFAULT_DEBOUNCE); } } - _makeRequest(action, args) { + _makeRequest(action, args, files = {}) { const splitUrl = this.urlValue.split('?'); let [url] = splitUrl; const [, queryString] = splitUrl; @@ -1210,9 +1210,16 @@ class default_1 extends Controller { } } if (!dataAdded) { + const formData = new FormData(); + formData.append('data', JSON.stringify(this.dataValue)); fetchOptions.method = 'POST'; - fetchOptions.body = JSON.stringify(this.dataValue); - fetchOptions.headers['Content-Type'] = 'application/json'; + fetchOptions.body = formData; + for (const [key, value] of Object.entries(files)) { + const length = value.length; + for (let i = 0; i < length; ++i) { + formData.append(length > 1 ? key + '[]' : key, value[i]); + } + } } this._onLoadingStart(); const paramsString = params.toString(); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index e289ab7b2dc..69ac84f8280 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -318,7 +318,7 @@ export default class extends Controller { } } - _makeRequest(action: string|null, args: Record) { + _makeRequest(action: string|null, args: Record, files: Record = {}) { const splitUrl = this.urlValue.split('?'); let [url] = splitUrl const [, queryString] = splitUrl; @@ -357,6 +357,13 @@ export default class extends Controller { formData.append('data', JSON.stringify(this.dataValue)); fetchOptions.method = 'POST'; fetchOptions.body = formData; + + for (const [key, value] of Object.entries(files)) { + const length = value.length; + for (let i=0; i < length; ++i) { + formData.append(length > 1 ? key+'[]' : key, value[i]); + } + } } this._onLoadingStart(); From 04393941d9d6049070f4e410f91ba757a803dbcf Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 11 Mar 2022 15:53:47 +0100 Subject: [PATCH 04/29] Introduce file modifier to pass files from live targets to action --- .../assets/dist/live_controller.js | 15 ++++++++++++++- .../assets/src/live_controller.ts | 19 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 7f3e443444a..0080d267e63 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1073,10 +1073,11 @@ class default_1 extends Controller { action(event) { const rawAction = event.currentTarget.dataset.actionName; const directives = parseDirectives(rawAction); + const files = {}; directives.forEach((directive) => { const _executeAction = () => { this._clearWaitingDebouncedRenders(); - this._makeRequest(directive.action, directive.named); + this._makeRequest(directive.action, directive.named, files); }; let handled = false; directive.modifiers.forEach((modifier) => { @@ -1105,6 +1106,17 @@ class default_1 extends Controller { handled = true; break; } + case 'file': + if (!modifier.value) { + console.warn(`Modifier file requires value in action ${rawAction}`); + break; + } + this.fileTargets.forEach(input => { + if (input.name === modifier.value) { + files[input.name] = input.files; + } + }); + break; default: console.warn(`Unknown modifier ${modifier.name} in action ${rawAction}`); } @@ -1546,6 +1558,7 @@ class default_1 extends Controller { }); } } +default_1.targets = ['file']; default_1.values = { url: String, data: Object, diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 69ac84f8280..755879843d9 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -18,6 +18,7 @@ declare const Turbo: any; const DEFAULT_DEBOUNCE = 150; export default class extends Controller { + static targets = [ 'file' ] static values = { url: String, data: Object, @@ -132,6 +133,8 @@ export default class extends Controller { // data-action-name="prevent|debounce(1000)|save" const directives = parseDirectives(rawAction); + const files: Record = {}; + directives.forEach((directive) => { // set here so it can be delayed with debouncing below const _executeAction = () => { @@ -144,7 +147,7 @@ export default class extends Controller { // taking precedence this._clearWaitingDebouncedRenders(); - this._makeRequest(directive.action, directive.named); + this._makeRequest(directive.action, directive.named, files); } let handled = false; @@ -179,6 +182,20 @@ export default class extends Controller { break; } + case 'file': + if (!modifier.value) { + console.warn(`Modifier file requires value in action ${rawAction}`); + + break; + } + + this.fileTargets.forEach(input => { + if (input.name === modifier.value) { + files[input.name] = input.files; + } + }) + + break; default: console.warn(`Unknown modifier ${modifier.name} in action ${rawAction}`); From d07e589ae0ca7b5af42d12de1f8ddefc30d8d358 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 11 Mar 2022 16:03:43 +0100 Subject: [PATCH 05/29] Rename modifier to `files` and send all files by default --- src/LiveComponent/assets/dist/live_controller.js | 8 ++------ src/LiveComponent/assets/src/live_controller.ts | 10 ++-------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 0080d267e63..36a1ce71d83 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1106,13 +1106,9 @@ class default_1 extends Controller { handled = true; break; } - case 'file': - if (!modifier.value) { - console.warn(`Modifier file requires value in action ${rawAction}`); - break; - } + case 'files': this.fileTargets.forEach(input => { - if (input.name === modifier.value) { + if (!modifier.value || input.name === modifier.value) { files[input.name] = input.files; } }); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 755879843d9..ab76e8f4cee 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -182,15 +182,9 @@ export default class extends Controller { break; } - case 'file': - if (!modifier.value) { - console.warn(`Modifier file requires value in action ${rawAction}`); - - break; - } - + case 'files': this.fileTargets.forEach(input => { - if (input.name === modifier.value) { + if (!modifier.value || input.name === modifier.value) { files[input.name] = input.files; } }) From 19bc2705a94ca0f4768f3fca6cd26f5643dac08a Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 11 Mar 2022 16:09:45 +0100 Subject: [PATCH 06/29] Fix failing test --- src/LiveComponent/assets/test/controller/action.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LiveComponent/assets/test/controller/action.test.ts b/src/LiveComponent/assets/test/controller/action.test.ts index fb4e69d4191..93e6f606f21 100644 --- a/src/LiveComponent/assets/test/controller/action.test.ts +++ b/src/LiveComponent/assets/test/controller/action.test.ts @@ -65,7 +65,7 @@ describe('LiveController Action Tests', () => { await waitFor(() => expect(element).toHaveTextContent('Comment Saved!')); expect(getByLabelText(element, 'Comments:')).toHaveValue('hi weaver'); - const bodyData = JSON.parse(postMock.lastOptions().body); + const bodyData = JSON.parse(postMock.lastOptions().body.get('data')); expect(bodyData.comments).toEqual('hi WEAVER'); }); From e31f591d472956df616cc80e4808c8d05b9ece8b Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 18 Mar 2022 11:17:24 +0100 Subject: [PATCH 07/29] Don't update file targets in morhpdom --- src/LiveComponent/assets/dist/live_controller.js | 3 +++ src/LiveComponent/assets/src/live_controller.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 36a1ce71d83..5d2d4b438ba 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1419,6 +1419,9 @@ class default_1 extends Controller { if (fromEl.hasAttribute('data-live-ignore')) { return false; } + if (this.fileTargets.includes(fromEl)) { + return false; + } return true; } }); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index ab76e8f4cee..1ced415e83f 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -645,6 +645,11 @@ export default class extends Controller { return false; } + // Don't update file targets + if (this.fileTargets.includes(fromEl)) { + return false; + } + return true; } }); From 8e53081c572406f23cd5da3e8499859bbd05dc44 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 25 Mar 2022 13:26:46 +0100 Subject: [PATCH 08/29] Remove multiple files key handling - should be done by consumer --- src/LiveComponent/assets/dist/live_controller.js | 2 +- src/LiveComponent/assets/src/live_controller.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 5d2d4b438ba..b526d25e5de 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1225,7 +1225,7 @@ class default_1 extends Controller { for (const [key, value] of Object.entries(files)) { const length = value.length; for (let i = 0; i < length; ++i) { - formData.append(length > 1 ? key + '[]' : key, value[i]); + formData.append(key, value[i]); } } } diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 1ced415e83f..831c05ac9ec 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -372,7 +372,7 @@ export default class extends Controller { for (const [key, value] of Object.entries(files)) { const length = value.length; for (let i=0; i < length; ++i) { - formData.append(length > 1 ? key+'[]' : key, value[i]); + formData.append(key, value[i]); } } } From 365efa2fb719ad5dbd3e9af1fa20d16a4e11fc22 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 1 Apr 2022 13:34:02 +0200 Subject: [PATCH 09/29] Add file upload info to docs --- src/LiveComponent/src/Resources/doc/index.rst | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/LiveComponent/src/Resources/doc/index.rst b/src/LiveComponent/src/Resources/doc/index.rst index 4805be69f47..e72bb27b3cc 100644 --- a/src/LiveComponent/src/Resources/doc/index.rst +++ b/src/LiveComponent/src/Resources/doc/index.rst @@ -571,6 +571,7 @@ Actions & Arguments You can also provide custom arguments to your action:: .. code-block:: twig +
@@ -597,6 +598,36 @@ args but inject to your defined parameter with another name.:: } } +Actions and file uploads +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.2 + + The ability to pass arguments to actions was added in version 2.2. + +If you want live component to track and send files you first need +to mark file upload inputs as ``file`` Stimulus target.:: + +.. code-block:: twig + + + +Then, when defining action you need to use special ``files(name)`` modifier.:: + +.. code-block:: twig + +
+
+ +This will send files from ``my_file`` file input. When used without argument +it would send all files from all ``file`` targets of the controller. + +If you want to send multiple files from a single input remember to suffix its' name +with ``[]`` - both in HTML name attribute and ``files`` modifier argument. + Actions and CSRF Protection ~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 7d8121495aab186e5177d00aefb3f59a0e820dfa Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 13 May 2022 12:33:26 +0200 Subject: [PATCH 10/29] Throw when no file target is found --- src/LiveComponent/assets/dist/live_controller.js | 3 +++ src/LiveComponent/assets/src/live_controller.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index b526d25e5de..9eeaee5b855 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1112,6 +1112,9 @@ class default_1 extends Controller { files[input.name] = input.files; } }); + if (modifier.value && !files[modifier.value]) { + throw new Error(`Could not find the file input foo. Did you remember to make this element a Stimulus target (e.g. {{ stimulus_target('live', 'file') }}).`); + } break; default: console.warn(`Unknown modifier ${modifier.name} in action ${rawAction}`); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 831c05ac9ec..a3e923f8c60 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -188,6 +188,9 @@ export default class extends Controller { files[input.name] = input.files; } }) + if (modifier.value && !files[modifier.value]) { + throw new Error(`Could not find the file input foo. Did you remember to make this element a Stimulus target (e.g. {{ stimulus_target('live', 'file') }}).`); + } break; From dbb0cd1437675d129ddbc0e2b7bbbc8cf8313ad7 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 13 May 2022 12:42:47 +0200 Subject: [PATCH 11/29] Throw on missing live component data and fix test --- .../src/EventListener/LiveComponentSubscriber.php | 4 ++-- .../Functional/EventListener/LiveComponentSubscriberTest.php | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 7624703c600..fae6039417f 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -122,10 +122,10 @@ public function onKernelController(ControllerEvent $event): void // ?data= $data = json_decode($request->query->get('data'), true, 512, \JSON_THROW_ON_ERROR); } elseif ($request->request->has('data')) { - // OR body of the request is JSON + // OR data key from POST data $data = json_decode($request->request->get('data'), true, 512, \JSON_THROW_ON_ERROR); } else { - $data = $request->query->all(); + throw new BadRequestHttpException('Missing live component data.'); } if (!\is_array($controller = $event->getController()) || 2 !== \count($controller)) { diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index 42da02763b3..89b5e3a0f4b 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -122,6 +122,7 @@ public function testInvalidCsrfTokenForComponentActionFails(): void ->throwExceptions() ->post('/_components/component2/increase', [ 'headers' => ['X-CSRF-TOKEN' => 'invalid'], + 'body' => ['data' => '[]'] ]) ; } catch (BadRequestHttpException $e) { @@ -144,7 +145,7 @@ public function testDisabledCsrfTokenForComponentDoesNotFail(): void ->assertHeaderContains('Content-Type', 'html') ->assertContains('Count: 1') ->post('/_components/disabled_csrf/increase', [ - 'body' => json_encode($dehydrated), + 'body' => ['data' => json_encode($dehydrated)], ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') From 4426e73b2d08d376b88a88a86d859bf561ec7055 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 13 May 2022 13:09:56 +0200 Subject: [PATCH 12/29] Introduce LiveFileArg attribute and use it to autowire files --- .../src/Attribute/LiveFileArg.php | 54 +++++++++++++++++++ .../EventListener/LiveComponentSubscriber.php | 12 +++++ 2 files changed, 66 insertions(+) create mode 100644 src/LiveComponent/src/Attribute/LiveFileArg.php diff --git a/src/LiveComponent/src/Attribute/LiveFileArg.php b/src/LiveComponent/src/Attribute/LiveFileArg.php new file mode 100644 index 00000000000..1f5694df211 --- /dev/null +++ b/src/LiveComponent/src/Attribute/LiveFileArg.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Attribute; + +/** + * @author Jakub Caban + * + * @experimental + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +final class LiveFileArg +{ + public function __construct( + public ?string $name = null, + public bool $multiple = false, + public array $constraints = [] + ) + { + } + + /** + * @internal + * + * @return array + */ + public static function liveFileArgs(object $component, string $action): array + { + $method = new \ReflectionMethod($component, $action); + $liveFileArgs = []; + + foreach ($method->getParameters() as $parameter) { + foreach ($parameter->getAttributes(self::class) as $liveArg) { + /** @var LiveFileArg $attr */ + $attr = $liveArg->newInstance(); + $parameterName = $parameter->getName(); + + $attr->name ??= $parameterName; + + $liveFileArgs[$parameterName] = $attr; + } + } + + return $liveFileArgs; + } +} diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index fae6039417f..43dc379ad18 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -29,6 +29,7 @@ use Symfony\Contracts\Service\ServiceSubscriberInterface; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveArg; +use Symfony\UX\LiveComponent\Attribute\LiveFileArg; use Symfony\UX\LiveComponent\LiveComponentHydrator; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentMetadata; @@ -150,6 +151,17 @@ public function onKernelController(ControllerEvent $event): void $request->attributes->set('_mounted_component', $mounted); + // autowire live file arguments + foreach (LiveFileArg::liveFileArgs($component, $action) as $parameter => $fileArg) { + if ($request->files->has($fileArg->name)) { + $files = $request->files->get($fileArg->name); + $request->attributes->set( + $parameter, + $fileArg->multiple ? $files : $files[0] + ); + } + } + if (!\is_string($queryString = $request->query->get('args'))) { return; } From b776f84dafe36ebae92c15c9777ff0c743965d87 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 13 May 2022 13:11:34 +0200 Subject: [PATCH 13/29] Autowire files only when files are sent --- .../EventListener/LiveComponentSubscriber.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 43dc379ad18..5a736cec92d 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -152,13 +152,15 @@ public function onKernelController(ControllerEvent $event): void $request->attributes->set('_mounted_component', $mounted); // autowire live file arguments - foreach (LiveFileArg::liveFileArgs($component, $action) as $parameter => $fileArg) { - if ($request->files->has($fileArg->name)) { - $files = $request->files->get($fileArg->name); - $request->attributes->set( - $parameter, - $fileArg->multiple ? $files : $files[0] - ); + if ($request->files->count()) { + foreach (LiveFileArg::liveFileArgs($component, $action) as $parameter => $fileArg) { + if ($request->files->has($fileArg->name)) { + $files = $request->files->get($fileArg->name); + $request->attributes->set( + $parameter, + $fileArg->multiple ? $files : $files[0] + ); + } } } From 820e8999d098c0bfc1e4a254bbd56bb5a7fd4e2c Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 13 May 2022 13:36:46 +0200 Subject: [PATCH 14/29] Handle nested property paths in files --- src/LiveComponent/src/Attribute/LiveFileArg.php | 5 +++++ .../EventListener/LiveComponentSubscriber.php | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/LiveComponent/src/Attribute/LiveFileArg.php b/src/LiveComponent/src/Attribute/LiveFileArg.php index 1f5694df211..57d09fad793 100644 --- a/src/LiveComponent/src/Attribute/LiveFileArg.php +++ b/src/LiveComponent/src/Attribute/LiveFileArg.php @@ -27,6 +27,11 @@ public function __construct( { } + public function getPropertyPath(): string + { + return preg_replace('/^([^[]+)/', '[$1]', $this->name); + } + /** * @internal * diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 5a736cec92d..1055e170d01 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -24,6 +24,7 @@ use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; @@ -153,13 +154,25 @@ public function onKernelController(ControllerEvent $event): void // autowire live file arguments if ($request->files->count()) { + $allFiles = $request->files->all(); + $accessor = PropertyAccess::createPropertyAccessor(); foreach (LiveFileArg::liveFileArgs($component, $action) as $parameter => $fileArg) { - if ($request->files->has($fileArg->name)) { - $files = $request->files->get($fileArg->name); + $path = $fileArg->getPropertyPath(); + + if (!$accessor->isReadable($allFiles, $path)) { + throw new \RuntimeException(sprintf('File path "%s" for parameter "%s" is invalid', $fileArg->name, $parameter)); + } + + if ($files = $accessor->getValue($allFiles, $fileArg->getPropertyPath())) { $request->attributes->set( $parameter, $fileArg->multiple ? $files : $files[0] ); + } else { + $request->attributes->set( + $parameter, + $fileArg->multiple ? [] : null + ); } } } From e6d2331af9dac1040c6043917999b9148179d624 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 13 May 2022 13:49:23 +0200 Subject: [PATCH 15/29] Code Style --- src/LiveComponent/src/Attribute/LiveFileArg.php | 3 +-- .../Functional/EventListener/LiveComponentSubscriberTest.php | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/LiveComponent/src/Attribute/LiveFileArg.php b/src/LiveComponent/src/Attribute/LiveFileArg.php index 57d09fad793..5361ccec8c0 100644 --- a/src/LiveComponent/src/Attribute/LiveFileArg.php +++ b/src/LiveComponent/src/Attribute/LiveFileArg.php @@ -23,8 +23,7 @@ public function __construct( public ?string $name = null, public bool $multiple = false, public array $constraints = [] - ) - { + ) { } public function getPropertyPath(): string diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index 89b5e3a0f4b..50fea8253b6 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -122,7 +122,7 @@ public function testInvalidCsrfTokenForComponentActionFails(): void ->throwExceptions() ->post('/_components/component2/increase', [ 'headers' => ['X-CSRF-TOKEN' => 'invalid'], - 'body' => ['data' => '[]'] + 'body' => ['data' => '[]'], ]) ; } catch (BadRequestHttpException $e) { From 2e22f1e4373f153464ab16139f49a8100e019cc6 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 13 May 2022 14:22:19 +0200 Subject: [PATCH 16/29] Reuse variable --- src/LiveComponent/src/EventListener/LiveComponentSubscriber.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 1055e170d01..e8db1980f42 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -163,7 +163,7 @@ public function onKernelController(ControllerEvent $event): void throw new \RuntimeException(sprintf('File path "%s" for parameter "%s" is invalid', $fileArg->name, $parameter)); } - if ($files = $accessor->getValue($allFiles, $fileArg->getPropertyPath())) { + if ($files = $accessor->getValue($allFiles, $path)) { $request->attributes->set( $parameter, $fileArg->multiple ? $files : $files[0] From 2f0e127a38d581d3f4a33c3346cd57ddfe365d11 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 3 Jun 2022 10:25:47 +0200 Subject: [PATCH 17/29] Remove file targets hack --- src/LiveComponent/assets/dist/live_controller.js | 3 --- src/LiveComponent/assets/src/live_controller.ts | 5 ----- 2 files changed, 8 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 9eeaee5b855..154f66458a7 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1422,9 +1422,6 @@ class default_1 extends Controller { if (fromEl.hasAttribute('data-live-ignore')) { return false; } - if (this.fileTargets.includes(fromEl)) { - return false; - } return true; } }); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index a3e923f8c60..370f998a961 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -648,11 +648,6 @@ export default class extends Controller { return false; } - // Don't update file targets - if (this.fileTargets.includes(fromEl)) { - return false; - } - return true; } }); From 3dfe5ae29e7c1e06ab459a055859c5bf88dc97a7 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 3 Jun 2022 10:29:23 +0200 Subject: [PATCH 18/29] Rename files modifier to upload_files --- src/LiveComponent/assets/dist/live_controller.js | 2 +- src/LiveComponent/assets/src/live_controller.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 154f66458a7..3ad0283cad2 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1106,7 +1106,7 @@ class default_1 extends Controller { handled = true; break; } - case 'files': + case 'upload_files': this.fileTargets.forEach(input => { if (!modifier.value || input.name === modifier.value) { files[input.name] = input.files; diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 370f998a961..fd58178fa37 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -182,7 +182,7 @@ export default class extends Controller { break; } - case 'files': + case 'upload_files': this.fileTargets.forEach(input => { if (!modifier.value || input.name === modifier.value) { files[input.name] = input.files; From 6b7a90b0e06d602864c0ce3d54f00ba6410bd735 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 3 Jun 2022 11:37:33 +0200 Subject: [PATCH 19/29] Only save files on update and send them when needed --- .../assets/dist/live_controller.js | 32 ++++++++++++----- .../assets/src/live_controller.ts | 34 ++++++++++++++----- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 3ad0283cad2..8ec97371df9 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1024,6 +1024,7 @@ class default_1 extends Controller { this.renderDebounceTimeout = null; this.actionDebounceTimeout = null; this.renderPromiseStack = new PromiseStack(); + this.fileInputs = {}; this.pollingIntervals = []; this.isWindowUnloaded = false; this.originalDataJSON = '{}'; @@ -1107,13 +1108,24 @@ class default_1 extends Controller { break; } case 'upload_files': - this.fileTargets.forEach(input => { - if (!modifier.value || input.name === modifier.value) { - files[input.name] = input.files; + if (modifier.value) { + const input = this.fileInputs[modifier.value]; + if (input && input.files) { + files[modifier.value] = input.files; + } + else if (input) { + delete this.fileInputs[modifier.value]; + } + } + else { + for (const [key, input] of Object.entries(this.fileInputs)) { + if (input && input.files) { + files[key] = input.files; + } + else if (input) { + delete this.fileInputs[key]; + } } - }); - if (modifier.value && !files[modifier.value]) { - throw new Error(`Could not find the file input foo. Did you remember to make this element a Stimulus target (e.g. {{ stimulus_target('live', 'file') }}).`); } break; default: @@ -1141,7 +1153,12 @@ class default_1 extends Controller { throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-model" or "name" attribute set to the model name.`); } let finalValue = value; - if (/\[]$/.test(model)) { + if (element instanceof HTMLInputElement + && element.type === 'file') { + this.fileInputs[model] = element; + return; + } + else if (/\[]$/.test(model)) { const { currentLevelData, finalKey } = parseDeepData(this.dataValue, normalizeModelName(model)); const currentValue = currentLevelData[finalKey]; finalValue = updateArrayDataFromChangedElement(element, value, currentValue); @@ -1557,7 +1574,6 @@ class default_1 extends Controller { }); } } -default_1.targets = ['file']; default_1.values = { url: String, data: Object, diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index fd58178fa37..a17e67ecefa 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -7,6 +7,7 @@ import { haveRenderedValuesChanged } from './have_rendered_values_changed'; import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison'; import { cloneHTMLElement } from './clone_html_element'; import { updateArrayDataFromChangedElement } from "./update_array_data"; +import * as assert from "assert"; interface ElementLoadingDirectives { element: HTMLElement|SVGElement, @@ -18,7 +19,6 @@ declare const Turbo: any; const DEFAULT_DEBOUNCE = 150; export default class extends Controller { - static targets = [ 'file' ] static values = { url: String, data: Object, @@ -55,6 +55,8 @@ export default class extends Controller { */ renderPromiseStack = new PromiseStack(); + fileInputs: Record = {} + pollingIntervals: NodeJS.Timer[] = []; isWindowUnloaded = false; @@ -183,13 +185,21 @@ export default class extends Controller { break; } case 'upload_files': - this.fileTargets.forEach(input => { - if (!modifier.value || input.name === modifier.value) { - files[input.name] = input.files; + if (modifier.value) { + const input = this.fileInputs[modifier.value]; + if (input && input.files) { + files[modifier.value] = input.files; + } else if (input) { + delete this.fileInputs[modifier.value]; + } + } else { + for (const [key, input] of Object.entries(this.fileInputs)) { + if (input && input.files) { + files[key] = input.files; + } else if (input) { + delete this.fileInputs[key]; + } } - }) - if (modifier.value && !files[modifier.value]) { - throw new Error(`Could not find the file input foo. Did you remember to make this element a Stimulus target (e.g. {{ stimulus_target('live', 'file') }}).`); } break; @@ -229,7 +239,15 @@ export default class extends Controller { // we need to handle addition and removal of values from it to send // back only required data let finalValue : string|null|string[] = value - if (/\[]$/.test(model)) { + if ( + element instanceof HTMLInputElement + && element.type === 'file' + ) { + // Save file input reference for later and don't upload immediately + this.fileInputs[model] = element; + + return; + } else if (/\[]$/.test(model)) { // Get current value from data const { currentLevelData, finalKey } = parseDeepData(this.dataValue, normalizeModelName(model)) const currentValue = currentLevelData[finalKey]; From 3948f00d9e1608f02c4491f69dc806de58ef01d3 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 3 Jun 2022 11:51:37 +0200 Subject: [PATCH 20/29] Add uploadFile shortcut method --- .../assets/dist/live_controller.js | 14 ++++++++++++-- .../assets/src/directives_parser.ts | 2 +- .../assets/src/live_controller.ts | 18 ++++++++++++++---- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 8ec97371df9..22135a21846 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1071,7 +1071,16 @@ class default_1 extends Controller { updateDefer(event) { this._updateModelFromElement(event.target, this._getValueFromElement(event.target), false); } - action(event) { + uploadFile(event) { + this._updateModelFromElement(event.target, this._getValueFromElement(event.target), false); + const model = event.target.dataset.model || event.target.getAttribute('name'); + const modifier = { + name: 'upload_files', + value: model + }; + this.action(event, [modifier]); + } + action(event, autoModifiers = []) { const rawAction = event.currentTarget.dataset.actionName; const directives = parseDirectives(rawAction); const files = {}; @@ -1081,7 +1090,8 @@ class default_1 extends Controller { this._makeRequest(directive.action, directive.named, files); }; let handled = false; - directive.modifiers.forEach((modifier) => { + const modifiers = [...autoModifiers, ...directive.modifiers]; + modifiers.forEach((modifier) => { switch (modifier.name) { case 'prevent': event.preventDefault(); diff --git a/src/LiveComponent/assets/src/directives_parser.ts b/src/LiveComponent/assets/src/directives_parser.ts index b7669ee8a4b..58a2f7f9ad0 100644 --- a/src/LiveComponent/assets/src/directives_parser.ts +++ b/src/LiveComponent/assets/src/directives_parser.ts @@ -1,7 +1,7 @@ /** * A modifier for a directive */ -interface DirectiveModifier { +export interface DirectiveModifier { /** * The name of the modifier (e.g. delay) */ diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index a17e67ecefa..42d3bdc7e68 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -1,13 +1,12 @@ import { Controller } from '@hotwired/stimulus'; import morphdom from 'morphdom'; -import { parseDirectives, Directive } from './directives_parser'; +import { parseDirectives, Directive, DirectiveModifier } from './directives_parser'; import { combineSpacedArray } from './string_utils'; import { setDeepData, doesDeepPropertyExist, normalizeModelName, parseDeepData } from './set_deep_data'; import { haveRenderedValuesChanged } from './have_rendered_values_changed'; import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison'; import { cloneHTMLElement } from './clone_html_element'; import { updateArrayDataFromChangedElement } from "./update_array_data"; -import * as assert from "assert"; interface ElementLoadingDirectives { element: HTMLElement|SVGElement, @@ -124,7 +123,17 @@ export default class extends Controller { this._updateModelFromElement(event.target, this._getValueFromElement(event.target), false); } - action(event: any) { + uploadFile(event: any) { + this._updateModelFromElement(event.target, this._getValueFromElement(event.target), false); + const model = event.target.dataset.model || event.target.getAttribute('name'); + const modifier = { + name: 'upload_files', + value: model + } + this.action(event, [modifier]); + } + + action(event: any, autoModifiers: DirectiveModifier[] = []) { // using currentTarget means that the data-action and data-action-name // must live on the same element: you can't add // data-action="click->live#action" on a parent element and @@ -153,7 +162,8 @@ export default class extends Controller { } let handled = false; - directive.modifiers.forEach((modifier) => { + const modifiers: DirectiveModifier[] = [...autoModifiers, ...directive.modifiers]; + modifiers.forEach((modifier) => { switch (modifier.name) { case 'prevent': event.preventDefault(); From eaa70b42e8c455a0cafda2c803103ca5f2c7a841 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 3 Jun 2022 11:54:56 +0200 Subject: [PATCH 21/29] Don't throw on unreadable property path --- .../src/EventListener/LiveComponentSubscriber.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index e8db1980f42..cdeb1942720 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -159,11 +159,10 @@ public function onKernelController(ControllerEvent $event): void foreach (LiveFileArg::liveFileArgs($component, $action) as $parameter => $fileArg) { $path = $fileArg->getPropertyPath(); - if (!$accessor->isReadable($allFiles, $path)) { - throw new \RuntimeException(sprintf('File path "%s" for parameter "%s" is invalid', $fileArg->name, $parameter)); - } - - if ($files = $accessor->getValue($allFiles, $path)) { + if ( + $accessor->isReadable($allFiles, $path) + && ($files = $accessor->getValue($allFiles, $path)) + ) { $request->attributes->set( $parameter, $fileArg->multiple ? $files : $files[0] From 77a6797756eeadb5e80e4a20c89e98eb8394cbe1 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 3 Jun 2022 11:57:38 +0200 Subject: [PATCH 22/29] Empty fallback is unnecessary here --- .../src/EventListener/LiveComponentSubscriber.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index cdeb1942720..cce05b21384 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -167,11 +167,6 @@ public function onKernelController(ControllerEvent $event): void $parameter, $fileArg->multiple ? $files : $files[0] ); - } else { - $request->attributes->set( - $parameter, - $fileArg->multiple ? [] : null - ); } } } From d829cddda465aee42392e170ee082abda113f6ff Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 3 Jun 2022 12:01:01 +0200 Subject: [PATCH 23/29] Remove pointless constraints attribute --- src/LiveComponent/src/Attribute/LiveFileArg.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/LiveComponent/src/Attribute/LiveFileArg.php b/src/LiveComponent/src/Attribute/LiveFileArg.php index 5361ccec8c0..c28c5cddd68 100644 --- a/src/LiveComponent/src/Attribute/LiveFileArg.php +++ b/src/LiveComponent/src/Attribute/LiveFileArg.php @@ -21,8 +21,7 @@ final class LiveFileArg { public function __construct( public ?string $name = null, - public bool $multiple = false, - public array $constraints = [] + public bool $multiple = false ) { } From 50baf1457f861c1fcfd3bec0f7a8ea855a58d339 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 3 Jun 2022 12:53:15 +0200 Subject: [PATCH 24/29] Expect file model to have simple name and remove PropertyAccessor spaghetti --- src/LiveComponent/src/Attribute/LiveFileArg.php | 5 ----- .../src/EventListener/LiveComponentSubscriber.php | 9 +++------ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/LiveComponent/src/Attribute/LiveFileArg.php b/src/LiveComponent/src/Attribute/LiveFileArg.php index c28c5cddd68..b22f490aa43 100644 --- a/src/LiveComponent/src/Attribute/LiveFileArg.php +++ b/src/LiveComponent/src/Attribute/LiveFileArg.php @@ -25,11 +25,6 @@ public function __construct( ) { } - public function getPropertyPath(): string - { - return preg_replace('/^([^[]+)/', '[$1]', $this->name); - } - /** * @internal * diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index cce05b21384..bb807a367b7 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -154,15 +154,12 @@ public function onKernelController(ControllerEvent $event): void // autowire live file arguments if ($request->files->count()) { - $allFiles = $request->files->all(); - $accessor = PropertyAccess::createPropertyAccessor(); foreach (LiveFileArg::liveFileArgs($component, $action) as $parameter => $fileArg) { - $path = $fileArg->getPropertyPath(); - if ( - $accessor->isReadable($allFiles, $path) - && ($files = $accessor->getValue($allFiles, $path)) + $request->files->has($fileArg->name) ) { + $files = $request->files->all($fileArg->name); + $request->attributes->set( $parameter, $fileArg->multiple ? $files : $files[0] From c0a5736df183cb22af36f0a607a16258c3fb9617 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 3 Jun 2022 13:20:54 +0200 Subject: [PATCH 25/29] Make autowiring smart to some extent --- .../src/Attribute/LiveFileArg.php | 30 +++++++++++++++++-- .../EventListener/LiveComponentSubscriber.php | 10 ++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/LiveComponent/src/Attribute/LiveFileArg.php b/src/LiveComponent/src/Attribute/LiveFileArg.php index b22f490aa43..51083200032 100644 --- a/src/LiveComponent/src/Attribute/LiveFileArg.php +++ b/src/LiveComponent/src/Attribute/LiveFileArg.php @@ -11,6 +11,9 @@ namespace Symfony\UX\LiveComponent\Attribute; + +use ReflectionNamedType; + /** * @author Jakub Caban * @@ -19,12 +22,25 @@ #[\Attribute(\Attribute::TARGET_PARAMETER)] final class LiveFileArg { + private ?array $types = null; + public function __construct( - public ?string $name = null, - public bool $multiple = false + public ?string $name = null ) { } + public function isValueCompatible(mixed $value): bool { + if (null === $this->types) { + return true; + } + $type = gettype($value); + if ('object' === $type) { + $type = $value::class; + } + + return in_array($type, $this->types, true); + } + /** * @internal * @@ -42,6 +58,16 @@ public static function liveFileArgs(object $component, string $action): array $parameterName = $parameter->getName(); $attr->name ??= $parameterName; + if ($type = $parameter->getType()) { + if ($type instanceof ReflectionNamedType) { + $attr->types = [$type->getName()]; + } else { + $attr->types = array_map( + static fn (ReflectionNamedType $type) => $type->getName(), + $type->getTypes() + ); + } + } $liveFileArgs[$parameterName] = $attr; } diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index bb807a367b7..5ff28e428cd 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -160,9 +160,17 @@ public function onKernelController(ControllerEvent $event): void ) { $files = $request->files->all($fileArg->name); + $value = null; + if (count($files) === 1 && $fileArg->isValueCompatible($files[0])) { + $value = $files[0]; + } else if ($fileArg->isValueCompatible($files)) { + $value = $files; + } else { + throw new BadRequestHttpException("Could not autowire uploaded files for {$fileArg->name} parameter."); + } $request->attributes->set( $parameter, - $fileArg->multiple ? $files : $files[0] + $value ); } } From ddcc41c485dc7c2c49a54c846e474111dd161a34 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 3 Jun 2022 13:29:33 +0200 Subject: [PATCH 26/29] Code style --- .../src/Attribute/LiveFileArg.php | 19 ++++++++----------- .../EventListener/LiveComponentSubscriber.php | 5 ++--- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/LiveComponent/src/Attribute/LiveFileArg.php b/src/LiveComponent/src/Attribute/LiveFileArg.php index 51083200032..9100cf746f7 100644 --- a/src/LiveComponent/src/Attribute/LiveFileArg.php +++ b/src/LiveComponent/src/Attribute/LiveFileArg.php @@ -11,9 +11,6 @@ namespace Symfony\UX\LiveComponent\Attribute; - -use ReflectionNamedType; - /** * @author Jakub Caban * @@ -24,21 +21,21 @@ final class LiveFileArg { private ?array $types = null; - public function __construct( - public ?string $name = null - ) { + public function __construct(public ?string $name = null) + { } - public function isValueCompatible(mixed $value): bool { + public function isValueCompatible(mixed $value): bool + { if (null === $this->types) { return true; } - $type = gettype($value); + $type = \gettype($value); if ('object' === $type) { $type = $value::class; } - return in_array($type, $this->types, true); + return \in_array($type, $this->types, true); } /** @@ -59,11 +56,11 @@ public static function liveFileArgs(object $component, string $action): array $attr->name ??= $parameterName; if ($type = $parameter->getType()) { - if ($type instanceof ReflectionNamedType) { + if ($type instanceof \ReflectionNamedType) { $attr->types = [$type->getName()]; } else { $attr->types = array_map( - static fn (ReflectionNamedType $type) => $type->getName(), + static fn (\ReflectionNamedType $type) => $type->getName(), $type->getTypes() ); } diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 5ff28e428cd..653c328a75d 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -24,7 +24,6 @@ use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; -use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; @@ -161,9 +160,9 @@ public function onKernelController(ControllerEvent $event): void $files = $request->files->all($fileArg->name); $value = null; - if (count($files) === 1 && $fileArg->isValueCompatible($files[0])) { + if (1 === \count($files) && $fileArg->isValueCompatible($files[0])) { $value = $files[0]; - } else if ($fileArg->isValueCompatible($files)) { + } elseif ($fileArg->isValueCompatible($files)) { $value = $files; } else { throw new BadRequestHttpException("Could not autowire uploaded files for {$fileArg->name} parameter."); From d05836adb831e2f139f552ac3fcb5bb287d1ae63 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 10 Jun 2022 13:18:54 +0200 Subject: [PATCH 27/29] Fix single file upload --- .../src/EventListener/LiveComponentSubscriber.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 653c328a75d..ebe37185c3f 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -157,10 +157,14 @@ public function onKernelController(ControllerEvent $event): void if ( $request->files->has($fileArg->name) ) { - $files = $request->files->all($fileArg->name); + $files = $request->files->get($fileArg->name); $value = null; - if (1 === \count($files) && $fileArg->isValueCompatible($files[0])) { + if ( + is_array($files) + && 1 === \count($files) + && $fileArg->isValueCompatible($files[0]) + ) { $value = $files[0]; } elseif ($fileArg->isValueCompatible($files)) { $value = $files; From b5ed9997ce96d1de4d05d738bebfb97be7bc7709 Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 10 Jun 2022 13:37:25 +0200 Subject: [PATCH 28/29] Code Style --- src/LiveComponent/src/EventListener/LiveComponentSubscriber.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index ebe37185c3f..bd82f5ce24f 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -161,7 +161,7 @@ public function onKernelController(ControllerEvent $event): void $value = null; if ( - is_array($files) + \is_array($files) && 1 === \count($files) && $fileArg->isValueCompatible($files[0]) ) { From 07fd7890234204f667472e8fecef6116af355fef Mon Sep 17 00:00:00 2001 From: Jakub Caban Date: Fri, 24 Jun 2022 13:52:19 +0200 Subject: [PATCH 29/29] Handle uploaded files in ComponentWithFormTrait by adding them to data in PostHydrate --- .../src/ComponentWithFormTrait.php | 22 ++++++++++++++++++- .../EventListener/LiveComponentSubscriber.php | 2 ++ .../src/LiveComponentHydrator.php | 3 ++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/LiveComponent/src/ComponentWithFormTrait.php b/src/LiveComponent/src/ComponentWithFormTrait.php index f24d438ebef..fd9e5cbdf5f 100644 --- a/src/LiveComponent/src/ComponentWithFormTrait.php +++ b/src/LiveComponent/src/ComponentWithFormTrait.php @@ -16,6 +16,7 @@ use Symfony\Component\Form\FormView; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Attribute\PostHydrate; use Symfony\UX\LiveComponent\Attribute\PreReRender; use Symfony\UX\LiveComponent\Util\LiveFormUtility; use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; @@ -44,6 +45,11 @@ trait ComponentWithFormTrait #[LiveProp(writable: true, fieldName: 'getFormName()')] public ?array $formValues = null; + /** + * Holds the raw submitted files. + */ + protected array $uploadedFiles = []; + /** * Tracks whether this entire component has been validated. * @@ -94,6 +100,14 @@ public function initializeForm(array $data): array return $data; } + #[PostHydrate] + public function extractFiles(array $data): void + { + if (isset($data[LiveComponentHydrator::FILES_KEY][$this->formName])) { + $this->uploadedFiles = $data[LiveComponentHydrator::FILES_KEY][$this->formName]; + } + } + /** * Make sure the form has been submitted. * @@ -138,8 +152,14 @@ private function submitForm(bool $validateAll = true): void throw new \LogicException('The submitForm() method is being called, but the FormView has already been built. Are you calling $this->getForm() - which creates the FormView - before submitting the form?'); } + if (\is_array($this->formValues)) { + $data = array_replace_recursive($this->formValues, $this->uploadedFiles); + } else { + $data = $this->uploadedFiles; + } + $form = $this->getFormInstance(); - $form->submit($this->formValues); + $form->submit($data); if ($validateAll) { // mark the entire component as validated diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index bd82f5ce24f..292e56dcb82 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -143,6 +143,8 @@ public function onKernelController(ControllerEvent $event): void throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveAction attribute above it.', $action, \get_class($component))); } + $data[LiveComponentHydrator::FILES_KEY] = $request->files->all(); + $mounted = $this->container->get(LiveComponentHydrator::class)->hydrate( $component, $data, diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php index e521ccd7f3a..2c0a25599d6 100644 --- a/src/LiveComponent/src/LiveComponentHydrator.php +++ b/src/LiveComponent/src/LiveComponentHydrator.php @@ -34,6 +34,7 @@ final class LiveComponentHydrator private const CHECKSUM_KEY = '_checksum'; private const EXPOSED_PROP_KEY = '_id'; private const ATTRIBUTES_KEY = '_attributes'; + public const FILES_KEY = '_files'; public function __construct( private NormalizerInterface|DenormalizerInterface $normalizer, @@ -200,7 +201,7 @@ public function hydrate(object $component, array $data, string $componentName): } foreach (AsLiveComponent::postHydrateMethods($component) as $method) { - $component->{$method->name}(); + $component->{$method->name}($data); } return new MountedComponent($componentName, $component, $attributes);