diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index a43e9518ee5..9ff4da4160e 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -3,6 +3,8 @@ ## 2.23.0 - Allow configuring the secret used to compute fingerprints and checksums. +- [EXPERIMENTAL] Add `LiveDownloadResponse` and enable file downloads from + a `LiveAction`. ## 2.22.0 diff --git a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts index a51a6448707..864a3533545 100644 --- a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts +++ b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts @@ -3,4 +3,5 @@ export default class { private body; constructor(response: Response); getBody(): Promise; + getBlob(): Promise; } diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 375a5b15bd5..81476ff9ff3 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -111,6 +111,9 @@ class BackendResponse { } return this.body; } + async getBlob() { + return this.response.blob(); + } } function getElementAsTagText(element) { @@ -2119,11 +2122,32 @@ class Component { this.isRequestPending = false; this.backendRequest.promise.then(async (response) => { const backendResponse = new BackendResponse(response); - const html = await backendResponse.getBody(); + const headers = backendResponse.response.headers; for (const input of Object.values(this.pendingFiles)) { input.value = ''; } - const headers = backendResponse.response.headers; + const contentDisposition = headers.get('Content-Disposition'); + const fileResponse = contentDisposition?.match(/^(attachment|inline).*filename="?([^;]+)"?/); + if (fileResponse) { + const blob = await backendResponse.getBlob(); + const link = Object.assign(document.createElement('a'), { + href: URL.createObjectURL(blob), + download: fileResponse[2], + style: 'display: none', + target: '_blank', + }); + document.body.appendChild(link); + link.click(); + setTimeout(() => document.body.removeChild(link), 75); + this.backendRequest = null; + thisPromiseResolve(backendResponse); + if (this.isRequestPending) { + this.isRequestPending = false; + this.performRequest(); + } + return response; + } + const html = await backendResponse.getBody(); if (!headers.get('Content-Type')?.includes('application/vnd.live-component+html') && !headers.get('X-Live-Redirect')) { const controls = { displayError: true }; diff --git a/src/LiveComponent/assets/src/Backend/BackendResponse.ts b/src/LiveComponent/assets/src/Backend/BackendResponse.ts index 5b1357bd24e..1b74c5a727b 100644 --- a/src/LiveComponent/assets/src/Backend/BackendResponse.ts +++ b/src/LiveComponent/assets/src/Backend/BackendResponse.ts @@ -13,4 +13,8 @@ export default class { return this.body; } + + async getBlob(): Promise { + return this.response.blob(); + } } diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 7db1f564a7b..553d58f73ea 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -300,15 +300,41 @@ export default class Component { this.backendRequest.promise.then(async (response) => { const backendResponse = new BackendResponse(response); - const html = await backendResponse.getBody(); + const headers = backendResponse.response.headers; // clear sent files inputs for (const input of Object.values(this.pendingFiles)) { input.value = ''; } + // File Download + const contentDisposition = headers.get('Content-Disposition'); + const fileResponse = contentDisposition?.match(/^(attachment|inline).*filename="?([^;]+)"?/); + if (fileResponse) { + const blob = await backendResponse.getBlob(); + const link = Object.assign(document.createElement('a'), { + href: URL.createObjectURL(blob), + download: fileResponse[2], + style: 'display: none', + target: '_blank', + }); + document.body.appendChild(link); + link.click(); + setTimeout(() => document.body.removeChild(link), 75); + + this.backendRequest = null; + thisPromiseResolve(backendResponse); + if (this.isRequestPending) { + this.isRequestPending = false; + this.performRequest(); + } + + return response; + } + + const html = await backendResponse.getBody(); + // if the response does not contain a component, render as an error - const headers = backendResponse.response.headers; if ( !headers.get('Content-Type')?.includes('application/vnd.live-component+html') && !headers.get('X-Live-Redirect') diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 58b5df6d111..4d8d17af4e2 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -13,6 +13,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Exception\JsonException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; diff --git a/src/LiveComponent/src/LiveResponse.php b/src/LiveComponent/src/LiveResponse.php new file mode 100644 index 00000000000..b100b168470 --- /dev/null +++ b/src/LiveComponent/src/LiveResponse.php @@ -0,0 +1,51 @@ + + * @author Kevin Bond + */ +final class LiveResponse +{ + /** + * @param string|\SplFileInfo $file The file to send as a response + * @param string|null $filename The name of the file to send (defaults to the basename of the file) + * @param string|null $contentType The content type of the file (defaults to `application/octet-stream`) + */ + public static function file(string|\SplFileInfo $file, ?string $filename = null, ?string $contentType = null, ?int $size = null): BinaryFileResponse + { + return new BinaryFileResponse($file, 200, [ + 'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename ?? basename($file)), + 'Content-Type' => $contentType ?? 'application/octet-stream', + 'Content-Length' => $size ?? ($file instanceof \SplFileInfo ? $file->getSize() : null), + ]); + } + + /** + * @param resource|Closure $file The file to stream as a response + * @param string $filename The name of the file to send (defaults to the basename of the file) + * @param string|null $contentType The content type of the file (defaults to `application/octet-stream`) + * @param int|null $size The size of the file + */ + public static function streamFile(mixed $file, string $filename, ?string $contentType = null, ?int $size = null): StreamedResponse + { + if (!is_resource($file) && !$file instanceof \Closure) { + throw new \InvalidArgumentException(sprintf('The file must be a resource or a closure, "%s" given.', get_debug_type($file))); + } + + return new StreamedResponse($file instanceof \Closure ? $file(...) : function () use ($file) { + while (!feof($file)) { + echo fread($file, 1024); + } + }, 200, [ + 'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename), + 'Content-Type' => $contentType ?? 'application/octet-stream', + 'Content-Length' => $size, + ]); + } +} diff --git a/src/LiveComponent/tests/Fixtures/Component/DownloadFileComponent.php b/src/LiveComponent/tests/Fixtures/Component/DownloadFileComponent.php new file mode 100644 index 00000000000..d6333142324 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Component/DownloadFileComponent.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; + +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveArg; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\LiveComponent\LiveResponse; + +/** + * @author Simon André + */ +#[AsLiveComponent('download_file', template: 'components/download_file.html.twig')] +class DownloadFileComponent +{ + use DefaultActionTrait; + + private const FILE_DIRECTORY = __DIR__.'/../files/'; + + #[LiveAction] + public function download(): BinaryFileResponse + { + $file = new \SplFileInfo(self::FILE_DIRECTORY.'/foo.json'); + + return LiveResponse::file($file); + } + + #[LiveAction] + public function generate(): BinaryFileResponse + { + $file = new \SplTempFileObject(); + $file->fwrite(file_get_contents(self::FILE_DIRECTORY.'/foo.json')); + + return LiveResponse::file($file, 'foo.json', size: 1000); + } + + #[LiveAction] + public function heavyFile(#[LiveArg] int $size): BinaryFileResponse + { + $file = new \SplFileInfo(self::FILE_DIRECTORY.'heavy.txt'); + + $response = LiveResponse::file($file); + $response->headers->set('Content-Length', 10000000); // 10MB + } +} diff --git a/src/LiveComponent/tests/Fixtures/files/foo.html b/src/LiveComponent/tests/Fixtures/files/foo.html new file mode 100644 index 00000000000..85f74bd15ed --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/files/foo.html @@ -0,0 +1,9 @@ + + + + Foo + + +

