-
-
Notifications
You must be signed in to change notification settings - Fork 364
[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
Changes from all commits
6d78662
84c59e0
8b0762f
0439394
d07e589
19bc270
e31f591
8e53081
365efa2
7d81214
dbb0cd1
4426e73
b776f84
820e899
e6d2331
2e22f1e
2f0e127
3dfe5ae
6b7a90b
3948f00
eaa70b4
77a6797
d829cdd
50baf14
c0a5736
ddcc41c
d05836a
b5ed999
07fd789
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'; | ||
|
@@ -54,6 +54,8 @@ export default class extends Controller { | |
*/ | ||
renderPromiseStack = new PromiseStack(); | ||
|
||
fileInputs: Record<string, HTMLInputElement> = {} | ||
|
||
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<string, FileList> = {}; | ||
|
||
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]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} | ||
} else { | ||
for (const [key, input] of Object.entries(this.fileInputs)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this can just be an |
||
// 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<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; | ||
|
@@ -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)); | ||
Lustmored marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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(); | ||
|
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; | ||
} | ||
} |
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:So I've decided to treat non-existent input the same way as empty ones.