diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 6bc2d23fb15..22135a21846 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 = '{}'; @@ -1070,16 +1071,27 @@ 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 = {}; 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) => { + const modifiers = [...autoModifiers, ...directive.modifiers]; + modifiers.forEach((modifier) => { switch (modifier.name) { case 'prevent': event.preventDefault(); @@ -1105,6 +1117,27 @@ class default_1 extends Controller { handled = true; break; } + case 'upload_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]; + } + } + } + break; default: console.warn(`Unknown modifier ${modifier.name} in action ${rawAction}`); } @@ -1130,7 +1163,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); @@ -1182,7 +1220,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 +1248,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(key, value[i]); + } + } } this._onLoadingStart(); const paramsString = params.toString(); 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 0c46700dfb2..42d3bdc7e68 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -1,6 +1,6 @@ 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'; @@ -54,6 +54,8 @@ export default class extends Controller { */ renderPromiseStack = new PromiseStack(); + fileInputs: Record = {} + pollingIntervals: NodeJS.Timer[] = []; isWindowUnloaded = false; @@ -121,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 @@ -132,6 +144,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,11 +158,12 @@ 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; - directive.modifiers.forEach((modifier) => { + const modifiers: DirectiveModifier[] = [...autoModifiers, ...directive.modifiers]; + modifiers.forEach((modifier) => { switch (modifier.name) { case 'prevent': event.preventDefault(); @@ -179,6 +194,25 @@ export default class extends Controller { break; } + case 'upload_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]; + } + } + } + + break; default: console.warn(`Unknown modifier ${modifier.name} in action ${rawAction}`); @@ -215,7 +249,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]; @@ -318,7 +360,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; @@ -353,9 +395,17 @@ 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; + + for (const [key, value] of Object.entries(files)) { + const length = value.length; + for (let i=0; i < length; ++i) { + formData.append(key, value[i]); + } + } } this._onLoadingStart(); 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'); }); diff --git a/src/LiveComponent/src/Attribute/LiveFileArg.php b/src/LiveComponent/src/Attribute/LiveFileArg.php new file mode 100644 index 00000000000..9100cf746f7 --- /dev/null +++ b/src/LiveComponent/src/Attribute/LiveFileArg.php @@ -0,0 +1,75 @@ + + * + * 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 +{ + private ?array $types = null; + + public function __construct(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 + * + * @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; + 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; + } + } + + return $liveFileArgs; + } +} 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 78d479064bc..292e56dcb82 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; @@ -121,9 +122,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); + } elseif ($request->request->has('data')) { + // OR data key from POST data + $data = json_decode($request->request->get('data'), true, 512, \JSON_THROW_ON_ERROR); } else { - // OR body of the request is JSON - $data = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR); + throw new BadRequestHttpException('Missing live component data.'); } if (!\is_array($controller = $event->getController()) || 2 !== \count($controller)) { @@ -140,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, @@ -148,6 +153,34 @@ public function onKernelController(ControllerEvent $event): void $request->attributes->set('_mounted_component', $mounted); + // autowire live file arguments + if ($request->files->count()) { + foreach (LiveFileArg::liveFileArgs($component, $action) as $parameter => $fileArg) { + if ( + $request->files->has($fileArg->name) + ) { + $files = $request->files->get($fileArg->name); + + $value = null; + if ( + \is_array($files) + && 1 === \count($files) + && $fileArg->isValueCompatible($files[0]) + ) { + $value = $files[0]; + } elseif ($fileArg->isValueCompatible($files)) { + $value = $files; + } else { + throw new BadRequestHttpException("Could not autowire uploaded files for {$fileArg->name} parameter."); + } + $request->attributes->set( + $parameter, + $value + ); + } + } + } + if (!\is_string($queryString = $request->query->get('args'))) { return; } 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); 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 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 diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index aac4337f6ba..50fea8253b6 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') @@ -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') @@ -183,7 +184,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 +194,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 +221,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)