diff --git a/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig index 4872335..1bde229 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig @@ -374,87 +374,7 @@ {% endif %} - {# Remote assets sidebar - only show if project has manifest URLs configured #} - {% if hasRemoteAssets %} -
- {# On narrow screens: border-top separator. On wide screens: left border #} -
-
-

- {{ 'remote_content_assets.browser_title'|trans }} -

- -
- - {# Upload dropzone - only show if S3 is configured #} - {% if project.hasS3UploadConfigured() %} -
- - - -

{{ 'remote_content_assets.browser_upload_dropzone'|trans }}

-

{{ 'remote_content_assets.browser_upload_hint'|trans }}

-
- {# Upload progress indicator #} - - {# Upload success message #} - - {# Upload error message #} - - {% endif %} - -
- -
-
- {{ 'common.loading'|trans }} -
- -
-
-
- {% endif %} + {% include '@chat_based_content_editor.presentation/remote_asset_area.twig' %} {# Info section with cross-links - subtle placement at bottom #} diff --git a/src/ChatBasedContentEditor/Presentation/Resources/templates/remote_asset_area.twig b/src/ChatBasedContentEditor/Presentation/Resources/templates/remote_asset_area.twig new file mode 100644 index 0000000..bc8ddf9 --- /dev/null +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/remote_asset_area.twig @@ -0,0 +1,90 @@ +{# Remote assets sidebar - only show if project has manifest URLs configured #} +{% if hasRemoteAssets %} +
+ {# On narrow screens: border-top separator. On wide screens: left border #} +
+
+

+ {{ 'remote_content_assets.browser_title'|trans }} +

+ +
+ + {# Upload dropzone - only show if S3 is configured #} + {% if project.hasS3UploadConfigured() %} +
+ + + +

{{ 'remote_content_assets.browser_upload_dropzone'|trans }}

+

{{ 'remote_content_assets.browser_upload_hint'|trans }}

+
+ {# Hidden file input for click-to-upload #} + + {# Upload progress indicator #} + + {# Upload success message #} + + {# Upload error message #} + + {% endif %} + +
+ +
+
+ {{ 'common.loading'|trans }} +
+ +
+
+
+{% endif %} diff --git a/src/RemoteContentAssets/Presentation/Resources/assets/controllers/remote_asset_browser_controller.ts b/src/RemoteContentAssets/Presentation/Resources/assets/controllers/remote_asset_browser_controller.ts index 5e5e338..2e2d4c1 100644 --- a/src/RemoteContentAssets/Presentation/Resources/assets/controllers/remote_asset_browser_controller.ts +++ b/src/RemoteContentAssets/Presentation/Resources/assets/controllers/remote_asset_browser_controller.ts @@ -9,7 +9,8 @@ import { Controller } from "@hotwired/stimulus"; * - Image preview for image URLs * - Click to open asset in new tab * - Add to chat button dispatches custom event for insertion - * - Drag-and-drop upload to S3 (when configured) + * - Drag-and-drop upload to S3 (when configured, supports multiple files) + * - Clickable dropzone to open file dialog (supports multiple files) */ export default class extends Controller { static values = { @@ -30,7 +31,9 @@ export default class extends Controller { "empty", "search", "dropzone", + "fileInput", "uploadProgress", + "uploadProgressText", "uploadError", "uploadSuccess", ]; @@ -55,8 +58,12 @@ export default class extends Controller { declare readonly searchTarget: HTMLInputElement; declare readonly hasDropzoneTarget: boolean; declare readonly dropzoneTarget: HTMLElement; + declare readonly hasFileInputTarget: boolean; + declare readonly fileInputTarget: HTMLInputElement; declare readonly hasUploadProgressTarget: boolean; declare readonly uploadProgressTarget: HTMLElement; + declare readonly hasUploadProgressTextTarget: boolean; + declare readonly uploadProgressTextTarget: HTMLElement; declare readonly hasUploadErrorTarget: boolean; declare readonly uploadErrorTarget: HTMLElement; declare readonly hasUploadSuccessTarget: boolean; @@ -120,7 +127,7 @@ export default class extends Controller { } /** - * Handle drop event - upload the file. + * Handle drop event - upload all dropped files. */ private async handleDrop(e: DragEvent): Promise { e.preventDefault(); @@ -136,52 +143,122 @@ export default class extends Controller { return; } - // Only upload the first file - await this.uploadFile(files[0]); + await this.uploadFiles(files); } /** - * Upload a file to S3. + * Open the native file dialog by clicking the hidden file input. */ - private async uploadFile(file: File): Promise { - if (!this.isUploadEnabled() || this.isUploading) { + openFileDialog(): void { + if (!this.isUploadEnabled() || !this.hasFileInputTarget || this.isUploading) { return; } - this.isUploading = true; - this.showUploadStatus("progress"); + // Reset value so the same file(s) can be re-selected + this.fileInputTarget.value = ""; + this.fileInputTarget.click(); + } - try { - const formData = new FormData(); - formData.append("file", file); - formData.append("workspace_id", this.workspaceIdValue); - formData.append("_csrf_token", this.uploadCsrfTokenValue); - - const response = await fetch(this.uploadUrlValue, { - method: "POST", - headers: { - "X-Requested-With": "XMLHttpRequest", - }, - body: formData, - }); + /** + * Handle file selection from the native file dialog. + */ + handleFileSelect(e: Event): void { + const input = e.target as HTMLInputElement; + const files = input.files; + if (!files || files.length === 0) { + return; + } - const data = (await response.json()) as { success?: boolean; url?: string; error?: string }; + void this.uploadFiles(files); + } - if (data.success && data.url) { - this.showUploadStatus("success"); - // Notify chat controller about the upload - this.dispatch("uploadComplete", { detail: { url: data.url } }); - // Re-fetch the asset list to show updated manifests - await this.fetchAssets(); - // Auto-hide success message after 3 seconds - setTimeout(() => this.showUploadStatus("none"), 3000); - } else { - this.showUploadError(data.error || "Upload failed"); + /** + * Upload multiple files sequentially to S3. + */ + private async uploadFiles(files: FileList): Promise { + if (!this.isUploadEnabled() || this.isUploading) { + return; + } + + this.isUploading = true; + const total = files.length; + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < total; i++) { + this.updateUploadProgressText(i + 1, total); + this.showUploadStatus("progress"); + + try { + const uploaded = await this.uploadSingleFile(files[i]); + if (uploaded) { + successCount++; + } else { + errorCount++; + } + } catch { + errorCount++; } - } catch { + } + + // Re-fetch the asset list once after all uploads + if (successCount > 0) { + await this.fetchAssets(); + } + + // Show final status + if (errorCount > 0 && successCount === 0) { this.showUploadError("Upload failed. Please try again."); - } finally { - this.isUploading = false; + } else if (errorCount > 0) { + this.showUploadError(`${errorCount} of ${total} uploads failed.`); + } else { + this.showUploadStatus("success"); + setTimeout(() => this.showUploadStatus("none"), 3000); + } + + this.isUploading = false; + } + + /** + * Upload a single file to S3. Returns true on success. + */ + private async uploadSingleFile(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append("workspace_id", this.workspaceIdValue); + formData.append("_csrf_token", this.uploadCsrfTokenValue); + + const response = await fetch(this.uploadUrlValue, { + method: "POST", + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + body: formData, + }); + + const data = (await response.json()) as { success?: boolean; url?: string; error?: string }; + + if (data.success && data.url) { + this.dispatch("uploadComplete", { detail: { url: data.url } }); + + return true; + } + + return false; + } + + /** + * Update the upload progress text for multi-file uploads. + */ + private updateUploadProgressText(current: number, total: number): void { + if (!this.hasUploadProgressTextTarget) { + return; + } + + if (total === 1) { + this.uploadProgressTextTarget.textContent = ""; + } else { + this.uploadProgressTextTarget.textContent = `(${current}/${total})`; } } diff --git a/tests/Integration/RemoteContentAssets/RemoteAssetsControllerUploadTest.php b/tests/Integration/RemoteContentAssets/RemoteAssetsControllerUploadTest.php new file mode 100644 index 0000000..18dbcc8 --- /dev/null +++ b/tests/Integration/RemoteContentAssets/RemoteAssetsControllerUploadTest.php @@ -0,0 +1,346 @@ +client = static::createClient(); + $container = static::getContainer(); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $container->get(EntityManagerInterface::class); + $this->entityManager = $entityManager; + + $this->cleanupTestData(); + } + + private function cleanupTestData(): void + { + $connection = $this->entityManager->getConnection(); + + try { + $connection->executeStatement('DELETE FROM conversations'); + $connection->executeStatement('DELETE FROM workspaces'); + $connection->executeStatement('DELETE FROM projects'); + $connection->executeStatement('DELETE FROM account_cores'); + } catch (Throwable) { + // Tables may not exist yet on first run + } + } + + // ========================================== + // CSRF Validation + // ========================================== + + public function testUploadReturns403WhenCsrfTokenIsInvalid(): void + { + $user = $this->createAndLoginUser(); + $project = $this->createProjectWithS3Config(); + + $projectId = $project->getId(); + self::assertNotNull($projectId); + + $this->client->request( + 'POST', + '/en/api/projects/' . $projectId . '/remote-assets/upload', + ['_csrf_token' => 'invalid-token', 'workspace_id' => 'ws-123'] + ); + + self::assertResponseStatusCodeSame(403); + + $response = $this->getJsonResponse(); + self::assertArrayHasKey('error', $response); + self::assertIsString($response['error']); + self::assertStringContainsString('CSRF', $response['error']); + } + + // ========================================== + // File Validation + // ========================================== + + public function testUploadReturns400WhenNoFileUploaded(): void + { + $user = $this->createAndLoginUser(); + $project = $this->createProjectWithS3Config(); + + $projectId = $project->getId(); + self::assertNotNull($projectId); + + $this->client->request( + 'POST', + '/en/api/projects/' . $projectId . '/remote-assets/upload', + ['_csrf_token' => self::CSRF_TOKEN_VALUE, 'workspace_id' => 'ws-123'] + ); + + self::assertResponseStatusCodeSame(400); + + $response = $this->getJsonResponse(); + self::assertArrayHasKey('error', $response); + self::assertIsString($response['error']); + self::assertStringContainsString('No file uploaded', $response['error']); + } + + public function testUploadReturns400WhenMimeTypeNotAllowed(): void + { + $user = $this->createAndLoginUser(); + $project = $this->createProjectWithS3Config(); + + $projectId = $project->getId(); + self::assertNotNull($projectId); + + // Create a temporary text file (not an allowed image type) + $tmpFile = tempnam(sys_get_temp_dir(), 'test_upload_'); + self::assertNotFalse($tmpFile); + file_put_contents($tmpFile, 'This is a plain text file, not an image.'); + + $uploadedFile = new UploadedFile($tmpFile, 'document.txt', 'text/plain', null, true); + + try { + $this->client->request( + 'POST', + '/en/api/projects/' . $projectId . '/remote-assets/upload', + ['_csrf_token' => self::CSRF_TOKEN_VALUE, 'workspace_id' => 'ws-123'], + ['file' => $uploadedFile] + ); + + self::assertResponseStatusCodeSame(400); + + $response = $this->getJsonResponse(); + self::assertArrayHasKey('error', $response); + self::assertIsString($response['error']); + self::assertStringContainsString('File type not allowed', $response['error']); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + // ========================================== + // Successful Upload + // ========================================== + + public function testUploadReturnsSuccessWithUrlOnValidJpegUpload(): void + { + $user = $this->createAndLoginUser(); + $project = $this->createProjectWithS3Config(); + + $projectId = $project->getId(); + self::assertNotNull($projectId); + + $expectedUrl = 'https://test-bucket.s3.eu-central-1.amazonaws.com/uploads/20260210/abc123_photo.jpg'; + + $mockFacade = $this->mockRemoteContentAssetsFacade(); + $mockFacade->expects($this->once()) + ->method('uploadAsset') + ->with( + 'test-bucket', + 'eu-central-1', + 'AKIAIOSFODNN7EXAMPLE', + 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + null, + 'assets', + 'photo.jpg', + self::callback(fn (string $contents): bool => $contents !== ''), + 'image/jpeg' + ) + ->willReturn($expectedUrl); + + // Create a minimal JPEG file (valid JPEG header) + $tmpFile = tempnam(sys_get_temp_dir(), 'test_upload_'); + self::assertNotFalse($tmpFile); + file_put_contents($tmpFile, "\xFF\xD8\xFF\xE0" . str_repeat("\x00", 100)); + + $uploadedFile = new UploadedFile($tmpFile, 'photo.jpg', 'image/jpeg', null, true); + + $this->client->request( + 'POST', + '/en/api/projects/' . $projectId . '/remote-assets/upload', + ['_csrf_token' => self::CSRF_TOKEN_VALUE, 'workspace_id' => 'ws-123'], + ['file' => $uploadedFile] + ); + + try { + self::assertResponseIsSuccessful(); + + $response = $this->getJsonResponse(); + self::assertTrue($response['success']); + self::assertSame($expectedUrl, $response['url']); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + // ========================================== + // S3 Error Handling + // ========================================== + + public function testUploadReturns500WhenS3UploadFails(): void + { + $user = $this->createAndLoginUser(); + $project = $this->createProjectWithS3Config(); + + $projectId = $project->getId(); + self::assertNotNull($projectId); + + $mockFacade = $this->mockRemoteContentAssetsFacade(); + $mockFacade->method('uploadAsset') + ->willThrowException(new RuntimeException('Access Denied')); + + // Create a minimal JPEG file + $tmpFile = tempnam(sys_get_temp_dir(), 'test_upload_'); + self::assertNotFalse($tmpFile); + file_put_contents($tmpFile, "\xFF\xD8\xFF\xE0" . str_repeat("\x00", 100)); + + $uploadedFile = new UploadedFile($tmpFile, 'photo.jpg', 'image/jpeg', null, true); + + try { + $this->client->request( + 'POST', + '/en/api/projects/' . $projectId . '/remote-assets/upload', + ['_csrf_token' => self::CSRF_TOKEN_VALUE, 'workspace_id' => 'ws-123'], + ['file' => $uploadedFile] + ); + + self::assertResponseStatusCodeSame(500); + + $response = $this->getJsonResponse(); + self::assertArrayHasKey('error', $response); + self::assertIsString($response['error']); + self::assertStringContainsString('Upload failed', $response['error']); + self::assertStringContainsString('Access Denied', $response['error']); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + // ========================================== + // Helper Methods + // ========================================== + + /** + * Create a user, log them in, and set up a session with the CSRF token. + * + * This combines user creation, authentication, and CSRF token injection + * into a single session to ensure the POST requests pass both checks. + */ + private function createAndLoginUser(): AccountCore + { + $container = static::getContainer(); + + /** @var UserPasswordHasherInterface $passwordHasher */ + $passwordHasher = $container->get(UserPasswordHasherInterface::class); + + $user = new AccountCore('upload-test@example.com', ''); + $hashedPassword = $passwordHasher->hashPassword($user, 'password123'); + $user = new AccountCore('upload-test@example.com', $hashedPassword); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + // Create a session that contains both the authentication token and the CSRF token + /** @var \Symfony\Component\HttpFoundation\Session\SessionFactoryInterface $sessionFactory */ + $sessionFactory = $container->get('session.factory'); + $session = $sessionFactory->createSession(); + + // Set authentication token + /** @var SecurityUserProvider $userProvider */ + $userProvider = $container->get(SecurityUserProvider::class); + $securityUser = $userProvider->loadUserByIdentifier($user->getEmail()); + $authToken = new UsernamePasswordToken($securityUser, 'main', $securityUser->getRoles()); + $session->set('_security_main', serialize($authToken)); + + // Set CSRF token (SessionTokenStorage stores under '_csrf/{tokenId}') + $session->set('_csrf/remote_asset_upload', self::CSRF_TOKEN_VALUE); + + $session->save(); + + // Set the session cookie so the client uses this session + $cookie = new Cookie($session->getName(), $session->getId()); + $this->client->getCookieJar()->set($cookie); + + return $user; + } + + private function createProjectWithS3Config(): Project + { + $project = new Project( + 'org-test-123', + 'Upload Test Project', + 'https://github.com/org/repo.git', + 'ghp_test_token', + LlmModelProvider::OpenAI, + 'sk-test-key' + ); + + $project->setS3BucketName('test-bucket'); + $project->setS3Region('eu-central-1'); + $project->setS3AccessKeyId('AKIAIOSFODNN7EXAMPLE'); + $project->setS3SecretAccessKey('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'); + $project->setS3KeyPrefix('assets'); + + $this->entityManager->persist($project); + $this->entityManager->flush(); + + return $project; + } + + /** + * Replace the RemoteContentAssetsFacadeInterface in the container with a mock. + */ + private function mockRemoteContentAssetsFacade(): RemoteContentAssetsFacadeInterface&MockObject + { + $mock = $this->createMock(RemoteContentAssetsFacadeInterface::class); + static::getContainer()->set(RemoteContentAssetsFacadeInterface::class, $mock); + + return $mock; + } + + /** + * @return array + */ + private function getJsonResponse(): array + { + $content = (string) $this->client->getResponse()->getContent(); + + /** @var array $decoded */ + $decoded = json_decode($content, true); + + return $decoded; + } +} diff --git a/tests/Unit/RemoteContentAssets/S3AssetUploaderTest.php b/tests/Unit/RemoteContentAssets/S3AssetUploaderTest.php new file mode 100644 index 0000000..b27ad83 --- /dev/null +++ b/tests/Unit/RemoteContentAssets/S3AssetUploaderTest.php @@ -0,0 +1,148 @@ +createMock(LoggerInterface::class); + $this->uploader = new S3AssetUploader($logger); + + $this->generateUniqueKey = new ReflectionMethod(S3AssetUploader::class, 'generateUniqueKey'); + } + + // ========================================== + // Filename Sanitization + // ========================================== + + public function testSanitizesSpecialCharactersInFilename(): void + { + $key = $this->callGenerateUniqueKey(null, 'hello world (copy).jpg'); + + // Special characters should be replaced with underscores + self::assertStringContainsString('hello_world__copy_', $key); + self::assertStringEndsWith('.jpg', $key); + } + + public function testSanitizesUmlautsAndUnicodeCharacters(): void + { + $key = $this->callGenerateUniqueKey(null, 'über-straße.png'); + + // Multi-byte characters (ü = 2 bytes, ß = 2 bytes) each become underscores + self::assertStringContainsString('__ber-stra__e', $key); + self::assertStringEndsWith('.png', $key); + } + + public function testKeepsAlphanumericDashAndUnderscore(): void + { + $key = $this->callGenerateUniqueKey(null, 'my-image_2024.webp'); + + self::assertStringContainsString('my-image_2024', $key); + self::assertStringEndsWith('.webp', $key); + } + + public function testTruncatesLongFilenameToFiftyCharacters(): void + { + $longName = str_repeat('a', 100) . '.jpg'; + $key = $this->callGenerateUniqueKey(null, $longName); + + // Extract the basename part (after hex_) + preg_match('/[a-f0-9]{16}_(.+)\.jpg$/', $key, $matches); + self::assertNotEmpty($matches, 'Key should match expected format'); + self::assertSame(50, mb_strlen($matches[1])); + } + + public function testHandlesFilenameWithoutExtension(): void + { + $key = $this->callGenerateUniqueKey(null, 'README'); + + // Should not end with a dot + self::assertDoesNotMatchRegularExpression('/\.$/', $key); + self::assertMatchesRegularExpression('/[a-f0-9]{16}_README$/', $key); + } + + public function testPreservesFileExtension(): void + { + $key = $this->callGenerateUniqueKey(null, 'photo.avif'); + + self::assertStringEndsWith('.avif', $key); + } + + // ========================================== + // Key Prefix Handling + // ========================================== + + public function testKeyWithoutPrefixStartsWithUploads(): void + { + $key = $this->callGenerateUniqueKey(null, 'image.jpg'); + + self::assertMatchesRegularExpression('#^uploads/\d{8}/[a-f0-9]{16}_image\.jpg$#', $key); + } + + public function testKeyWithPrefixPrependsPrefix(): void + { + $key = $this->callGenerateUniqueKey('my-project', 'image.jpg'); + + self::assertMatchesRegularExpression('#^my-project/uploads/\d{8}/[a-f0-9]{16}_image\.jpg$#', $key); + } + + public function testKeyWithTrailingSlashPrefixDoesNotDoubleSlash(): void + { + $key = $this->callGenerateUniqueKey('my-project/', 'image.jpg'); + + self::assertStringStartsWith('my-project/uploads/', $key); + self::assertStringNotContainsString('//', $key); + } + + public function testEmptyStringPrefixIsTreatedAsNoPrefix(): void + { + $key = $this->callGenerateUniqueKey('', 'image.jpg'); + + self::assertMatchesRegularExpression('#^uploads/\d{8}/#', $key); + } + + // ========================================== + // Key Format + // ========================================== + + public function testKeyContainsDateFolder(): void + { + $key = $this->callGenerateUniqueKey(null, 'test.png'); + $expectedDate = date('Ymd'); + + self::assertStringContainsString('uploads/' . $expectedDate . '/', $key); + } + + public function testKeyContainsSixteenCharHexPrefix(): void + { + $key = $this->callGenerateUniqueKey(null, 'test.png'); + + self::assertMatchesRegularExpression('#/[a-f0-9]{16}_#', $key); + } + + // ========================================== + // Helper + // ========================================== + + private function callGenerateUniqueKey(?string $keyPrefix, string $filename): string + { + /** @var string $result */ + $result = $this->generateUniqueKey->invoke($this->uploader, $keyPrefix, $filename); + + return $result; + } +} diff --git a/tests/frontend/unit/RemoteContentAssets/remote_asset_browser_controller.test.ts b/tests/frontend/unit/RemoteContentAssets/remote_asset_browser_controller.test.ts index 8e3377f..a209c1d 100644 --- a/tests/frontend/unit/RemoteContentAssets/remote_asset_browser_controller.test.ts +++ b/tests/frontend/unit/RemoteContentAssets/remote_asset_browser_controller.test.ts @@ -526,5 +526,173 @@ describe("RemoteAssetBrowserController", () => { expect(eventListener).not.toHaveBeenCalled(); }); + + it("uploads multiple files sequentially and dispatches events for each", async () => { + const mockFetch = vi + .fn() + // Initial asset list fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ urls: [] }), + }) + // Upload file 1 + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, url: "https://example.com/a.jpg" }), + }) + // Upload file 2 + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, url: "https://example.com/b.png" }), + }) + // Upload file 3 + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, url: "https://example.com/c.gif" }), + }) + // Re-fetch after all uploads + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + urls: [ + "https://example.com/a.jpg", + "https://example.com/b.png", + "https://example.com/c.gif", + ], + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const controller = await createControllerElementWithUpload(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const eventListener = vi.fn(); + controller.addEventListener("remote-asset-browser:uploadComplete", eventListener); + + const dropzone = document.querySelector('[data-remote-asset-browser-target="dropzone"]') as HTMLElement; + const files = [ + new File(["a"], "a.jpg", { type: "image/jpeg" }), + new File(["b"], "b.png", { type: "image/png" }), + new File(["c"], "c.gif", { type: "image/gif" }), + ]; + const dropEvent = createMockDropEvent(files); + dropzone.dispatchEvent(dropEvent); + + await new Promise((resolve) => setTimeout(resolve, 400)); + + // One uploadComplete event per successful file + expect(eventListener).toHaveBeenCalledTimes(3); + expect((eventListener.mock.calls[0][0] as CustomEvent).detail.url).toBe("https://example.com/a.jpg"); + expect((eventListener.mock.calls[1][0] as CustomEvent).detail.url).toBe("https://example.com/b.png"); + expect((eventListener.mock.calls[2][0] as CustomEvent).detail.url).toBe("https://example.com/c.gif"); + + // 5 fetch calls: initial list + 3 uploads + re-fetch + expect(mockFetch).toHaveBeenCalledTimes(5); + }); + + it("shows partial failure message when some uploads fail", async () => { + const mockFetch = vi + .fn() + // Initial asset list fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ urls: [] }), + }) + // Upload file 1 - success + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, url: "https://example.com/a.jpg" }), + }) + // Upload file 2 - failure + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: false, error: "S3 error" }), + }) + // Upload file 3 - success + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, url: "https://example.com/c.gif" }), + }) + // Re-fetch after uploads (called because at least one succeeded) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + urls: ["https://example.com/a.jpg", "https://example.com/c.gif"], + }), + }); + vi.stubGlobal("fetch", mockFetch); + + await createControllerElementWithUpload(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const dropzone = document.querySelector('[data-remote-asset-browser-target="dropzone"]') as HTMLElement; + const files = [ + new File(["a"], "a.jpg", { type: "image/jpeg" }), + new File(["b"], "b.png", { type: "image/png" }), + new File(["c"], "c.gif", { type: "image/gif" }), + ]; + const dropEvent = createMockDropEvent(files); + dropzone.dispatchEvent(dropEvent); + + await new Promise((resolve) => setTimeout(resolve, 400)); + + // Error message should be shown with partial failure count + const errorEl = document.querySelector('[data-remote-asset-browser-target="uploadError"]') as HTMLElement; + expect(errorEl.classList.contains("hidden")).toBe(false); + + const errorText = errorEl.querySelector("[data-error-text]") as HTMLElement; + expect(errorText.textContent).toBe("1 of 3 uploads failed."); + }); + + it("opens file dialog when openFileDialog is called", async () => { + const html = ` +
+
Click to upload
+ + + + +
Loading...
+ + +
+
+ `; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ urls: [] }), + }); + vi.stubGlobal("fetch", mockFetch); + + document.body.innerHTML = html; + await new Promise((resolve) => setTimeout(resolve, 100)); + + const fileInput = document.querySelector( + '[data-remote-asset-browser-target="fileInput"]', + ) as HTMLInputElement; + const clickSpy = vi.spyOn(fileInput, "click"); + + // Click the dropzone (which triggers openFileDialog action) + const dropzone = document.querySelector('[data-remote-asset-browser-target="dropzone"]') as HTMLElement; + dropzone.click(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(clickSpy).toHaveBeenCalled(); + }); }); }); diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index a277727..635c4a6 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -436,8 +436,8 @@ remote_content_assets: browser_search_placeholder: "Assets suchen..." browser_add_to_chat: "Zum Chat hinzufügen" browser_open_in_new_tab: "In neuem Tab öffnen" - browser_upload_dropzone: "Bild hier ablegen zum Hochladen" - browser_upload_hint: "JPEG, PNG, GIF, WebP, SVG, AVIF (max. 10MB)" + browser_upload_dropzone: "Bilder hierher ziehen oder klicken" + browser_upload_hint: "Mehrere Dateien möglich. JPEG, PNG, GIF, WebP, SVG, AVIF (max. 10MB)" browser_uploading: "Wird hochgeladen..." browser_upload_success: "Upload abgeschlossen. Asset-Liste wird aktualisiert..." browser_upload_error: "Upload fehlgeschlagen. Bitte erneut versuchen." diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 503c158..18b5a03 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -436,8 +436,8 @@ remote_content_assets: browser_search_placeholder: "Search assets..." browser_add_to_chat: "Add to chat" browser_open_in_new_tab: "Open in new tab" - browser_upload_dropzone: "Drop image here to upload" - browser_upload_hint: "JPEG, PNG, GIF, WebP, SVG, AVIF (max 10MB)" + browser_upload_dropzone: "Drop images here or click to upload" + browser_upload_hint: "Multiple files supported. JPEG, PNG, GIF, WebP, SVG, AVIF (max 10MB)" browser_uploading: "Uploading..." browser_upload_success: "Upload complete. Refreshing asset list..." browser_upload_error: "Upload failed. Please try again."