Skip to content

Commit 2dd64b9

Browse files
committed
feature #834 Enabling files upload (Lustmored, weaverryan)
This PR was merged into the 2.x branch. Discussion ---------- Enabling files upload | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | | License | MIT Getting back on the file uploading front I've decided to go slowly this time, as in the end it should allow us to get basic support faster and then build up on it, without determining all the little details of both backend and frontend implementations upfront. This PR aims at *enabling* frontend tools to send files back to the server. It does not add file upload functions to the live controller or handle the files on the backend. Instead it focuses only on changes that are essential to get any kind of file upload in the future. What exactly is done here: - `RequestBuilder` gets an additional parameter (`files`) that takes a map of `FileList` objects that should be uploaded with the request (and the keys by which they should be available). This additional parameter is added as an empty map everywhere right now. - To make file upload possible the content type of a request is moved from JSON to the default (form type) and all the component related data is sent under `data` key. - `RequestBuilder` tests are adapted to the changes and tests for appending files to the request are added. - On the backend site `LiveComponentSubscriber` is adapted to read data from JSON-encoded `data` parameter instead of request content (in case of a POST request), without any further functional changes. - All tests are adapted to send data in the new format. Two tests are failing for me locally (and in 2 seemingly random CI configurations), while logic tells me they shouldn't and I'm unable to find the cause right now. Help would be appreciated. I would love to get that first stepping stone finalized and merged before further work. It is a simple change, but has strong implications, therefore getting those out of the way will make further work much more comfortable. Also - splitting the work will hopefully make it easier for you to review. Commits ------- d29df00 Fixing test 380b492 phpcs cc41980 [Upload] Updating demo 1a147a2 Adapt tests again 65e2794 Rewire files handling to actually send files only once and clear input afterwards 1f74e6d Clear pendingFiles after being sent 0e1013e Pump up docs 2155865 Stop using data-model on file inputs as an alternative. It's pointless c74f689 Rebuild dist assets c7c56f0 Use `expectException` to mitigate test failures. cf8ef71 Code style 610b216 Add very simple upload files docs 7ea3fbb Add extremely simple upload files example ad70f81 Hook up files in Component and build assets 458ba60 Add possibility to send files with an action 7fd6e5a Rebuild assets fe69154 Read POST payload from `data` key instead of a request body b5ff362 Add tests for passing files to RequestBuilder and make them pass 14a640b Make files mandatory in Backend.ts and pass empty map from controller 2d13c27 Allow files to be passed to `RequestBuilder` and move request body under 'data' key
2 parents 046e580 + d29df00 commit 2dd64b9

23 files changed

+536
-91
lines changed

src/LiveComponent/assets/dist/Backend/Backend.d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export interface BackendInterface {
1010
[key: string]: any;
1111
}, children: ChildrenFingerprints, updatedPropsFromParent: {
1212
[key: string]: any;
13+
}, files: {
14+
[key: string]: FileList;
1315
}): BackendRequest;
1416
}
1517
export interface BackendAction {
@@ -23,5 +25,7 @@ export default class implements BackendInterface {
2325
[key: string]: any;
2426
}, children: ChildrenFingerprints, updatedPropsFromParent: {
2527
[key: string]: any;
28+
}, files: {
29+
[key: string]: FileList;
2630
}): BackendRequest;
2731
}