Bar

+ + diff --git a/src/LiveComponent/tests/Fixtures/files/foo.json b/src/LiveComponent/tests/Fixtures/files/foo.json new file mode 100644 index 00000000000..e63d37b65a8 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/files/foo.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} diff --git a/src/LiveComponent/tests/Fixtures/files/foo.md b/src/LiveComponent/tests/Fixtures/files/foo.md new file mode 100644 index 00000000000..ed69f19dbf3 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/files/foo.md @@ -0,0 +1,3 @@ +# Foo + +## Bar diff --git a/src/LiveComponent/tests/Fixtures/files/test.txt b/src/LiveComponent/tests/Fixtures/files/test.txt new file mode 100644 index 00000000000..8e27be7d615 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/files/test.txt @@ -0,0 +1 @@ +text diff --git a/src/LiveComponent/tests/Fixtures/templates/components/download_file.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/download_file.html.twig new file mode 100644 index 00000000000..a337ab59064 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/components/download_file.html.twig @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index 9ac59da9123..72633cc563d 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -12,12 +12,11 @@ namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Symfony\Component\DomCrawler\Crawler; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1; use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper; +use Zenstruck\Browser; use Zenstruck\Browser\Test\HasBrowser; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -70,8 +69,7 @@ public function testCanRenderComponentAsHtml(): void ->assertContains('Prop1: '.$entity->id) ->assertContains('Prop2: 2021-03-05 9:23') ->assertContains('Prop3: value3') - ->assertContains('Prop4: (none)') - ; + ->assertContains('Prop4: (none)'); } public function testCanRenderComponentAsHtmlWithAlternateRoute(): void @@ -89,8 +87,7 @@ public function testCanRenderComponentAsHtmlWithAlternateRoute(): void ]) ->assertSuccessful() ->assertOn('/alt/alternate_route', parts: ['path']) - ->assertContains('From alternate route. (count: 0)') - ; + ->assertContains('From alternate route. (count: 0)'); } public function testCanExecuteComponentActionNormalRoute(): void @@ -127,8 +124,7 @@ public function testCanExecuteComponentActionNormalRoute(): void ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') ->assertContains('Count: 2') - ->assertSee('Embedded content with access to context, like count=2') - ; + ->assertSee('Embedded content with access to context, like count=2'); } public function testCanExecuteComponentActionWithAlternateRoute(): void @@ -151,24 +147,21 @@ public function testCanExecuteComponentActionWithAlternateRoute(): void ]) ->assertSuccessful() ->assertOn('/alt/alternate_route/increase') - ->assertContains('count: 1') - ; + ->assertContains('count: 1'); } public function testCannotExecuteComponentActionForGetRequest(): void { $this->browser() ->get('/_components/component2/increase') - ->assertStatus(405) - ; + ->assertStatus(405); } public function testCannotExecuteComponentDefaultActionForGetRequestWhenMethodIsPost(): void { $this->browser() ->get('/_components/with_method_post/__invoke') - ->assertStatus(405) - ; + ->assertStatus(405); } public function testPreReRenderHookOnlyExecutedDuringAjax(): void @@ -187,8 +180,7 @@ public function testPreReRenderHookOnlyExecutedDuringAjax(): void ], ]) ->assertSuccessful() - ->assertSee('PreReRenderCalled: Yes') - ; + ->assertSee('PreReRenderCalled: Yes'); } public function testItAddsEmbeddedTemplateContextToEmbeddedComponents(): void @@ -224,8 +216,7 @@ public function testItAddsEmbeddedTemplateContextToEmbeddedComponents(): void ]) ->assertSuccessful() ->assertSee('PreReRenderCalled: Yes') - ->assertSee('Embedded content with access to context, like count=1') - ; + ->assertSee('Embedded content with access to context, like count=1'); } public function testItWorksWithNamespacedTemplateNamesForEmbeddedComponents(): void @@ -237,8 +228,7 @@ public function testItWorksWithNamespacedTemplateNamesForEmbeddedComponents(): v $this->browser() ->visit('/render-namespaced-template/render_embedded_with_blocks') ->assertSuccessful() - ->assertElementAttributeContains('.component2', 'data-live-props-value', '"data-host-template":"'.$obscuredName.'"') - ; + ->assertElementAttributeContains('.component2', 'data-live-props-value', '"data-host-template":"'.$obscuredName.'"'); } public function testItUseBlocksFromEmbeddedContextUsingMultipleComponents(): void @@ -269,8 +259,7 @@ public function testItUseBlocksFromEmbeddedContextUsingMultipleComponents(): voi ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') - ->assertSee('Overridden content from component 2 on same line - count: 2') - ; + ->assertSee('Overridden content from component 2 on same line - count: 2'); } public function testItUseBlocksFromEmbeddedContextUsingMultipleComponentsWithNamespacedTemplate(): void @@ -301,8 +290,7 @@ public function testItUseBlocksFromEmbeddedContextUsingMultipleComponentsWithNam ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') - ->assertSee('Overridden content from component 2 on same line - count: 2') - ; + ->assertSee('Overridden content from component 2 on same line - count: 2'); } public function testCanRedirectFromComponentAction(): void @@ -336,10 +324,69 @@ public function testCanRedirectFromComponentAction(): void ->assertStatus(204) ->assertHeaderEquals('Location', '/') ->assertHeaderContains('X-Live-Redirect', '1') - ->assertHeaderEquals('X-Custom-Header', '1') + ->assertHeaderEquals('X-Custom-Header', '1'); + } + + public function testCanDownloadFileFromComponentAction(): void + { + $dehydrated = $this->dehydrateComponent($this->mountComponent('download_file')); + + $this->browser() + ->throwExceptions() + ->post('/_components/download_file', [ + 'body' => [ + 'data' => json_encode([ + 'props' => $dehydrated->getProps(), + ]), + ], + ]) + + ->interceptRedirects() + ->post('/_components/download_file/download', [ + 'headers' => [ + 'Accept' => 'application/vnd.live-component+html', + ], + 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], + ]) + ->assertStatus(200) + ->assertHeaderContains('Content-Type', 'application/octet-stream') + ->assertHeaderContains('Content-Disposition', 'attachment') + ->assertHeaderEquals('Content-Length', '21') ; } + public function testCanDownloadGeneratedFileFromComponentAction(): void + { + $dehydrated = $this->dehydrateComponent($this->mountComponent('download_file')); + + $this->browser() + ->throwExceptions() + ->post('/_components/download_file', [ + 'body' => [ + 'data' => json_encode([ + 'props' => $dehydrated->getProps(), + ]), + ], + ]) + ->interceptRedirects() + ->assertSuccessful() + ->post('/_components/download_file/generate', [ + 'body' => [ + 'data' => json_encode([ + 'props' => $dehydrated->getProps(), + ]), + ], + ]) + ->assertStatus(200) + ->assertHeaderContains('Content-Type', 'application/octet-stream') + ->assertHeaderContains('Content-Disposition', 'attachment') + ->assertHeaderEquals('Content-Length', '21') + ->use(function(Browser $browser) { + self::assertJson($browser->content()); + self::assertSame(['foo' => 'bar'], \json_decode($browser->content(), true)); + }); + } + public function testInjectsLiveArgs(): void { $dehydrated = $this->dehydrateComponent($this->mountComponent('component6')); @@ -371,8 +418,7 @@ public function testInjectsLiveArgs(): void ->assertHeaderContains('Content-Type', 'html') ->assertContains('Arg1: hello') ->assertContains('Arg2: 666') - ->assertContains('Arg3: 33.3') - ; + ->assertContains('Arg3: 33.3'); } public function testWithNullableEntity(): void @@ -389,8 +435,7 @@ public function testWithNullableEntity(): void ], ]) ->assertSuccessful() - ->assertContains('Prop1: default') - ; + ->assertContains('Prop1: default'); } public function testCanHaveControllerAttributes(): void @@ -407,8 +452,7 @@ public function testCanHaveControllerAttributes(): void ->actingAs(new InMemoryUser('kevin', 'pass', ['ROLE_USER'])) ->assertAuthenticated('kevin') ->post('/_components/with_security?props='.urlencode(json_encode($dehydrated->getProps()))) - ->assertSuccessful() - ; + ->assertSuccessful(); } public function testCanInjectSecurityUserIntoAction(): void @@ -436,7 +480,6 @@ public function testCanInjectSecurityUserIntoAction(): void ], ]) ->assertSuccessful() - ->assertSee('username: kevin') - ; + ->assertSee('username: kevin'); } } diff --git a/src/LiveComponent/tests/Unit/LiveResponseTest.php b/src/LiveComponent/tests/Unit/LiveResponseTest.php new file mode 100644 index 00000000000..81c34946ee9 --- /dev/null +++ b/src/LiveComponent/tests/Unit/LiveResponseTest.php @@ -0,0 +1,75 @@ +assertInstanceOf(BinaryFileResponse::class, $response); + $this->assertEquals('attachment; filename=test.txt', $response->headers->get('Content-Disposition')); + $this->assertEquals('application/octet-stream', $response->headers->get('Content-Type')); + } + + public function testSendFileWithSplFileInfo(): void + { + $file = new File(__DIR__.'/../fixtures/files/test.txt'); + $response = LiveResponse::file($file, 'custom-name.txt', 'text/plain'); + + $this->assertInstanceOf(BinaryFileResponse::class, $response); + $this->assertEquals('attachment; filename=custom-name.txt', $response->headers->get('Content-Disposition')); + $this->assertEquals('text/plain', $response->headers->get('Content-Type')); + } + + public function testSendFileWithSplTempFileObject(): void + { + $tempFile = new \SplTempFileObject(); + $tempFile->fwrite('Temporary content'); + $response = LiveResponse::file($tempFile, size: 17); + + $this->assertInstanceOf(BinaryFileResponse::class, $response); + $this->assertEquals('application/octet-stream', $response->headers->get('Content-Type')); + $this->assertEquals(17, $response->headers->get('Content-Length')); + } + + public function testStreamFileWithResource(): void + { + $file = fopen(__DIR__.'/../fixtures/files/test.txt', 'rb'); + $response = LiveResponse::streamFile($file, 'streamed-file.txt'); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertEquals('attachment; filename=streamed-file.txt', $response->headers->get('Content-Disposition')); + $this->assertEquals('application/octet-stream', $response->headers->get('Content-Type')); + fclose($file); + } + + public function testStreamFileWithClosure(): void + { + $closure = function () { + echo 'Streaming content'; + }; + + $response = LiveResponse::streamFile($closure, 'streamed-closure.txt', 'text/plain'); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertEquals('attachment; filename=streamed-closure.txt', $response->headers->get('Content-Disposition')); + $this->assertEquals('text/plain', $response->headers->get('Content-Type')); + } + + public function testStreamFileWithInvalidType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The file must be a resource or a closure, "string" given.'); + + LiveResponse::streamFile('invalid-type', 'invalid.txt'); + } +} diff --git a/ux.symfony.com/assets/styles/components/_Button.scss b/ux.symfony.com/assets/styles/components/_Button.scss index 799761294e3..46eda5f100d 100644 --- a/ux.symfony.com/assets/styles/components/_Button.scss +++ b/ux.symfony.com/assets/styles/components/_Button.scss @@ -9,6 +9,8 @@ background: var(--bs-body-bg); color: var(--bs-body-color); + + transition: background-color 0.2s, color 0.2s; } .Button--dark { @@ -17,3 +19,38 @@ color: #dee2e6; border: 1px solid #a6a0a0; } + +.Button--large { + font-size: 1.25rem; + padding: 1rem 2rem; +} + +.Button--blue { + background: #007bff; + color: #fff; + &:hover { + background: #0056b3; + } +} + +.BigButton { + display: grid; + place-content: center; + background: var(--bg-color); + background-blend-mode: color-burn; + color: var(--color); + font-size: 1rem; + text-transform: uppercase; + padding: .5rem 1rem; + border-radius: 1.5rem; + font-weight: 300; + font-stretch: semi-condensed; + opacity: .75; + transition: all 150ms; + border: 2px solid rgba(0, 0, 0, .6); + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; +} diff --git a/ux.symfony.com/assets/styles/components/_DemoCard.scss b/ux.symfony.com/assets/styles/components/_DemoCard.scss index cbcc965c8f2..964647bcb89 100644 --- a/ux.symfony.com/assets/styles/components/_DemoCard.scss +++ b/ux.symfony.com/assets/styles/components/_DemoCard.scss @@ -85,3 +85,14 @@ flex-wrap: wrap; gap: .5rem; } + +.DemoCard__badge { + position: absolute; + top: .75rem; + right: .75rem; + + .Badge { + background: var(--bs-secondary-bg); + border: 1px solid var(--bs-secondary-bg); + } +} diff --git a/ux.symfony.com/src/Controller/Demo/LiveDemoController.php b/ux.symfony.com/src/Controller/Demo/LiveDemoController.php index afbfb9cb0e6..da1bc309d20 100644 --- a/ux.symfony.com/src/Controller/Demo/LiveDemoController.php +++ b/ux.symfony.com/src/Controller/Demo/LiveDemoController.php @@ -100,6 +100,7 @@ public function invoice(LiveDemoRepository $liveDemoRepository, ?Invoice $invoic #[Route('/infinite-scroll-2', name: 'app_demo_live_component_infinite_scroll_2')] #[Route('/product-form', name: 'app_demo_live_component_product_form')] #[Route('/upload', name: 'app_demo_live_component_upload')] + #[Route('/download', name: 'app_demo_live_component_download')] public function demo( LiveDemoRepository $liveDemoRepository, string $demo, diff --git a/ux.symfony.com/src/Model/LiveDemo.php b/ux.symfony.com/src/Model/LiveDemo.php index ccd28acdc91..2d015b7088b 100644 --- a/ux.symfony.com/src/Model/LiveDemo.php +++ b/ux.symfony.com/src/Model/LiveDemo.php @@ -44,4 +44,9 @@ public function getLongDescription(): string { return $this->longDescription; } + + public function isNew(): bool + { + return \DateTimeImmutable::createFromFormat('Y-m-d', $this->getPublishedAt()) > new \DateTimeImmutable('-30 days'); + } } diff --git a/ux.symfony.com/src/Service/DocumentStorage.php b/ux.symfony.com/src/Service/DocumentStorage.php new file mode 100644 index 00000000000..77e2fd2007b --- /dev/null +++ b/ux.symfony.com/src/Service/DocumentStorage.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Service; + +use App\Model\Document; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; + +final class DocumentStorage +{ + private readonly Filesystem $filesystem; + + public function __construct( + #[Autowire('%kernel.project_dir%/assets/documents')] + private readonly string $storageDirectory, + ) { + $this->filesystem = new Filesystem(); + + if (!$this->filesystem->exists($this->storageDirectory)) { + $this->filesystem->mkdir($this->storageDirectory); + } + } + + public function readFile(string $path): string + { + if (!$this->hasFile($path)) { + throw new \InvalidArgumentException(\sprintf('The file "%s" does not exist.', $path)); + } + + return $this->filesystem->readFile($this->getAbsolutePath($path)); + } + + public function hasFile(string $path): bool + { + return $this->filesystem->exists($this->getAbsolutePath($path)); + } + + public function getFile(string $path): Document + { + if (!$this->hasFile($path)) { + throw new \InvalidArgumentException(\sprintf('The file "%s" does not exist.', $path)); + } + + return new Document($this->getAbsolutePath($path)); + } + + private function getAbsolutePath(string $path): string + { + try { + $absolutePath = Path::makeAbsolute($path, $this->storageDirectory); + } catch (\Throwable $e) { + throw new \InvalidArgumentException(\sprintf('The file "%s" is not valid.', $path), 0, $e); + } + + return $absolutePath; + } +} diff --git a/ux.symfony.com/src/Service/LiveDemoRepository.php b/ux.symfony.com/src/Service/LiveDemoRepository.php index c65a6a16f14..92b8414adb3 100644 --- a/ux.symfony.com/src/Service/LiveDemoRepository.php +++ b/ux.symfony.com/src/Service/LiveDemoRepository.php @@ -21,6 +21,16 @@ class LiveDemoRepository public function findAll(): array { return [ + new LiveDemo( + 'download', + name: 'Downloading files', + description: 'Return file as downloadable attachment from your Live Component.', + author: 'smnandre', + publishedAt: '2025-01-01', + tags: ['file', 'upload', 'LiveAction', 'download', 'button'], + longDescription: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur tincidunt vulputate felis a ultricies. + * Morbi at odio nec nulla imperdiet scelerisque a eget nibh. Donec convallis turpis ut nunc egest', + ), new LiveDemo( 'infinite-scroll-2', name: 'Infinite Scroll - 2/2', diff --git a/ux.symfony.com/src/Twig/Components/DownloadFiles.php b/ux.symfony.com/src/Twig/Components/DownloadFiles.php new file mode 100644 index 00000000000..50116150080 --- /dev/null +++ b/ux.symfony.com/src/Twig/Components/DownloadFiles.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig\Components; + +use App\Service\DocumentStorage; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveArg; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\LiveComponent\LiveDownloadResponse; + +#[AsLiveComponent] +// #[AsTaggedItem('controller.service_arguments')] +final class DownloadFiles +{ + use DefaultActionTrait; + + #[LiveProp(writable: true)] + public int $year = 2025; + + public function __construct( + private readonly DocumentStorage $documentStorage, + ) { + } + + #[LiveAction] + public function download(): BinaryFileResponse + { + $file = $this->documentStorage->getFile('demos/empty.html'); + + return new LiveDownloadResponse($file); + } + + #[LiveAction] + public function generate(#[LiveArg] string $format): BinaryFileResponse + { + $report = match ($format) { + 'csv' => $this->generateCsvReport($this->year), + 'json' => $this->generateJsonReport($this->year), + 'md' => $this->generateMarkdownReport($this->year), + default => throw new \InvalidArgumentException('Invalid format provided'), + }; + + $file = new \SplTempFileObject(); + $file->fwrite($report); + + return new LiveDownloadResponse($file, 'report.'.$format); + } + + private function generateCsvReport(int $year): string + { + $file = new \SplTempFileObject(); + // $file->fputcsv(['Month', 'Number', 'Name', 'Number of days']); + foreach ($this->getReportData($year) as $row) { + $file->fputcsv($row); + } + + return $file->fread($file->ftell()); + } + + private function generateMarkdownReport(int $year): string + { + $rows = iterator_to_array($this->getReportData($year)); + + foreach ($rows as $key => $row) { + $rows[$key] = '|'.implode('|', $row).'|'; + } + + return implode("\n", $rows); + } + + private function generateJsonReport(int $year): string + { + $rows = iterator_to_array($this->getReportData($year)); + + return json_encode($rows, \JSON_FORCE_OBJECT | \JSON_THROW_ON_ERROR); + } + + /** + * @param int<2000,2025> $year The year to generate the report for (2000-2025) + * + * @return iterable + */ + private function getReportData(int $year): iterable + { + foreach (range(1, 12) as $month) { + $startDate = \DateTimeImmutable::createFromFormat('Y', $year)->setDate($year, $month, 1); + $endDate = $startDate->modify('last day of this month'); + yield $month => [ + 'name' => $startDate->format('F'), + 'month' => $startDate->format('F'), + 'number' => $startDate->format('Y-m'), + 'nb_days' => $endDate->diff($startDate)->days, + ]; + } + } +} diff --git a/ux.symfony.com/templates/_header.html.twig b/ux.symfony.com/templates/_header.html.twig index c0ce67fb6c0..90ec7b950d7 100644 --- a/ux.symfony.com/templates/_header.html.twig +++ b/ux.symfony.com/templates/_header.html.twig @@ -50,10 +50,12 @@ Live Components Icons Packages - Demos + + + Demos + Cookbook - Support diff --git a/ux.symfony.com/templates/components/Demo/DemoCard.html.twig b/ux.symfony.com/templates/components/Demo/DemoCard.html.twig index 71789a0dced..0b18a24aab7 100644 --- a/ux.symfony.com/templates/components/Demo/DemoCard.html.twig +++ b/ux.symfony.com/templates/components/Demo/DemoCard.html.twig @@ -8,6 +8,14 @@ loading="lazy" > + + {% if demo.isNew() %} +
+
+ NEW +
+
+ {% endif %}

