-
-
Notifications
You must be signed in to change notification settings - Fork 357
[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
Conversation
9374bbd
to
b65677e
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apologies for the delay. I was thinking a lot about this PR last night. I think there is some room in the future for a super fancy upload system. But, to get something functional, I think this is the correct approach and you've implemented it very nicely! If someone comes along with a better idea in the future, great! But at this moment, I think this is it. So, let's finish this off... if you still can... 1+ months later 😇
// Don't update file targets | ||
if (this.fileTargets.includes(fromEl)) { | ||
return false; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. I see 2 possible ways that file uploads could be used
-
Traditional Form Submit: the user has an upload field... but they don't want to process it or re-render it in any way with the live component. If they select a file, nothing happens: it just sits there and waits. Eventually, the user submits the form via a traditional form submit.
-
LiveAction Process: the user has na upload field and, on "change", they trigger a
LiveAction
that will then process that file upload. Later, if there is a full form, they would submit the entire form via anotherLiveAction
.
What you have here works great for (1), but not for (2). We may (? need to experiment) not need this code at all. morphdom is already really good at "not updating things that didn't change".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually I have tampered with it a bit and found that chosen files are reset on file field when I don't have this code in place (so (2) is broken for me). I didn't play around file inputs not handled by live actions for now, so I am not sure how (1) behaves right now. Will surely come back to this when other pieces will get into place 👍
src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
Outdated
Show resolved
Hide resolved
|
||
.. versionadded:: 2.2 | ||
|
||
The ability to pass arguments to actions was added in version 2.2. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs to be updated ;)
|
||
<div | ||
data-action="change->live#action" | ||
data-action-name="prevent|files(my_file)|upload" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
data-action-name="prevent|files(my_file)|upload" | |
data-action-name="upload_files(my_file)|handleMyFileUpload" |
So, I changed a few things:
A) No prevent
. Not needed here, and I think it takes away focus.
B) I like upload_files
as the modifier name. It feels like a "verb", which "feels" correct here. I want to "trigger the action of uploading"
C) Made the live action name super explicit that it is a custom thing for this example. At first, I was thinking |upload
was another modifier 🙃
<input type="file" {{ stimulus_target('live', 'file') }} name="my_file" /> | ||
</div> | ||
|
||
This will send files from ``my_file`` file input. When used without argument |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will send files from ``my_file`` file input. When used without argument | |
This will upload the file from the ``my_file`` file input. If you use `upload_files` without any arguments, | |
it would upload all files that have the `file` target. |
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you want to send multiple files from a single input remember to suffix its' name | |
If you want to send multiple files from a single input, remember to suffix its name |
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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
with ``[]`` - both in HTML name attribute and ``files`` modifier argument. | |
with ``[]`` - both in the HTML name attribute and the ``upload_files`` modifier argument - e.g. ``upload_files('photos[]')``. |
|
||
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. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The missing piece (which I'm guessing you were waiting on to get more feedback) is the PHP logic. We're going to need a really nice documentation example of how the LiveAction
looks, including executing validation (and re-rendering with an error), processing the file upload (i.e. moving it somewhere) and setting the final filename (e.g. tulips.jpg
) from that process onto some other LiveAction
property... or setting it into an entity and saving immediately.
Thanks for the review. Better late than never 😉 I definitely will come back to this, hopefully next week and then you can expect answers to the comments and more code to come by 👍 |
After working on comments from @weaverryan I have started to work on backend file upload handling, taking inspiration from live arguments, as both seem reasonably similar. I am leaving all docs and tests for now and will come back to them at the end, when the final shape of this feature will be decided :) I have added I would love to add some validation to uploaded files. For now I've added Or maybe it is better to leave validation out of this scope, so consumer code can validate files themselves and display errors the way they want? |
Also - I have made very simple file upload component example on live-demo for testing purposes: https://github.com/Lustmored/live-demo-1/tree/local |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey @Lustmored!
Ok, I just had some good time to play with this. THANK YOU for adding your demo - that was super helpful - I played with that directly.
First, I like LiveFileArg
. It means that any "action" arguments need to have LiveArg
or LiveFileArg
. How nice!
I would love to add some validation to uploaded files.
Let's FIRST get a working "file" component where we do the validation manually. Ideally, we would try to use ValidatableComponentTrait
in some way... or maybe not. Either way, we would probably validate the UploadedFile
manually inside the action. Then, if there are errors, we set those somewhere: it could be as simple as setting them on a non-writable LiveProp
- e.g. uploadErrors
. Then, in the template, we render those. Start there so we can see how it looks and feels. If we can identify some patterns where we could add something into LiveComponents itself to make this process easier, then we can do it.
My biggest feedback is about the syntax in the templates themselves. I'd like to not have the file targets. I see 3 possible "scenarios" with upload fields:
-
A file upload is inside of a live component... but will be 100% submitted normally. We've talked about this use-case. All we need to do is make sure that we don't re-render the
<input file>
when the component re-renders. In [Live] Do not re-render <input file> if input holds unuploaded files #323, I've done a slightly different approach. So, this scenario is a ✅ already. -
After the user selects a file, nothing immediately happens (no re-render). But then, later (e.g. via a button click), we upload the file to some custom action. For this scenario, I'm imagining this syntax:
<input
type="file"
multiple
data-file="images"
data-action="live#update"
/>
<button
data-action="live#action"
data-action-name="upload_files|save"
></button>
The idea is this: on "change" of the file input, we trigger the same data-action="live#update"
that we trigger with normal "model" fields. However, in that function, we would determine that the input is a FILE input. And so, instead of updating the data
, we look for data-file
(or fallback to name=""
... because that is super handy for Symfony forms) and then set the current <input type="file"
HtmlInputElement
into some fileInputs = []
array property on the controller object. Basically, on change, we store the HtmlInputElement
for later. This removes the need for the targets and makes file inputs work very similar to normal "model" fields, except that instead of storing the VALUE of the input, we're storing the input itself.
Then, very similar to what you already have, when you want to upload the field, you trigger a custom action but use a modifier. I like the upload_files
modifier vs files
that you have. And upload_files
could take zero arguments (send everything) or take an argument - e.g. upload_files(images)
.
- Immediately after the user selects a file, we want to trigger the "upload" live action. For this scenario, I'm thinking this syntax:
<input
type="file"
multiple
data-file="images"
data-action="live#uploadFile"
data-action-name="uploadImage()"
/>
The new uploadFile()
method would basically FIRST trigger the update()
method's logic so that this HtmlInputElement
is stored onto the fileInputs
array property I described above. THEN it would effectively call the action()
method so that the custom uploadImage()
LiveAction is triggered. So this is a shortcut version of scenario (B).
WDYT?
src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
Outdated
Show resolved
Hide resolved
src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
Outdated
Show resolved
Hide resolved
if ($files = $accessor->getValue($allFiles, $path)) { | ||
$request->attributes->set( | ||
$parameter, | ||
$fileArg->multiple ? $files : $files[0] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need $fileArg->multiple
at all? If the <input file
has multiple
, then $files
will be an array of UploadedFile
. If it is NOT multiple
, then it will already be a single UploadedFile
. I don't think we need to do anything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was my thought as well but do we need to be worried about someone fudging the <file>
input to add multiple
then uploading multiple files?
What about checking the typehint? array === multiple
, UploadedFile === single
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. It we do nothing, and someone messes with things, it would explode with a 500 error (since an array would fail to be passed to an arg typed with UploadedFile
). So, I don't think there's an attack vector. However, allowing bad users to trigger a 500 isn't too great either ;).
So, yea, we COULD check the uploaded data first to see if we're dealing with ONE or MULTIPLE files. Then we COULD check the type on the arg to see if it's something compatible (e.g. no type, or UploadedFile
for a single, etc). If it is NOT compatible, we return a 400 error.
That DOES feel a little magic... I can't think of any similar thing that's done in Symfony. Like, is there a way in php to basically ask "Hey: would this value be compatible with this argument type?". WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think there is a simple, PHP way of doing it. And I agree removing multiple
on fileArg
could be beneficial. After all we have typehints. I will refactor this part for sure 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have refactored the logic here so it goes like that:
- in
LiveFileArg::liveFileArgs(...)
I collect types compatible with argument declaration - I have added
LiveFileArg->isValueCompatible(mixed $value): bool
that does its best to determine if passed value is compatible with argument typedef. It works for untyped parameters too. - In listener I check if there is only one file uploaded. If it is and
UploadedFile
is compatible with argument - I autowire that. - If there are multiple files uploaded or
UploadedFile
is not compatible andarray
is compatible - I autowire that. - Otherwise I throw
BadRequestHttpException
to mitigate 500 as at that point I know autowiring will fail (or wire default value, but that is also incorrect behavior).
Hope that solves all the cases 😺
src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
Outdated
Show resolved
Hide resolved
src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
Outdated
Show resolved
Hide resolved
…ed files (weaverryan) This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Live] Do not re-render <input file> if input holds unuploaded files | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | Related to #299 | License | MIT On #299, we're working on proper file upload support for live components. BUT, I think one thing is for sure: if you simply select a file and then (later) the component re-renders for some reason, we do NOT want to "replace" the `<input file` that has the attached file with an empty `<input file`. I've tested locally - testing proved troublesome, due to some differences in how the `.value` attribute seems to work on browser vs the testing environment. Cheers! Commits ------- 28b29e9 dropping 7.2 from CI: it is no longer supported by CI tools 2ded2f2 do not re-render <input file> if input holds unuploaded files
First of all - thanks a lot for very deep and constructive review. I must say working on this project is both demanding and a great opportunity to learn (both by myself and from you guys) and I love it ❤️ Your ideas from the comment sound great and I intend to work on them. I have quite a busy time right now, but will try to contribute within a week or two with the next iteration of this feature 👍 |
@weaverryan I have pushed another step in outlined directions. I have refactored frontend following your suggestions and also refactored autowiring logic to be simpler and more friendly. I have also pushed updated demo working with changed version. One thing I have dropped is support for file model paths like I did not get to the validation yet, so I will come back to this next week. For now if you have time to take a look - comments are more than welcome 😺 |
PS: I believe file inputs are still being rerendered even when files are selected within them. I have removed code that prevented rerendering "file" targets. You can see this in action in my demo - select some files for "manual" files, don't upload them and then select some files in "auto" files. Manual input is cleared on render. |
Fun fact - I had absolutely nothing to do to achieve validation via
I'm still putting docs and tests on hold to make sure I get it done right at once. So I'd love to get some more feedback before getting into it. Is architecture of this feature right? Do you see some spots that could be done better? |
I'm hoping to review this early next week - MAYBE this week - some conference prep has me bogged down. But I'm excited to give this a look! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey @Lustmored!
Thanks for your continued patience. This is a really complex issue, and the exact right "end product" isn't immediately clear... but it's slowly coming into focus. Each time you work, we get a bit further and learn more :).
I've left some minor code reviews. Here are the bigger things I'm "pondering":
-
I noticed (I'm still using your demo, so thanks for keeping that up-to-date) that, after uploading, the file input don't clear. I... think they should. Even if there were a validation error, the user would need to re-select a file and re-upload. Do you agree? If so, we need to find a nice way to do this. It's a bit tricky because. Suppose a component has 2 file uploads and 2 different buttons: one to upload file A and one to upload file B. If the user clicks to upload file A, we need to reset ONLY that file. Normally, we allow the server to return HTML and then use morphdom to "update" the DOM. That may be possible in this case... but right now, the server is returning the same HTML as is on the page (for the file input)... and so the field is not re-rendered and "cleared". Again, that IS the behavior we want sometimes (e.g. we uploaded file A but not file B... or we re-rendered the component for a reason unrelated to file uploading), but not always. Right now, I'm wondering if the only/best way to clear the field is actually from
live_component.ts
: after a file upload request finishes, clear the files for fields that were included in that upload. -
At first, it looked strange to me have the
$autoFiles
and$manualFile
properties: https://github.com/Lustmored/live-demo-1/blob/85dfd0d1a272193c3686377018c9c4c2ab924a57/src/Twig/Components/FileComponent.php#L22-L26. These are NOTLiveProp
and they CAN'T beLiveProp
, as there is no way to dehydrate anUploadedFile
. That is why, of course, we force uploads to go through aLiveAction
, so that the user can process and move the files. Anyways, it occurred to me that these files exist 100% so that they can be momentarily set so that validation works. To extend yourLiveAction
code a bit to a more realistic example, it would look like this:
#[LiveAction]
public function test(#[LiveFileArg] UploadedFile $manualFile = null): void
{
if ($manualFile) {
$this->manualFile = $manualFile;
}
$this->validate();
$this->manualFile->move(__DIR__, 'test.jpg');
}
Do you see what I mean? We $this->manualFile = $manualFile;
SIMPLY so that $this->validate()
works. And so, I thought, for a moment that that the $manualFile
property is kind of pointless and instead the user should maybe manually validate this value without setting it onto a property. But... that's harder... and then you don't get the nice this.getError()
in the template.
Anyways, this is a LONG way of saying that I think the $manualFile
property DOES make sense. In fact, it makes SO much sense that I think EVERY "upload" LiveAction
will start with code that looks like $this->manualFile = $manualFile
. So NOW I'm thinking that instead of LiveFileArg
(where we pass the UploadedFile
as an argument to the LiveAction
), it might make more sense to set the UploadedFile
onto a non-"Live" property... right before we call the action. Something like:
class FileComponent
{
use DefaultActionTrait;
use ValidatableComponentTrait;
#[Valid, Image]
#[LiveProp(isFile: true)]
public ?UploadedFile $manualFile = null;
#[LiveAction]
public function test(): void
{
$this->validate();
$this->manualFile->move(__DIR__, 'test.jpg');
}
}
The key new thing is LiveProp(file: true)
. This would tell us to fetch that value from $request->files
. This could also be a different class - e.g. LiveFile()
. If it is a LiveProp(file: true)
, then the system will try to dehydrate the UploadedFile
... which might be ok: we would just need to add a custom dehydrator that sets it to null. But, if this causes too many problems, then a new class - e.g. LiveFile
- might make more sense (instead of trying to extend the LiveProp
system.
WDYT? Please don't take my thoughts/opinions as "definitely the best". If you disagree or have any input, I would love to hear it. Thanks!
case 'upload_files': | ||
if (modifier.value) { | ||
const input = this.fileInputs[modifier.value]; | ||
if (input && input.files) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 if
s 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.
delete this.fileInputs[modifier.value]; | ||
} | ||
} else { | ||
for (const [key, input] of Object.entries(this.fileInputs)) { |
There was a problem hiding this comment.
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[modifier.value] = input.files; | ||
} else if (input) { | ||
delete this.fileInputs[modifier.value]; |
There was a problem hiding this comment.
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?
this.fileInputs[model] = element; | ||
|
||
return; | ||
} else if (/\[]$/.test(model)) { |
There was a problem hiding this comment.
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.
if ( | ||
\is_array($files) | ||
&& 1 === \count($files) | ||
&& $fileArg->isValueCompatible($files[0]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this allowing you to have a multiple
file upload, but then use an UploadedFile
type hint (instead of an array)? If so, let's remove that: it's too magical, and won't work if the user DOES decide to upload multiple files.
&& $fileArg->isValueCompatible($files[0]) | ||
) { | ||
$value = $files[0]; | ||
} elseif ($fileArg->isValueCompatible($files)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems legit: makes sure that the files (whether single and UploadedFile
or multiple an array
) are compatible with the argument they are trying to "fit into".
@@ -148,6 +151,34 @@ public function onKernelController(ControllerEvent $event): void | |||
|
|||
$request->attributes->set('_mounted_component', $mounted); | |||
|
|||
// autowire live file arguments | |||
if ($request->files->count()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if ($request->files->count()) { | |
if ($request->files->count() > 0) { |
Btw, for the demo, to make it more realistic, I added: class FileComponent
{
// ...
+ #[LiveProp()]
+ public array $uploadedFiles = [];
public function test(...)
{
if ($manualFile) {
$this->manualFile = $manualFile;
+ $this->uploadedFiles[] = $manualFile->getClientOriginalName();
}
if ($autoFiles) {
$this->autoFiles = $autoFiles;
+ foreach ($this->autoFiles as $autoFile) {
+ $this->uploadedFiles[] = $autoFile->getClientOriginalName();
+ }
}
}
} And in the template: <h2>ALL FILES</h2>
<ul>
{% for file in uploadedFiles %}
<li>{{ file }}</li>
{% endfor %}
</ul> In a real app, the |
Sorry, one more thing :p. Perhaps before we make any of the big changes proposed above, we should build out the demo a little bit more to make it more realistic and make sure it's covering use-cases. Here are two big, but similar, use-cases that I'm thinking of: A) A component (without using the form system) that renders an B) The same component as above, but built with the form component. These represent a pretty complex, but also pretty standard use-case where I put a form into a live component so that I can have (A) real-time validation but also (B) a file upload that is processed immediately after attaching the file. If we can make this work, then I think we're in good shape. Sorry for the WALL of text today - I'm available on Slack to chat as always :). Thanks! |
I have just spent a lot of time figuring out what goes where, but in the end I have kinda working live demo that... Well, mostly just works in a "semi-nice" way. I think it's a good point to stop for now and show you what is where to decide what we really need :) Please take a look at my last commit here - it's pretty small, but touches areas I have not explored yet, so might be wrong from separation of concerns point of view. But something along those lines is required to pass files from AJAX upload all the way to Now to the demo:
Obviously file persisting logic I put there is more of a placeholder than real-life scenario, but that part doesn't matter for now. One final note - I am struggling thinking about file input clearing logic. Now that I have played with it a bit I think that maybe we should never clear them for the user. My reasoning is that upon uploading file, validation can fail for any field (not just the file). Therefore during morphing DOM there is no way to determine if the file was successfully persisted, other than removing file input on the template side (as I did in On the other hand it could lead to the same file being upload multiple times which is... Not what we really want. For today I have no idea how to approach this problem from any other angle. I think we'll have to come back to this after setting all the backend logic in place. |
Awesome, thanks for all this work @Lustmored! I will play with your demo later this week. |
Closing in favor of #834 |
This is a proof of concept for very simplistic file upload support within live components. It is by no mean finished (docs and tests are missing for sure), but due to a few design choices I've made I want to get some feedback before finishing it :)
Summary of changes:
FormData
object instead of directly in body. This is required to make multipart form sending easily doable.file
target within Stimulus live controller to be handled in a special way. I did it because I believe it's a good stimulus way and also to allow user to be able to handle some file inputs their own way.files
. When set without any parameter it will send all files from all targets to the server. When passed parameter it will only send target with name matching parameter. So it is possible to specify for example:<input type="file" name="file" data-live-target="file" data-action="change->live#action" data-action-name="stop|files(file)|action" />
that will send just this file(s) as soon as they're chosen and let backend handle it and skip it for any other actions.This allows for simple cases of file uploads, no forms integration at this point and I believe it'd be a good idea to get this in a good shape first and then think about possible form extension. There is a good chance this change with good documentation will even be enough :)