Skip to content

[LiveComponent][RFC] Simple file upload handling #299

New issue

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

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

Already on GitHub? Sign in to your account

Closed
wants to merge 29 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6d78662
Fix code style
Lustmored Mar 11, 2022
84c59e0
Pass data as parameter within body to allow multipart data
Lustmored Mar 11, 2022
8b0762f
Add file upload to _makeRequest
Lustmored Mar 11, 2022
0439394
Introduce file modifier to pass files from live targets to action
Lustmored Mar 11, 2022
d07e589
Rename modifier to `files` and send all files by default
Lustmored Mar 11, 2022
19bc270
Fix failing test
Lustmored Mar 11, 2022
e31f591
Don't update file targets in morhpdom
Lustmored Mar 18, 2022
8e53081
Remove multiple files key handling - should be done by consumer
Lustmored Mar 25, 2022
365efa2
Add file upload info to docs
Lustmored Apr 1, 2022
7d81214
Throw when no file target is found
Lustmored May 13, 2022
dbb0cd1
Throw on missing live component data and fix test
Lustmored May 13, 2022
4426e73
Introduce LiveFileArg attribute and use it to autowire files
Lustmored May 13, 2022
b776f84
Autowire files only when files are sent
Lustmored May 13, 2022
820e899
Handle nested property paths in files
Lustmored May 13, 2022
e6d2331
Code Style
Lustmored May 13, 2022
2e22f1e
Reuse variable
Lustmored May 13, 2022
2f0e127
Remove file targets hack
Lustmored Jun 3, 2022
3dfe5ae
Rename files modifier to upload_files
Lustmored Jun 3, 2022
6b7a90b
Only save files on update and send them when needed
Lustmored Jun 3, 2022
3948f00
Add uploadFile shortcut method
Lustmored Jun 3, 2022
eaa70b4
Don't throw on unreadable property path
Lustmored Jun 3, 2022
77a6797
Empty fallback is unnecessary here
Lustmored Jun 3, 2022
d829cdd
Remove pointless constraints attribute
Lustmored Jun 3, 2022
50baf14
Expect file model to have simple name and remove PropertyAccessor spa…
Lustmored Jun 3, 2022
c0a5736
Make autowiring smart to some extent
Lustmored Jun 3, 2022
ddcc41c
Code style
Lustmored Jun 3, 2022
d05836a
Fix single file upload
Lustmored Jun 10, 2022
b5ed999
Code Style
Lustmored Jun 10, 2022
07fd789
Handle uploaded files in ComponentWithFormTrait by adding them to dat…
Lustmored Jun 24, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 52 additions & 7 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '{}';
Expand Down Expand Up @@ -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();
Expand All @@ -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}`);
}
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/assets/src/directives_parser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* A modifier for a directive
*/
interface DirectiveModifier {
export interface DirectiveModifier {
/**
* The name of the modifier (e.g. delay)
*/
Expand Down
66 changes: 58 additions & 8 deletions src/LiveComponent/assets/src/live_controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -54,6 +54,8 @@ export default class extends Controller {
*/
renderPromiseStack = new PromiseStack();

fileInputs: Record<string, HTMLInputElement> = {}

pollingIntervals: NodeJS.Timer[] = [];

isWindowUnloaded = false;
Expand Down Expand Up @@ -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
Expand All @@ -132,6 +144,8 @@ export default class extends Controller {
// data-action-name="prevent|debounce(1000)|save"
const directives = parseDirectives(rawAction);

const files: Record<string, FileList> = {};

directives.forEach((directive) => {
// set here so it can be delayed with debouncing below
const _executeAction = () => {
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably if there is no input, we should throw some sort of error. Or is there a legit case for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe there could be forms that have the same action before processing the file and after it (like a single action to process all form that is aware of files).

I personally would probably remove the file input from resulting HTML upon successful validation and file handling. Then I'd need to update all the action modifiers for stimulus actions and that could be a lot of ifs in complex components, like:

<div data-action-name="{% if !uploadedAvatar %}upload_files(avatar)|{% endif %}my_action">...</div>

So I've decided to treat non-existent input the same way as empty ones.

files[modifier.value] = input.files;
} else if (input) {
delete this.fileInputs[modifier.value];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, if there are not currently any "attached" files on this input, we delete it from this.fileInputs. Do we need to do that?

}
} else {
for (const [key, input] of Object.entries(this.fileInputs)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it can, but could this be done with an array filter on this.fileInputs? If I'm understanding it correctly, we basically want to send ALL of this.fileInputs... except skip anything where there are not attached files to the field.

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}`);
Expand Down Expand Up @@ -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)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can just be an if (not else if), since we have the return in the first statement.

// Get current value from data
const { currentLevelData, finalKey } = parseDeepData(this.dataValue, normalizeModelName(model))
const currentValue = currentLevelData[finalKey];
Expand Down Expand Up @@ -318,7 +360,7 @@ export default class extends Controller {
}
}

_makeRequest(action: string|null, args: Record<string, string>) {
_makeRequest(action: string|null, args: Record<string, string>, files: Record<string, FileList> = {}) {
const splitUrl = this.urlValue.split('?');
let [url] = splitUrl
const [, queryString] = splitUrl;
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/assets/test/controller/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
75 changes: 75 additions & 0 deletions src/LiveComponent/src/Attribute/LiveFileArg.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <kuba.iluvatar@gmail.com>
*
* @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<string, self>
*/
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;
}
}
22 changes: 21 additions & 1 deletion src/LiveComponent/src/ComponentWithFormTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down
Loading