diff --git a/ux.symfony.com/templates/components/DownloadFiles.html.twig b/ux.symfony.com/templates/components/DownloadFiles.html.twig new file mode 100644 index 00000000000..c4520aa0e69 --- /dev/null +++ b/ux.symfony.com/templates/components/DownloadFiles.html.twig @@ -0,0 +1,56 @@ +
+ +
+
+ +
+
+

Download an existing File

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur tincidunt vulputate felis a ultricies. + Morbi at odio nec nulla imperdiet scelerisque a eget nibh. Donec convallis turpis ut nunc egestas rutrum. +

+
+
+ +
+ +
+ +
+ +
+ +

Or one generated on-demand

+ +

Choose a format:

+ +
+ {% for format in ['csv', 'json', 'md'] %} + + {% endfor %} +
+ +
+
+
+ diff --git a/ux.symfony.com/templates/demos/live_component/download.html.twig b/ux.symfony.com/templates/demos/live_component/download.html.twig new file mode 100644 index 00000000000..df1f3e3544c --- /dev/null +++ b/ux.symfony.com/templates/demos/live_component/download.html.twig @@ -0,0 +1,13 @@ +{% extends 'demos/live_demo.html.twig' %} + +{% block code_block_left %} + +{% endblock %} + +{% block code_block_right %} + +{% endblock %} + +{% block demo_content %} + +{% endblock %}