Skip to content

Enabling files upload #834

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

Merged
merged 20 commits into from
Jul 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions src/LiveComponent/assets/dist/Backend/Backend.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface BackendInterface {
[key: string]: any;
}, children: ChildrenFingerprints, updatedPropsFromParent: {
[key: string]: any;
}, files: {
[key: string]: FileList;
}): BackendRequest;
}
export interface BackendAction {
Expand All @@ -23,5 +25,7 @@ export default class implements BackendInterface {
[key: string]: any;
}, children: ChildrenFingerprints, updatedPropsFromParent: {
[key: string]: any;
}, files: {
[key: string]: FileList;
}): BackendRequest;
}
2 changes: 2 additions & 0 deletions src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export default class {
[key: string]: any;
}, children: ChildrenFingerprints, updatedPropsFromParent: {
[key: string]: any;
}, files: {
[key: string]: FileList;
}): {
url: string;
fetchOptions: RequestInit;
Expand Down
2 changes: 2 additions & 0 deletions src/LiveComponent/assets/dist/Component/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default class Component {
defaultDebounce: number;
private backendRequest;
private pendingActions;
private pendingFiles;
private isRequestPending;
private requestDebounceTimeout;
private nextRequestPromise;
Expand All @@ -40,6 +41,7 @@ export default class Component {
set(model: string, value: any, reRender?: boolean, debounce?: number | boolean): Promise<BackendResponse>;
getData(model: string): any;
action(name: string, args?: any, debounce?: number | boolean): Promise<BackendResponse>;
files(key: string, input: HTMLInputElement): void;
render(): Promise<BackendResponse>;
getUnsyncedModels(): string[];
addChild(child: Component, modelBindings?: ModelBinding[]): void;
Expand Down
1 change: 1 addition & 0 deletions src/LiveComponent/assets/dist/live_controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
component: Component;
pendingActionTriggerModelElement: HTMLElement | null;
private elementEventListeners;
private pendingFiles;
static componentRegistry: ComponentRegistry;
initialize(): void;
connect(): void;
Expand Down
67 changes: 58 additions & 9 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -1724,6 +1724,7 @@ class Component {
this.defaultDebounce = 150;
this.backendRequest = null;
this.pendingActions = [];
this.pendingFiles = {};
this.isRequestPending = false;
this.requestDebounceTimeout = null;
this.children = new Map();
Expand Down Expand Up @@ -1801,6 +1802,9 @@ class Component {
this.debouncedStartRequest(debounce);
return promise;
}
files(key, input) {
this.pendingFiles[key] = input;
}
render() {
const promise = this.nextRequestPromise;
this.tryStartingRequest();
Expand Down Expand Up @@ -1900,7 +1904,13 @@ class Component {
const thisPromiseResolve = this.nextRequestPromiseResolve;
this.resetPromise();
this.unsyncedInputsTracker.resetUnsyncedFields();
this.backendRequest = this.backend.makeRequest(this.valueStore.getOriginalProps(), this.pendingActions, this.valueStore.getDirtyProps(), this.getChildrenFingerprints(), this.valueStore.getUpdatedPropsFromParent());
const filesToSend = {};
for (const [key, value] of Object.entries(this.pendingFiles)) {
if (value.files) {
filesToSend[key] = value.files;
}
}
this.backendRequest = this.backend.makeRequest(this.valueStore.getOriginalProps(), this.pendingActions, this.valueStore.getDirtyProps(), this.getChildrenFingerprints(), this.valueStore.getUpdatedPropsFromParent(), filesToSend);
this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest);
this.pendingActions = [];
this.valueStore.flushDirtyPropsToPending();
Expand All @@ -1909,6 +1919,9 @@ class Component {
this.backendRequest = null;
const backendResponse = new BackendResponse(response);
const html = await backendResponse.getBody();
for (const input of Object.values(this.pendingFiles)) {
input.value = '';
}
const headers = backendResponse.response.headers;
if (headers.get('Content-Type') !== 'application/vnd.live-component+html' && !headers.get('X-Live-Redirect')) {
const controls = { displayError: true };
Expand Down Expand Up @@ -2130,7 +2143,7 @@ class RequestBuilder {
this.url = url;
this.csrfToken = csrfToken;
}
buildRequest(props, actions, updated, children, updatedPropsFromParent) {
buildRequest(props, actions, updated, children, updatedPropsFromParent, files) {
const splitUrl = this.url.split('?');
let [url] = splitUrl;
const [, queryString] = splitUrl;
Expand All @@ -2139,8 +2152,10 @@ class RequestBuilder {
fetchOptions.headers = {
Accept: 'application/vnd.live-component+html',
};
const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0);
const hasFingerprints = Object.keys(children).length > 0;
if (actions.length === 0 &&
totalFiles === 0 &&
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))) {
params.set('props', JSON.stringify(props));
params.set('updated', JSON.stringify(updated));
Expand All @@ -2154,18 +2169,18 @@ class RequestBuilder {
}
else {
fetchOptions.method = 'POST';
fetchOptions.headers['Content-Type'] = 'application/json';
const requestData = { props, updated };
if (Object.keys(updatedPropsFromParent).length > 0) {
requestData.propsFromParent = updatedPropsFromParent;
}
if (hasFingerprints) {
requestData.children = children;
}
if (this.csrfToken &&
(actions.length || totalFiles)) {
fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken;
}
if (actions.length > 0) {
if (this.csrfToken) {
fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken;
}
if (actions.length === 1) {
requestData.args = actions[0].args;
url += `/${encodeURIComponent(actions[0].name)}`;
Expand All @@ -2175,7 +2190,15 @@ class RequestBuilder {
requestData.actions = actions;
}
}
fetchOptions.body = JSON.stringify(requestData);
const formData = new FormData();
formData.append('data', JSON.stringify(requestData));
for (const [key, value] of Object.entries(files)) {
const length = value.length;
for (let i = 0; i < length; ++i) {
formData.append(key, value[i]);
}
}
fetchOptions.body = formData;
}
const paramsString = params.toString();
return {
Expand All @@ -2193,8 +2216,8 @@ class Backend {
constructor(url, csrfToken = null) {
this.requestBuilder = new RequestBuilder(url, csrfToken);
}
makeRequest(props, actions, updated, children, updatedPropsFromParent) {
const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent);
makeRequest(props, actions, updated, children, updatedPropsFromParent, files) {
const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files);
return new BackendRequest(fetch(url, fetchOptions), actions.map((backendAction) => backendAction.name), Object.keys(updated));
}
}
Expand Down Expand Up @@ -2658,6 +2681,7 @@ class LiveControllerDefault extends Controller {
{ event: 'change', callback: (event) => this.handleChangeEvent(event) },
{ event: 'live:connect', callback: (event) => this.handleConnectedControllerEvent(event) },
];
this.pendingFiles = {};
}
initialize() {
this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this);
Expand Down Expand Up @@ -2706,6 +2730,7 @@ class LiveControllerDefault extends Controller {
const directives = parseDirectives(rawAction);
let debounce = false;
directives.forEach((directive) => {
let pendingFiles = {};
const validModifiers = new Map();
validModifiers.set('prevent', () => {
event.preventDefault();
Expand All @@ -2721,6 +2746,14 @@ class LiveControllerDefault extends Controller {
validModifiers.set('debounce', (modifier) => {
debounce = modifier.value ? parseInt(modifier.value) : true;
});
validModifiers.set('files', (modifier) => {
if (!modifier.value) {
pendingFiles = this.pendingFiles;
}
else if (this.pendingFiles[modifier.value]) {
pendingFiles[modifier.value] = this.pendingFiles[modifier.value];
}
});
directive.modifiers.forEach((modifier) => {
var _a;
if (validModifiers.has(modifier.name)) {
Expand All @@ -2730,6 +2763,12 @@ class LiveControllerDefault extends Controller {
}
console.warn(`Unknown modifier ${modifier.name} in action "${rawAction}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`);
});
for (const [key, input] of Object.entries(pendingFiles)) {
if (input.files) {
this.component.files(key, input);
}
delete this.pendingFiles[key];
}
this.component.action(directive.action, directive.named, debounce);
if (getModelDirectiveFromElement(event.currentTarget, false)) {
this.pendingActionTriggerModelElement = event.currentTarget;
Expand Down Expand Up @@ -2799,12 +2838,22 @@ class LiveControllerDefault extends Controller {
this.updateModelFromElementEvent(target, 'change');
}
updateModelFromElementEvent(element, eventName) {
var _a;
if (!elementBelongsToThisComponent(element, this.component)) {
return;
}
if (!(element instanceof HTMLElement)) {
throw new Error('Could not update model for non HTMLElement');
}
if (element instanceof HTMLInputElement && element.type === 'file') {
const key = element.name;
if ((_a = element.files) === null || _a === void 0 ? void 0 : _a.length) {
this.pendingFiles[key] = element;
}
else if (this.pendingFiles[key]) {
delete this.pendingFiles[key];
}
}
const modelDirective = getModelDirectiveFromElement(element, false);
if (!modelDirective) {
return;
Expand Down
5 changes: 4 additions & 1 deletion src/LiveComponent/assets/src/Backend/Backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface BackendInterface {
updated: {[key: string]: any},
children: ChildrenFingerprints,
updatedPropsFromParent: {[key: string]: any},
files: {[key: string]: FileList},
): BackendRequest;
}

Expand All @@ -34,13 +35,15 @@ export default class implements BackendInterface {
updated: {[key: string]: any},
children: ChildrenFingerprints,
updatedPropsFromParent: {[key: string]: any},
files: {[key: string]: FileList},
): BackendRequest {
const { url, fetchOptions } = this.requestBuilder.buildRequest(
props,
actions,
updated,
children,
updatedPropsFromParent
updatedPropsFromParent,
files
);

return new BackendRequest(
Expand Down
30 changes: 25 additions & 5 deletions src/LiveComponent/assets/src/Backend/RequestBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default class {
updated: {[key: string]: any},
children: ChildrenFingerprints,
updatedPropsFromParent: {[key: string]: any},
files: {[key: string]: FileList},
): { url: string; fetchOptions: RequestInit } {
const splitUrl = this.url.split('?');
let [url] = splitUrl;
Expand All @@ -26,9 +27,15 @@ export default class {
Accept: 'application/vnd.live-component+html',
};

const totalFiles = Object.entries(files).reduce(
Copy link
Member

Choose a reason for hiding this comment

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

Could just be?

const totalFiles = Object.keys(files).length

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 think it's not - that would count FileList objects passed, but they can be empty (like inputs with no files selected). In that case we should not count them.

Actually maybe what would be more fitting is to first filter files to remove empty FileLists and then just use what you propose.

(total, current) => total + current.length,
0
);

const hasFingerprints = Object.keys(children).length > 0;
if (
actions.length === 0 &&
totalFiles === 0 &&
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))
) {
params.set('props', JSON.stringify(props));
Expand All @@ -42,7 +49,6 @@ export default class {
fetchOptions.method = 'GET';
} else {
fetchOptions.method = 'POST';
fetchOptions.headers['Content-Type'] = 'application/json';
const requestData: any = { props, updated };
if (Object.keys(updatedPropsFromParent).length > 0) {
requestData.propsFromParent = updatedPropsFromParent;
Expand All @@ -51,11 +57,15 @@ export default class {
requestData.children = children;
}

if (
this.csrfToken &&
(actions.length || totalFiles)
) {
fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken;
}

if (actions.length > 0) {
// one or more ACTIONs
if (this.csrfToken) {
fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken;
}

if (actions.length === 1) {
// simple, single action
Expand All @@ -68,7 +78,17 @@ export default class {
}
}

fetchOptions.body = JSON.stringify(requestData);
const formData = new FormData();
formData.append('data', JSON.stringify(requestData));

for(const [key, value] of Object.entries(files)) {
const length = value.length;
for (let i = 0; i < length ; ++i) {
formData.append(key, value[i]);
}
}

fetchOptions.body = formData;
}

const paramsString = params.toString();
Expand Down
19 changes: 19 additions & 0 deletions src/LiveComponent/assets/src/Component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export default class Component {
private backendRequest: BackendRequest|null = null;
/** Actions that are waiting to be executed */
private pendingActions: BackendAction[] = [];
/** Files that are waiting to be sent */
private pendingFiles: {[key: string]: HTMLInputElement} = {};
/** Is a request waiting to be made? */
private isRequestPending = false;
/** Current "timeout" before the pending request should be sent. */
Expand Down Expand Up @@ -194,6 +196,10 @@ export default class Component {
return promise;
}

files(key: string, input: HTMLInputElement): void {
this.pendingFiles[key] = input;
}

render(): Promise<BackendResponse> {
const promise = this.nextRequestPromise;
this.tryStartingRequest();
Expand Down Expand Up @@ -352,12 +358,20 @@ export default class Component {
// they are now "in sync" (with some exceptions noted inside)
this.unsyncedInputsTracker.resetUnsyncedFields();

const filesToSend: {[key: string]: FileList} = {};
for(const [key, value] of Object.entries(this.pendingFiles)) {
if (value.files) {
filesToSend[key] = value.files;
}
}

this.backendRequest = this.backend.makeRequest(
this.valueStore.getOriginalProps(),
this.pendingActions,
this.valueStore.getDirtyProps(),
this.getChildrenFingerprints(),
this.valueStore.getUpdatedPropsFromParent(),
filesToSend,
);
this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest);

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

// clear sent files inputs
for(const input of Object.values(this.pendingFiles)) {
input.value = '';
}

// if the response does not contain a component, render as an error
const headers = backendResponse.response.headers;
if (headers.get('Content-Type') !== 'application/vnd.live-component+html' && !headers.get('X-Live-Redirect')) {
Expand Down
Loading