diff --git a/src/LiveComponent/assets/dist/Backend/Backend.d.ts b/src/LiveComponent/assets/dist/Backend/Backend.d.ts index c4adb86f1f1..d9dbd309095 100644 --- a/src/LiveComponent/assets/dist/Backend/Backend.d.ts +++ b/src/LiveComponent/assets/dist/Backend/Backend.d.ts @@ -10,6 +10,8 @@ export interface BackendInterface { [key: string]: any; }, children: ChildrenFingerprints, updatedPropsFromParent: { [key: string]: any; + }, files: { + [key: string]: FileList; }): BackendRequest; } export interface BackendAction { @@ -23,5 +25,7 @@ export default class implements BackendInterface { [key: string]: any; }, children: ChildrenFingerprints, updatedPropsFromParent: { [key: string]: any; + }, files: { + [key: string]: FileList; }): BackendRequest; } diff --git a/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts b/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts index fa7c125a5ea..a4aacea9599 100644 --- a/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts +++ b/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts @@ -7,6 +7,8 @@ export default class { [key: string]: any; }, children: ChildrenFingerprints, updatedPropsFromParent: { [key: string]: any; + }, files: { + [key: string]: FileList; }): { url: string; fetchOptions: RequestInit; diff --git a/src/LiveComponent/assets/dist/Component/index.d.ts b/src/LiveComponent/assets/dist/Component/index.d.ts index c00eb8f670c..5abe908e60b 100644 --- a/src/LiveComponent/assets/dist/Component/index.d.ts +++ b/src/LiveComponent/assets/dist/Component/index.d.ts @@ -20,6 +20,7 @@ export default class Component { defaultDebounce: number; private backendRequest; private pendingActions; + private pendingFiles; private isRequestPending; private requestDebounceTimeout; private nextRequestPromise; @@ -40,6 +41,7 @@ export default class Component { set(model: string, value: any, reRender?: boolean, debounce?: number | boolean): Promise; getData(model: string): any; action(name: string, args?: any, debounce?: number | boolean): Promise; + files(key: string, input: HTMLInputElement): void; render(): Promise; getUnsyncedModels(): string[]; addChild(child: Component, modelBindings?: ModelBinding[]): void; diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index 02f2a38912c..37ff9c48cfc 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -48,6 +48,7 @@ export default class LiveControllerDefault extends Controller imple component: Component; pendingActionTriggerModelElement: HTMLElement | null; private elementEventListeners; + private pendingFiles; static componentRegistry: ComponentRegistry; initialize(): void; connect(): void; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 06ee0e11c32..f85737c1eb8 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -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(); @@ -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(); @@ -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(); @@ -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 }; @@ -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; @@ -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)); @@ -2154,7 +2169,6 @@ 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; @@ -2162,10 +2176,11 @@ class RequestBuilder { 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)}`; @@ -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 { @@ -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)); } } @@ -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); @@ -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(); @@ -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)) { @@ -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; @@ -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; diff --git a/src/LiveComponent/assets/src/Backend/Backend.ts b/src/LiveComponent/assets/src/Backend/Backend.ts index 71002ffb350..31f2c21b357 100644 --- a/src/LiveComponent/assets/src/Backend/Backend.ts +++ b/src/LiveComponent/assets/src/Backend/Backend.ts @@ -13,6 +13,7 @@ export interface BackendInterface { updated: {[key: string]: any}, children: ChildrenFingerprints, updatedPropsFromParent: {[key: string]: any}, + files: {[key: string]: FileList}, ): BackendRequest; } @@ -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( diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts index 4007ad0e71f..2fe6e3b0254 100644 --- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts +++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts @@ -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; @@ -26,9 +27,15 @@ export default class { 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)); @@ -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; @@ -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 @@ -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(); diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 738025bd070..8c2a040b26b 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -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. */ @@ -194,6 +196,10 @@ export default class Component { return promise; } + files(key: string, input: HTMLInputElement): void { + this.pendingFiles[key] = input; + } + render(): Promise { const promise = this.nextRequestPromise; this.tryStartingRequest(); @@ -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); @@ -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')) { diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 8c7ebcf4f38..227a14b00c7 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -66,6 +66,7 @@ export default class LiveControllerDefault extends Controller imple { event: 'change', callback: (event) => this.handleChangeEvent(event) }, { event: 'live:connect', callback: (event) => this.handleConnectedControllerEvent(event) }, ]; + private pendingFiles: { [key: string]: HTMLInputElement } = {}; static componentRegistry = new ComponentRegistry(); @@ -159,6 +160,7 @@ export default class LiveControllerDefault extends Controller imple let debounce: number | boolean = false; directives.forEach((directive) => { + let pendingFiles: { [key: string]: HTMLInputElement } = {}; const validModifiers: Map void> = new Map(); validModifiers.set('prevent', () => { event.preventDefault(); @@ -174,6 +176,13 @@ export default class LiveControllerDefault extends Controller imple validModifiers.set('debounce', (modifier: DirectiveModifier) => { debounce = modifier.value ? parseInt(modifier.value) : true; }); + validModifiers.set('files', (modifier: DirectiveModifier) => { + if (!modifier.value) { + pendingFiles = this.pendingFiles; + } else if (this.pendingFiles[modifier.value]) { + pendingFiles[modifier.value] = this.pendingFiles[modifier.value]; + } + }); directive.modifiers.forEach((modifier) => { if (validModifiers.has(modifier.name)) { @@ -191,6 +200,12 @@ export default class LiveControllerDefault extends Controller imple ); }); + 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); // possible case where this element is also a "model" element @@ -322,6 +337,17 @@ export default class LiveControllerDefault extends Controller imple throw new Error('Could not update model for non HTMLElement'); } + // file uploads aren't mapped to model, + // but needs to be scheduled for sending + if (element instanceof HTMLInputElement && element.type === 'file') { + const key = element.name; + if (element.files?.length) { + this.pendingFiles[key] = element; + } else if (this.pendingFiles[key]) { + delete this.pendingFiles[key]; + } + } + const modelDirective = getModelDirectiveFromElement(element, false); // if not tied to a model, no more work to be done diff --git a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts index 936e7557129..1963a7fbbad 100644 --- a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts +++ b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts @@ -8,6 +8,7 @@ describe('buildRequest', () => { [], { firstName: 'Kevin' }, { 'child-component': {fingerprint: '123', tag: 'div' } }, + {}, {} ); @@ -28,6 +29,7 @@ describe('buildRequest', () => { }], { firstName: 'Kevin' }, { 'child-component': {fingerprint: '123', tag: 'div' } }, + {}, {} ); @@ -35,10 +37,11 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('POST'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', - 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '_the_csrf_token', }); - expect(fetchOptions.body).toEqual(JSON.stringify({ + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan' }, updated: { firstName: 'Kevin' }, children: { 'child-component': { fingerprint: '123', tag: 'div' } }, @@ -59,12 +62,15 @@ describe('buildRequest', () => { }], { firstName: 'Kevin' }, {}, + {}, {} ); expect(url).toEqual('/_components/_batch'); expect(fetchOptions.method).toEqual('POST'); - expect(fetchOptions.body).toEqual(JSON.stringify({ + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan' }, updated: { firstName: 'Kevin' }, actions: [{ @@ -85,6 +91,7 @@ describe('buildRequest', () => { [], { firstName: 'Kevin'.repeat(1000) }, {}, + {}, {} ); @@ -93,9 +100,10 @@ describe('buildRequest', () => { expect(fetchOptions.headers).toEqual({ // no token Accept: 'application/vnd.live-component+html', - 'Content-Type': 'application/json', }); - expect(fetchOptions.body).toEqual(JSON.stringify({ + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan'.repeat(1000) }, updated: { firstName: 'Kevin'.repeat(1000) }, })); @@ -108,7 +116,8 @@ describe('buildRequest', () => { [], { firstName: 'Kevin' }, { }, - { count: 5 } + { count: 5 }, + {} ); expect(url).toEqual('/_components?existing_param=1&props=%7B%22firstName%22%3A%22Ryan%22%7D&updated=%7B%22firstName%22%3A%22Kevin%22%7D&propsFromParent=%7B%22count%22%3A5%7D'); @@ -123,13 +132,84 @@ describe('buildRequest', () => { { firstName: 'Kevin' }, {}, { count: 5 }, + {} ); - expect(fetchOptions.body).toEqual(JSON.stringify({ + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + expect(body.get('data')).toEqual(JSON.stringify({ props: { firstName: 'Ryan' }, updated: { firstName: 'Kevin' }, propsFromParent: { count: 5 }, args: { sendNotification: '1' }, })); }); + + // Helper method for FileList mocking + const getFileList = (length = 1) => { + const blob = new Blob([''], { type: 'text/html' }); + // @ts-ignore This is a mock and those are needed to mock a File object + blob['lastModifiedDate'] = ''; + // @ts-ignore This is a mock and those are needed to mock a File object + blob['name'] = 'filename'; + const file = blob; + const fileList: FileList = { + length: length, + item: () => file + }; + for (let i= 0; i < length; ++i) { + fileList[i] = file; + } + return fileList; + }; + + it('Sends file with request', () => { + const builder = new RequestBuilder('/_components', '_the_csrf_token'); + + const { url, fetchOptions } = builder.buildRequest( + { firstName: 'Ryan' }, + [], + {}, + {}, + {}, + { 'file': getFileList()} + ); + + expect(url).toEqual('/_components'); + expect(fetchOptions.method).toEqual('POST'); + expect(fetchOptions.headers).toEqual({ + Accept: 'application/vnd.live-component+html', + 'X-CSRF-TOKEN': '_the_csrf_token', + }); + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + expect(body.get('file')).toBeInstanceOf(File); + expect(body.getAll('file').length).toEqual(1); + }); + + it('Sends multiple files with request', () => { + const builder = new RequestBuilder('/_components', '_the_csrf_token'); + + const { url, fetchOptions } = builder.buildRequest( + { firstName: 'Ryan' }, + [], + {}, + {}, + {}, + { 'file[]': getFileList(3), 'otherFile': getFileList()} + ); + + expect(url).toEqual('/_components'); + expect(fetchOptions.method).toEqual('POST'); + expect(fetchOptions.headers).toEqual({ + Accept: 'application/vnd.live-component+html', + 'X-CSRF-TOKEN': '_the_csrf_token', + }); + const body = fetchOptions.body; + expect(body).toBeInstanceOf(FormData); + expect(body.get('file[]')).toBeInstanceOf(File); + expect(body.getAll('file[]').length).toEqual(3); + expect(body.get('otherFile')).toBeInstanceOf(File); + expect(body.getAll('otherFile').length).toEqual(1); + }); }); diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 57e9a2c5453..75749626f08 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -1153,6 +1153,75 @@ the component now extends ``AbstractController``! That is totally allowed, and gives you access to all of your normal controller shortcuts. We even added a flash message! +.. _files + +Uploading files +--------------- + +.. versionadded:: 2.9 + + The ability to upload files to actions was added in version 2.9. + +Files aren't sent to the component by default. You need to use a live action +to handle the files and tell the component when the file should be sent: + +.. code-block:: html+twig + +

+ + + diff --git a/ux.symfony.com/templates/live_component_demo/upload.html.twig b/ux.symfony.com/templates/live_component_demo/upload.html.twig new file mode 100644 index 00000000000..14f5960b059 --- /dev/null +++ b/ux.symfony.com/templates/live_component_demo/upload.html.twig @@ -0,0 +1,13 @@ +{% extends 'liveDemoBase.html.twig' %} + +{% block code_block_left %} + +{% endblock %} + +{% block code_block_right %} + +{% endblock %} + +{% block demo_content %} + {{ component('UploadFiles') }} +{% endblock %}