src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export default class {
77
[key: string]: any;
88
}, children: ChildrenFingerprints, updatedPropsFromParent: {
99
[key: string]: any;
10+
}, files: {
11+
[key: string]: FileList;
1012
}): {
1113
url: string;
1214
fetchOptions: RequestInit;

src/LiveComponent/assets/dist/Component/index.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default class Component {
2020
defaultDebounce: number;
2121
private backendRequest;
2222
private pendingActions;
23+
private pendingFiles;
2324
private isRequestPending;
2425
private requestDebounceTimeout;
2526
private nextRequestPromise;
@@ -40,6 +41,7 @@ export default class Component {
4041
set(model: string, value: any, reRender?: boolean, debounce?: number | boolean): Promise<BackendResponse>;
4142
getData(model: string): any;
4243
action(name: string, args?: any, debounce?: number | boolean): Promise<BackendResponse>;
44+
files(key: string, input: HTMLInputElement): void;
4345
render(): Promise<BackendResponse>;
4446
getUnsyncedModels(): string[];
4547
addChild(child: Component, modelBindings?: ModelBinding[]): void;

src/LiveComponent/assets/dist/live_controller.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
4848
component: Component;
4949
pendingActionTriggerModelElement: HTMLElement | null;
5050
private elementEventListeners;
51+
private pendingFiles;
5152
static componentRegistry: ComponentRegistry;
5253
initialize(): void;
5354
connect(): void;

src/LiveComponent/assets/dist/live_controller.js

+58-9
Original file line numberDiff line numberDiff line change
@@ -1724,6 +1724,7 @@ class Component {
17241724
this.defaultDebounce = 150;
17251725
this.backendRequest = null;
17261726
this.pendingActions = [];
1727+
this.pendingFiles = {};
17271728
this.isRequestPending = false;
17281729
this.requestDebounceTimeout = null;
17291730
this.children = new Map();
@@ -1801,6 +1802,9 @@ class Component {
18011802
this.debouncedStartRequest(debounce);
18021803
return promise;
18031804
}
1805+
files(key, input) {
1806+
this.pendingFiles[key] = input;
1807+
}
18041808
render() {
18051809
const promise = this.nextRequestPromise;
18061810
this.tryStartingRequest();
@@ -1900,7 +1904,13 @@ class Component {
19001904
const thisPromiseResolve = this.nextRequestPromiseResolve;
19011905
this.resetPromise();
19021906
this.unsyncedInputsTracker.resetUnsyncedFields();
1903-
this.backendRequest = this.backend.makeRequest(this.valueStore.getOriginalProps(), this.pendingActions, this.valueStore.getDirtyProps(), this.getChildrenFingerprints(), this.valueStore.getUpdatedPropsFromParent());
1907+
const filesToSend = {};
1908+
for (const [key, value] of Object.entries(this.pendingFiles)) {
1909+
if (value.files) {
1910+
filesToSend[key] = value.files;
1911+
}
1912+
}
1913+
this.backendRequest = this.backend.makeRequest(this.valueStore.getOriginalProps(), this.pendingActions, this.valueStore.getDirtyProps(), this.getChildrenFingerprints(), this.valueStore.getUpdatedPropsFromParent(), filesToSend);
19041914
this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest);
19051915
this.pendingActions = [];
19061916
this.valueStore.flushDirtyPropsToPending();
@@ -1909,6 +1919,9 @@ class Component {
19091919
this.backendRequest = null;
19101920
const backendResponse = new BackendResponse(response);
19111921
const html = await backendResponse.getBody();
1922+
for (const input of Object.values(this.pendingFiles)) {
1923+
input.value = '';
1924+
}
19121925
const headers = backendResponse.response.headers;
19131926
if (headers.get('Content-Type') !== 'application/vnd.live-component+html' && !headers.get('X-Live-Redirect')) {
19141927
const controls = { displayError: true };
@@ -2130,7 +2143,7 @@ class RequestBuilder {
21302143
this.url = url;
21312144
this.csrfToken = csrfToken;
21322145
}
2133-
buildRequest(props, actions, updated, children, updatedPropsFromParent) {
2146+
buildRequest(props, actions, updated, children, updatedPropsFromParent, files) {
21342147
const splitUrl = this.url.split('?');
21352148
let [url] = splitUrl;
21362149
const [, queryString] = splitUrl;
@@ -2139,8 +2152,10 @@ class RequestBuilder {
21392152
fetchOptions.headers = {
21402153
Accept: 'application/vnd.live-component+html',
21412154
};
2155+
const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0);
21422156
const hasFingerprints = Object.keys(children).length > 0;
21432157
if (actions.length === 0 &&
2158+
totalFiles === 0 &&
21442159
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))) {
21452160
params.set('props', JSON.stringify(props));
21462161
params.set('updated', JSON.stringify(updated));
@@ -2154,18 +2169,18 @@ class RequestBuilder {
21542169
}
21552170
else {
21562171
fetchOptions.method = 'POST';
2157-
fetchOptions.headers['Content-Type'] = 'application/json';
21582172
const requestData = { props, updated };
21592173
if (Object.keys(updatedPropsFromParent).length > 0) {
21602174
requestData.propsFromParent = updatedPropsFromParent;
21612175
}
21622176
if (hasFingerprints) {
21632177
requestData.children = children;
21642178
}
2179+
if (this.csrfToken &&
2180+
(actions.length || totalFiles)) {
2181+
fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken;
2182+
}
21652183
if (actions.length > 0) {
2166-
if (this.csrfToken) {
2167-
fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken;
2168-
}
21692184
if (actions.length === 1) {
21702185
requestData.args = actions[0].args;
21712186
url += `/${encodeURIComponent(actions[0].name)}`;
@@ -2175,7 +2190,15 @@ class RequestBuilder {
21752190
requestData.actions = actions;
21762191
}
21772192
}
2178-
fetchOptions.body = JSON.stringify(requestData);
2193+
const formData = new FormData();
2194+
formData.append('data', JSON.stringify(requestData));
2195+
for (const [key, value] of Object.entries(files)) {
2196+
const length = value.length;
2197+
for (let i = 0; i < length; ++i) {
2198+
formData.append(key, value[i]);
2199+
}
2200+
}
2201+
fetchOptions.body = formData;
21792202
}
21802203
const paramsString = params.toString();
21812204
return {
@@ -2193,8 +2216,8 @@ class Backend {
21932216
constructor(url, csrfToken = null) {
21942217
this.requestBuilder = new RequestBuilder(url, csrfToken);
21952218
}
2196-
makeRequest(props, actions, updated, children, updatedPropsFromParent) {
2197-
const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent);
2219+
makeRequest(props, actions, updated, children, updatedPropsFromParent, files) {
2220+
const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files);
21982221
return new BackendRequest(fetch(url, fetchOptions), actions.map((backendAction) => backendAction.name), Object.keys(updated));
21992222
}
22002223
}
@@ -2658,6 +2681,7 @@ class LiveControllerDefault extends Controller {
26582681
{ event: 'change', callback: (event) => this.handleChangeEvent(event) },
26592682
{ event: 'live:connect', callback: (event) => this.handleConnectedControllerEvent(event) },
26602683
];
2684+
this.pendingFiles = {};
26612685
}
26622686
initialize() {
26632687
this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this);
@@ -2706,6 +2730,7 @@ class LiveControllerDefault extends Controller {
27062730
const directives = parseDirectives(rawAction);
27072731
let debounce = false;
27082732
directives.forEach((directive) => {
2733+
let pendingFiles = {};
27092734
const validModifiers = new Map();
27102735
validModifiers.set('prevent', () => {
27112736
event.preventDefault();
@@ -2721,6 +2746,14 @@ class LiveControllerDefault extends Controller {
27212746
validModifiers.set('debounce', (modifier) => {
27222747
debounce = modifier.value ? parseInt(modifier.value) : true;
27232748
});
2749+
validModifiers.set('files', (modifier) => {
2750+
if (!modifier.value) {
2751+
pendingFiles = this.pendingFiles;
2752+
}
2753+
else if (this.pendingFiles[modifier.value]) {
2754+
pendingFiles[modifier.value] = this.pendingFiles[modifier.value];
2755+
}
2756+
});
27242757
directive.modifiers.forEach((modifier) => {
27252758
var _a;
27262759
if (validModifiers.has(modifier.name)) {
@@ -2730,6 +2763,12 @@ class LiveControllerDefault extends Controller {
27302763
}
27312764
console.warn(`Unknown modifier ${modifier.name} in action "${rawAction}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`);
27322765
});
2766+
for (const [key, input] of Object.entries(pendingFiles)) {
2767+
if (input.files) {
2768+
this.component.files(key, input);
2769+
}
2770+
delete this.pendingFiles[key];
2771+
}
27332772
this.component.action(directive.action, directive.named, debounce);
27342773
if (getModelDirectiveFromElement(event.currentTarget, false)) {
27352774
this.pendingActionTriggerModelElement = event.currentTarget;
@@ -2799,12 +2838,22 @@ class LiveControllerDefault extends Controller {
27992838
this.updateModelFromElementEvent(target, 'change');
28002839
}
28012840
updateModelFromElementEvent(element, eventName) {
2841+
var _a;
28022842
if (!elementBelongsToThisComponent(element, this.component)) {
28032843
return;
28042844
}
28052845
if (!(element instanceof HTMLElement)) {
28062846
throw new Error('Could not update model for non HTMLElement');
28072847
}
2848+
if (element instanceof HTMLInputElement && element.type === 'file') {
2849+
const key = element.name;
2850+
if ((_a = element.files) === null || _a === void 0 ? void 0 : _a.length) {
2851+
this.pendingFiles[key] = element;
2852+
}
2853+
else if (this.pendingFiles[key]) {
2854+
delete this.pendingFiles[key];
2855+
}
2856+
}
28082857
const modelDirective = getModelDirectiveFromElement(element, false);
28092858
if (!modelDirective) {
28102859
return;

src/LiveComponent/assets/src/Backend/Backend.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface BackendInterface {
1313
updated: {[key: string]: any},
1414
children: ChildrenFingerprints,
1515
updatedPropsFromParent: {[key: string]: any},
16+
files: {[key: string]: FileList},
1617
): BackendRequest;
1718
}
1819

@@ -34,13 +35,15 @@ export default class implements BackendInterface {
3435
updated: {[key: string]: any},
3536
children: ChildrenFingerprints,
3637
updatedPropsFromParent: {[key: string]: any},
38+
files: {[key: string]: FileList},
3739
): BackendRequest {
3840
const { url, fetchOptions } = this.requestBuilder.buildRequest(
3941
props,
4042
actions,
4143
updated,
4244
children,
43-
updatedPropsFromParent
45+
updatedPropsFromParent,
46+
files
4447
);
4548

4649
return new BackendRequest(

src/LiveComponent/assets/src/Backend/RequestBuilder.ts

+25-5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default class {
1515
updated: {[key: string]: any},
1616
children: ChildrenFingerprints,
1717
updatedPropsFromParent: {[key: string]: any},
18+
files: {[key: string]: FileList},
1819
): { url: string; fetchOptions: RequestInit } {
1920
const splitUrl = this.url.split('?');
2021
let [url] = splitUrl;
@@ -26,9 +27,15 @@ export default class {
2627
Accept: 'application/vnd.live-component+html',
2728
};
2829

30+
const totalFiles = Object.entries(files).reduce(
31+
(total, current) => total + current.length,
32+
0
33+
);
34+
2935
const hasFingerprints = Object.keys(children).length > 0;
3036
if (
3137
actions.length === 0 &&
38+
totalFiles === 0 &&
3239
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))
3340
) {
3441
params.set('props', JSON.stringify(props));
@@ -42,7 +49,6 @@ export default class {
4249
fetchOptions.method = 'GET';
4350
} else {
4451
fetchOptions.method = 'POST';
45-
fetchOptions.headers['Content-Type'] = 'application/json';
4652
const requestData: any = { props, updated };
4753
if (Object.keys(updatedPropsFromParent).length > 0) {
4854
requestData.propsFromParent = updatedPropsFromParent;
@@ -51,11 +57,15 @@ export default class {
5157
requestData.children = children;
5258
}
5359

60+
if (
61+
this.csrfToken &&
62+
(actions.length || totalFiles)
63+
) {
64+
fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken;
65+
}
66+
5467
if (actions.length > 0) {
5568
// one or more ACTIONs
56-
if (this.csrfToken) {
57-
fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken;
58-
}
5969

6070
if (actions.length === 1) {
6171
// simple, single action
@@ -68,7 +78,17 @@ export default class {
6878
}
6979
}
7080

71-
fetchOptions.body = JSON.stringify(requestData);
81+
const formData = new FormData();
82+
formData.append('data', JSON.stringify(requestData));
83+
84+
for(const [key, value] of Object.entries(files)) {
85+
const length = value.length;
86+
for (let i = 0; i < length ; ++i) {
87+
formData.append(key, value[i]);
88+
}
89+
}
90+
91+
fetchOptions.body = formData;
7292
}
7393

7494
const paramsString = params.toString();

src/LiveComponent/assets/src/Component/index.ts

+19
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export default class Component {
5555
private backendRequest: BackendRequest|null = null;
5656
/** Actions that are waiting to be executed */
5757
private pendingActions: BackendAction[] = [];
58+
/** Files that are waiting to be sent */
59+
private pendingFiles: {[key: string]: HTMLInputElement} = {};
5860
/** Is a request waiting to be made? */
5961
private isRequestPending = false;
6062
/** Current "timeout" before the pending request should be sent. */
@@ -194,6 +196,10 @@ export default class Component {
194196
return promise;
195197
}
196198

199+
files(key: string, input: HTMLInputElement): void {
200+
this.pendingFiles[key] = input;
201+
}
202+
197203
render(): Promise<BackendResponse> {
198204
const promise = this.nextRequestPromise;
199205
this.tryStartingRequest();
@@ -352,12 +358,20 @@ export default class Component {
352358
// they are now "in sync" (with some exceptions noted inside)
353359
this.unsyncedInputsTracker.resetUnsyncedFields();
354360

361+
const filesToSend: {[key: string]: FileList} = {};
362+
for(const [key, value] of Object.entries(this.pendingFiles)) {
363+
if (value.files) {
364+
filesToSend[key] = value.files;
365+
}
366+
}
367+
355368
this.backendRequest = this.backend.makeRequest(
356369
this.valueStore.getOriginalProps(),
357370
this.pendingActions,
358371
this.valueStore.getDirtyProps(),
359372
this.getChildrenFingerprints(),
360373
this.valueStore.getUpdatedPropsFromParent(),
374+
filesToSend,
361375
);
362376
this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest);
363377

@@ -370,6 +384,11 @@ export default class Component {
370384
const backendResponse = new BackendResponse(response);
371385
const html = await backendResponse.getBody();
372386

387+
// clear sent files inputs
388+
for(const input of Object.values(this.pendingFiles)) {
389+
input.value = '';
390+
}
391+
373392
// if the response does not contain a component, render as an error
374393
const headers = backendResponse.response.headers;
375394
if (headers.get('Content-Type') !== 'application/vnd.live-component+html' && !headers.get('X-Live-Redirect')) {

0 commit comments

Comments
 (0)