diff --git a/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php b/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php new file mode 100644 index 0000000..82b172b --- /dev/null +++ b/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php @@ -0,0 +1,192 @@ + '[a-f0-9-]{36}'] + )] + public function create( + string $conversationId, + Request $request, + #[CurrentUser] UserInterface $user, + ): JsonResponse { + $context = $this->resolveEditableWorkspace($conversationId, $request, $user); + + $text = $this->promptSuggestionsService->getRequestText($request); + if ($text === null) { + return $this->json(['error' => 'Missing or empty "text" field.'], Response::HTTP_BAD_REQUEST); + } + + try { + $suggestions = $this->promptSuggestionsService->addSuggestion($context->workspacePath, $text); + $this->workspaceMgmtFacade->commitAndPush( + $context->workspaceId, + 'Add prompt suggestion', + $context->authorEmail, + $conversationId, + ); + } catch (InvalidArgumentException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); + } catch (Throwable $e) { + return $this->json(['error' => 'Failed to save changes: ' . $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->json(['suggestions' => $suggestions]); + } + + #[Route( + path: '/conversation/{conversationId}/prompt-suggestions/{index}', + name: 'chat_based_content_editor.presentation.prompt_suggestions.update', + methods: [Request::METHOD_PUT], + requirements: ['conversationId' => '[a-f0-9-]{36}', 'index' => '\d+'] + )] + public function update( + string $conversationId, + int $index, + Request $request, + #[CurrentUser] UserInterface $user, + ): JsonResponse { + $context = $this->resolveEditableWorkspace($conversationId, $request, $user); + + $text = $this->promptSuggestionsService->getRequestText($request); + if ($text === null) { + return $this->json(['error' => 'Missing or empty "text" field.'], Response::HTTP_BAD_REQUEST); + } + + try { + $suggestions = $this->promptSuggestionsService->updateSuggestion($context->workspacePath, $index, $text); + + $this->workspaceMgmtFacade->commitAndPush( + $context->workspaceId, + 'Update prompt suggestion', + $context->authorEmail, + $conversationId, + ); + } catch (InvalidArgumentException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); + } catch (OutOfRangeException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } catch (Throwable $e) { + return $this->json(['error' => 'Failed to save changes: ' . $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->json(['suggestions' => $suggestions]); + } + + #[Route( + path: '/conversation/{conversationId}/prompt-suggestions/{index}', + name: 'chat_based_content_editor.presentation.prompt_suggestions.delete', + methods: [Request::METHOD_DELETE], + requirements: ['conversationId' => '[a-f0-9-]{36}', 'index' => '\d+'] + )] + public function delete( + string $conversationId, + int $index, + Request $request, + #[CurrentUser] UserInterface $user, + ): JsonResponse { + $context = $this->resolveEditableWorkspace($conversationId, $request, $user); + + try { + $suggestions = $this->promptSuggestionsService->deleteSuggestion($context->workspacePath, $index); + + $this->workspaceMgmtFacade->commitAndPush( + $context->workspaceId, + 'Remove prompt suggestion', + $context->authorEmail, + $conversationId, + ); + } catch (OutOfRangeException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } catch (Throwable $e) { + return $this->json(['error' => 'Failed to save changes: ' . $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->json(['suggestions' => $suggestions]); + } + + /** + * Resolve conversation → workspace and enforce authorization + CSRF. + * Returns a context DTO with workspace path, workspace ID, and author email. + * + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + */ + private function resolveEditableWorkspace( + string $conversationId, + Request $request, + UserInterface $user, + ): EditableWorkspaceContextDto { + if (!$this->isCsrfTokenValid('prompt-suggestions', $request->headers->get('X-CSRF-Token', ''))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $conversation = $this->entityManager->find(Conversation::class, $conversationId); + if ($conversation === null) { + throw $this->createNotFoundException('Conversation not found.'); + } + + $accountInfo = $this->accountFacade->getAccountInfoByEmail($user->getUserIdentifier()); + if ($accountInfo === null) { + throw new RuntimeException('Account not found for authenticated user'); + } + + if ($conversation->getUserId() !== $accountInfo->id) { + throw $this->createAccessDeniedException('Only the conversation owner can manage prompt suggestions.'); + } + + if ($conversation->getStatus() !== ConversationStatus::ONGOING) { + throw $this->createAccessDeniedException('Cannot modify prompt suggestions for a finished conversation.'); + } + + $workspace = $this->workspaceMgmtFacade->getWorkspaceById($conversation->getWorkspaceId()); + if ($workspace === null) { + throw $this->createNotFoundException('Workspace not found.'); + } + + return new EditableWorkspaceContextDto( + $workspace->workspacePath, + $workspace->id, + $accountInfo->email, + ); + } +} diff --git a/src/ChatBasedContentEditor/Presentation/Dto/EditableWorkspaceContextDto.php b/src/ChatBasedContentEditor/Presentation/Dto/EditableWorkspaceContextDto.php new file mode 100644 index 0000000..2382f2b --- /dev/null +++ b/src/ChatBasedContentEditor/Presentation/Dto/EditableWorkspaceContextDto.php @@ -0,0 +1,19 @@ + { - button.classList.remove("hidden"); + const row = button.closest("[data-index]") as HTMLElement | null; + if (row) { + row.classList.remove("hidden"); + } }); - // Hide expand button, show collapse button if (this.hasExpandButtonTarget) { this.expandButtonTarget.classList.add("hidden"); } @@ -71,14 +131,15 @@ export default class extends Controller { * Hide suggestions beyond maxVisible and toggle expand/collapse buttons. */ collapse(): void { - // Hide suggestions beyond maxVisible this.suggestionTargets.forEach((button, index) => { if (index >= this.maxVisibleValue) { - button.classList.add("hidden"); + const row = button.closest("[data-index]") as HTMLElement | null; + if (row) { + row.classList.add("hidden"); + } } }); - // Show expand button, hide collapse button if (this.hasExpandButtonTarget) { this.expandButtonTarget.classList.remove("hidden"); } @@ -86,4 +147,276 @@ export default class extends Controller { this.collapseButtonTarget.classList.add("hidden"); } } + + // ─── Add / Edit modal ─────────────────────────────────────── + + /** + * Prevent Enter from inserting a newline in the form textarea. + * Newlines are forbidden because prompts are stored one-per-line. + */ + handleFormKeydown(event: KeyboardEvent): void { + if (event.key === "Enter") { + event.preventDefault(); + } + } + + /** + * Open the form modal in "add" mode (empty input). + */ + showAddModal(): void { + this.editIndex = null; + this.openFormModal("", this.addTitleValue); + } + + /** + * Open the form modal in "edit" mode (prefilled with the suggestion text). + */ + showEditModal(event: Event): void { + const button = event.currentTarget as HTMLButtonElement; + const index = parseInt(button.dataset.index || "0", 10); + const text = button.dataset.text || ""; + + this.editIndex = index; + this.openFormModal(text, this.editTitleValue); + } + + /** + * Close the form modal without saving. + */ + hideFormModal(): void { + if (this.hasFormModalTarget) { + this.formModalTarget.classList.add("hidden"); + } + } + + /** + * Submit the form modal: POST for add, PUT for edit. + */ + async submitForm(): Promise { + if (!this.hasFormInputTarget) { + return; + } + + const text = this.formInputTarget.value.trim(); + if (text === "") { + return; + } + + let url: string; + let method: string; + + if (this.editIndex === null) { + url = this.createUrlValue; + method = "POST"; + } else { + url = this.updateUrlTemplateValue.replace("99999", String(this.editIndex)); + method = "PUT"; + } + + const suggestions = await this.sendRequest(url, method, { text }); + if (suggestions !== null) { + this.refreshSuggestionsList(suggestions); + } + + this.hideFormModal(); + } + + // ─── Delete confirmation modal ────────────────────────────── + + /** + * Open the delete confirmation modal. + */ + confirmDelete(event: Event): void { + const button = event.currentTarget as HTMLButtonElement; + this.deleteIndex = parseInt(button.dataset.index || "0", 10); + + if (this.hasDeleteModalTarget) { + this.deleteModalTarget.classList.remove("hidden"); + } + } + + /** + * Close the delete confirmation modal without deleting. + */ + cancelDelete(): void { + this.deleteIndex = null; + if (this.hasDeleteModalTarget) { + this.deleteModalTarget.classList.add("hidden"); + } + } + + /** + * Execute the deletion after confirmation. + */ + async executeDelete(): Promise { + if (this.deleteIndex == null) { + return; + } + + const url = this.deleteUrlTemplateValue.replace("99999", String(this.deleteIndex)); + const suggestions = await this.sendRequest(url, "DELETE"); + + if (suggestions !== null) { + this.refreshSuggestionsList(suggestions); + } + + this.cancelDelete(); + } + + // ─── Private helpers ──────────────────────────────────────── + + private openFormModal(text: string, title: string): void { + if (!this.hasFormModalTarget || !this.hasFormInputTarget) { + return; + } + + if (this.hasFormTitleTarget) { + this.formTitleTarget.textContent = title; + } + + this.formInputTarget.value = text; + this.formInputTarget.placeholder = this.placeholderValue; + this.formModalTarget.classList.remove("hidden"); + + // Focus the textarea after it becomes visible + requestAnimationFrame(() => { + this.formInputTarget.focus(); + }); + } + + /** + * Send a JSON request to the API and return the updated suggestions list. + * Returns null if the request failed. + */ + private async sendRequest(url: string, method: string, body?: Record): Promise { + try { + const options: RequestInit = { + method, + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-Token": this.csrfTokenValue, + }, + }; + + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(url, options); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { + suggestions?: string[]; + }; + + return data.suggestions ?? null; + } catch { + return null; + } + } + + /** + * Rebuild the suggestions list DOM from the server response. + */ + refreshSuggestionsList(suggestions: string[]): void { + if (!this.hasSuggestionListTarget) { + return; + } + + const container = this.suggestionListTarget; + container.innerHTML = ""; + + suggestions.forEach((text, index) => { + const row = document.createElement("div"); + row.className = "group flex items-start gap-1"; + row.dataset.index = String(index); + + const button = document.createElement("button"); + button.type = "button"; + button.dataset.text = text; + button.dataset.promptSuggestionsTarget = "suggestion"; + button.dataset.action = [ + "click->prompt-suggestions#insert", + "mouseenter->prompt-suggestions#hoverStart", + "mouseleave->prompt-suggestions#hoverEnd", + ].join(" "); + button.className = + "prompt-suggestion flex-1 px-3 py-1.5 text-xs border border-dark-300 dark:border-dark-600 text-dark-600 dark:text-dark-400 hover:bg-dark-100 dark:hover:bg-dark-700 hover:text-dark-900 dark:hover:text-dark-100 cursor-pointer"; + button.textContent = text; + + const actions = document.createElement("div"); + actions.className = + "flex-shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"; + + if (index >= this.maxVisibleValue) { + row.classList.add("hidden"); + } + + // Edit button + const editBtn = document.createElement("button"); + editBtn.type = "button"; + editBtn.dataset.action = "click->prompt-suggestions#showEditModal"; + editBtn.dataset.index = String(index); + editBtn.dataset.text = text; + editBtn.className = + "p-1 text-dark-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"; + editBtn.innerHTML = + ''; + + // Delete button + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.dataset.action = "click->prompt-suggestions#confirmDelete"; + deleteBtn.dataset.index = String(index); + deleteBtn.className = "p-1 text-dark-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"; + deleteBtn.innerHTML = + ''; + + actions.appendChild(editBtn); + actions.appendChild(deleteBtn); + + row.appendChild(button); + row.appendChild(actions); + + container.appendChild(row); + }); + + // Update expand/collapse buttons based on the new suggestion count + this.updateExpandCollapseState(suggestions.length); + } + + /** + * Show/hide and update the expand/collapse buttons based on the current suggestion count. + * Resets to collapsed state so new items beyond maxVisible are hidden by default. + */ + updateExpandCollapseState(totalCount: number): void { + if (!this.hasExpandCollapseWrapperTarget) { + return; + } + + const hiddenCount = totalCount - this.maxVisibleValue; + + if (hiddenCount > 0) { + this.expandCollapseWrapperTarget.classList.remove("hidden"); + + // Reset to collapsed state + if (this.hasExpandButtonTarget) { + this.expandButtonTarget.classList.remove("hidden"); + this.expandButtonTarget.textContent = this.showMoreTemplateValue.replace( + "{count}", + String(hiddenCount), + ); + } + if (this.hasCollapseButtonTarget) { + this.collapseButtonTarget.classList.add("hidden"); + this.collapseButtonTarget.textContent = this.showLessLabelValue; + } + } else { + this.expandCollapseWrapperTarget.classList.add("hidden"); + } + } } diff --git a/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig b/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig new file mode 100644 index 0000000..0bde924 --- /dev/null +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig @@ -0,0 +1,134 @@ +{# Prompt suggestions from .sitebuilder/prompt-suggestions.md #} +
+ {# Headline with add button #} +
+

{{ 'editor.prompt_suggestions_headline'|trans }}

+ +
+ + {# Suggestions list #} +
+ {% for suggestion in promptSuggestions %} +
+ +
+ + +
+
+ {% endfor %} +
+ +
+ + +
+ + {# Shared modal for Add / Edit #} + + + {# Delete confirmation modal #} + +
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 cc9f6b3..9f7fbdc 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig @@ -258,42 +258,7 @@ - {# Prompt suggestions from .sitebuilder/prompt-suggestions.md #} - {% if promptSuggestions is not empty %} -
-

{{ 'editor.prompt_suggestions_headline'|trans }}

-
- {% for suggestion in promptSuggestions %} - - {% endfor %} -
- {% if promptSuggestions|length > 3 %} -
- - -
- {% endif %} -
- {% endif %} + {% include '@chat_based_content_editor.presentation/_prompt_suggestions.twig' %} {% else %}

diff --git a/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php b/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php index 8fc3f60..c036267 100644 --- a/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php +++ b/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php @@ -4,17 +4,13 @@ namespace App\ChatBasedContentEditor\Presentation\Service; -use function array_filter; -use function array_map; -use function array_values; -use function explode; -use function file_exists; -use function file_get_contents; -use function is_dir; -use function trim; +use InvalidArgumentException; +use OutOfRangeException; +use RuntimeException; +use Symfony\Component\HttpFoundation\Request; /** - * Reads and parses prompt suggestions from .sitebuilder/prompt-suggestions.md. + * Reads, creates, updates, and deletes prompt suggestions in .sitebuilder/prompt-suggestions.md. */ final readonly class PromptSuggestionsService { @@ -58,4 +54,112 @@ public function getSuggestions(?string $workspacePath): array ) ); } + + /** + * Add a new prompt suggestion to the workspace's suggestions file. + * Creates the file and directory if they don't exist. + * + * @return list Updated list of suggestions + */ + public function addSuggestion(string $workspacePath, string $text): array + { + $text = trim($text); + if ($text === '') { + throw new InvalidArgumentException('Suggestion text must not be empty.'); + } + + $suggestions = $this->getSuggestions($workspacePath); + array_unshift($suggestions, $text); + + $this->saveSuggestions($workspacePath, $suggestions); + + return $suggestions; + } + + /** + * Update an existing prompt suggestion at the given index. + * + * @return list Updated list of suggestions + */ + public function updateSuggestion(string $workspacePath, int $index, string $text): array + { + $text = trim($text); + if ($text === '') { + throw new InvalidArgumentException('Suggestion text must not be empty.'); + } + + $suggestions = $this->getSuggestions($workspacePath); + + if ($index < 0 || $index >= count($suggestions)) { + throw new OutOfRangeException('Suggestion index ' . $index . ' is out of range.'); + } + + $suggestions[$index] = $text; + $suggestions = array_values($suggestions); + + $this->saveSuggestions($workspacePath, $suggestions); + + return $suggestions; + } + + /** + * Delete a prompt suggestion at the given index. + * + * @return list Updated list of suggestions + */ + public function deleteSuggestion(string $workspacePath, int $index): array + { + $suggestions = $this->getSuggestions($workspacePath); + + if ($index < 0 || $index >= count($suggestions)) { + throw new OutOfRangeException('Suggestion index ' . $index . ' is out of range.'); + } + + array_splice($suggestions, $index, 1); + + $this->saveSuggestions($workspacePath, $suggestions); + + return $suggestions; + } + + /** + * Write the suggestions list back to the file. + * + * @param list $suggestions + */ + private function saveSuggestions(string $workspacePath, array $suggestions): void + { + $filePath = $workspacePath . '/' . self::SUGGESTIONS_FILE_PATH; + $dir = dirname($filePath); + + if (!is_dir($dir) && !mkdir($dir, 0755, true)) { + throw new RuntimeException('Could not create directory: ' . $dir); + } + + $content = implode("\n", $suggestions) . "\n"; + + if (file_put_contents($filePath, $content) === false) { + throw new RuntimeException('Could not write suggestions file: ' . $filePath); + } + } + + public function getRequestText(Request $request): ?string + { + $content = $request->getContent(); + if ($content === '') { + return null; + } + + $data = json_decode($content, true); + if ( + !is_array($data) + || !array_key_exists('text', $data) + || !is_string($data['text']) + || trim($data['text']) === '' + ) { + return null; + } + + return $data['text']; + } } diff --git a/tests/Unit/ChatBasedContentEditor/PromptSuggestionsControllerTest.php b/tests/Unit/ChatBasedContentEditor/PromptSuggestionsControllerTest.php new file mode 100644 index 0000000..7d3a798 --- /dev/null +++ b/tests/Unit/ChatBasedContentEditor/PromptSuggestionsControllerTest.php @@ -0,0 +1,345 @@ +workspacePath = sys_get_temp_dir() . '/prompt-suggestions-test-' . uniqid(); + mkdir($this->workspacePath . '/.sitebuilder', 0755, true); + file_put_contents( + $this->workspacePath . '/.sitebuilder/prompt-suggestions.md', + "Existing suggestion\n", + ); + + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->accountFacade = $this->createMock(AccountFacadeInterface::class); + $this->workspaceMgmtFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); + + // Use the real PromptSuggestionsService (it is final readonly, cannot be mocked) + $promptSuggestionsService = new PromptSuggestionsService(); + + $this->controller = new PromptSuggestionsController( + $this->entityManager, + $this->accountFacade, + $this->workspaceMgmtFacade, + $promptSuggestionsService, + ); + + // Set up container mock for CSRF validation and json() method + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->method('isTokenValid') + ->willReturn(true); + + $container = $this->createMock(ContainerInterface::class); + $container->method('has') + ->willReturnCallback(static function (string $id): bool { + return $id === 'security.csrf.token_manager'; + }); + $container->method('get') + ->willReturnCallback(static function (string $id) use ($csrfTokenManager): ?object { + if ($id === 'security.csrf.token_manager') { + return $csrfTokenManager; + } + + return null; + }); + + $this->controller->setContainer($container); + + // Set up default mocks for the happy path + $conversation = $this->createConversation( + self::CONVERSATION_ID, + self::WORKSPACE_ID, + self::USER_ID, + ConversationStatus::ONGOING, + ); + $this->entityManager->method('find') + ->with(Conversation::class, self::CONVERSATION_ID) + ->willReturn($conversation); + + $this->accountFacade->method('getAccountInfoByEmail') + ->with(self::USER_EMAIL) + ->willReturn(new AccountInfoDto( + self::USER_ID, + self::USER_EMAIL, + ['ROLE_USER'], + DateAndTimeService::getDateTimeImmutable(), + )); + + $this->workspaceMgmtFacade->method('getWorkspaceById') + ->with(self::WORKSPACE_ID) + ->willReturn(new WorkspaceInfoDto( + self::WORKSPACE_ID, + 'project-1', + 'Test Project', + WorkspaceStatus::IN_CONVERSATION, + 'main', + $this->workspacePath, + null, + null, + )); + } + + protected function tearDown(): void + { + // Clean up temp files + $suggestionsFile = $this->workspacePath . '/.sitebuilder/prompt-suggestions.md'; + if (file_exists($suggestionsFile)) { + unlink($suggestionsFile); + } + + $sitebuilderDir = $this->workspacePath . '/.sitebuilder'; + if (is_dir($sitebuilderDir)) { + rmdir($sitebuilderDir); + } + + if (is_dir($this->workspacePath)) { + rmdir($this->workspacePath); + } + } + + // --------------------------------------------------------------- + // Happy Path — commitAndPush is called with correct parameters + // --------------------------------------------------------------- + + public function testCreateCallsCommitAndPushWithCorrectParameters(): void + { + $this->workspaceMgmtFacade->expects($this->once()) + ->method('commitAndPush') + ->with(self::WORKSPACE_ID, 'Add prompt suggestion', self::USER_EMAIL, self::CONVERSATION_ID); + + $response = $this->controller->create( + self::CONVERSATION_ID, + $this->createJsonRequest(Request::METHOD_POST, ['text' => 'New suggestion']), + $this->createUser(), + ); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + + $data = $this->decodeResponse($response); + self::assertSame(['New suggestion', 'Existing suggestion'], $data['suggestions']); + } + + public function testUpdateCallsCommitAndPushWithCorrectParameters(): void + { + $this->workspaceMgmtFacade->expects($this->once()) + ->method('commitAndPush') + ->with(self::WORKSPACE_ID, 'Update prompt suggestion', self::USER_EMAIL, self::CONVERSATION_ID); + + $response = $this->controller->update( + self::CONVERSATION_ID, + 0, + $this->createJsonRequest(Request::METHOD_PUT, ['text' => 'Updated text']), + $this->createUser(), + ); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + + $data = $this->decodeResponse($response); + self::assertSame(['Updated text'], $data['suggestions']); + } + + public function testDeleteCallsCommitAndPushWithCorrectParameters(): void + { + $this->workspaceMgmtFacade->expects($this->once()) + ->method('commitAndPush') + ->with(self::WORKSPACE_ID, 'Remove prompt suggestion', self::USER_EMAIL, self::CONVERSATION_ID); + + $response = $this->controller->delete( + self::CONVERSATION_ID, + 0, + $this->createDeleteRequest(), + $this->createUser(), + ); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + + $data = $this->decodeResponse($response); + self::assertSame([], $data['suggestions']); + } + + // --------------------------------------------------------------- + // Error Handling — commitAndPush failures return HTTP 500 + // --------------------------------------------------------------- + + public function testCreateReturns500WhenCommitAndPushFails(): void + { + $this->workspaceMgmtFacade->method('commitAndPush') + ->willThrowException(new RuntimeException('Git push failed')); + + $response = $this->controller->create( + self::CONVERSATION_ID, + $this->createJsonRequest(Request::METHOD_POST, ['text' => 'New suggestion']), + $this->createUser(), + ); + + self::assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + + $data = $this->decodeResponse($response); + self::assertIsString($data['error']); + self::assertStringContainsString('Git push failed', $data['error']); + } + + public function testUpdateReturns500WhenCommitAndPushFails(): void + { + $this->workspaceMgmtFacade->method('commitAndPush') + ->willThrowException(new RuntimeException('Network error')); + + $response = $this->controller->update( + self::CONVERSATION_ID, + 0, + $this->createJsonRequest(Request::METHOD_PUT, ['text' => 'Updated']), + $this->createUser(), + ); + + self::assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + + $data = $this->decodeResponse($response); + self::assertIsString($data['error']); + self::assertStringContainsString('Network error', $data['error']); + } + + public function testDeleteReturns500WhenCommitAndPushFails(): void + { + $this->workspaceMgmtFacade->method('commitAndPush') + ->willThrowException(new RuntimeException('Permission denied')); + + $response = $this->controller->delete( + self::CONVERSATION_ID, + 0, + $this->createDeleteRequest(), + $this->createUser(), + ); + + self::assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + + $data = $this->decodeResponse($response); + self::assertIsString($data['error']); + self::assertStringContainsString('Permission denied', $data['error']); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * @return array + */ + private function decodeResponse(Response $response): array + { + $content = $response->getContent(); + self::assertIsString($content); + + /** @var array $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + return $data; + } + + private function createConversation( + string $id, + string $workspaceId, + string $userId, + ConversationStatus $status, + ): Conversation { + $conversation = new Conversation($workspaceId, $userId, '/path/to/workspace'); + $conversation->setStatus($status); + + $reflection = new ReflectionClass($conversation); + $idProperty = $reflection->getProperty('id'); + $idProperty->setValue($conversation, $id); + + return $conversation; + } + + private function createUser(): UserInterface + { + $user = $this->createMock(UserInterface::class); + $user->method('getUserIdentifier') + ->willReturn(self::USER_EMAIL); + + return $user; + } + + /** + * @param array $body + */ + private function createJsonRequest(string $method, array $body): Request + { + return new Request( + [], + [], + [], + [], + [], + [ + 'REQUEST_METHOD' => $method, + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_CSRF_Token' => 'valid-token', + ], + json_encode($body, JSON_THROW_ON_ERROR), + ); + } + + private function createDeleteRequest(): Request + { + return new Request( + [], + [], + [], + [], + [], + [ + 'REQUEST_METHOD' => Request::METHOD_DELETE, + 'HTTP_X_CSRF_Token' => 'valid-token', + ], + ); + } +} diff --git a/tests/Unit/ChatBasedContentEditor/PromptSuggestionsServiceTest.php b/tests/Unit/ChatBasedContentEditor/PromptSuggestionsServiceTest.php index ee37525..c7de0df 100644 --- a/tests/Unit/ChatBasedContentEditor/PromptSuggestionsServiceTest.php +++ b/tests/Unit/ChatBasedContentEditor/PromptSuggestionsServiceTest.php @@ -5,6 +5,8 @@ namespace Tests\Unit\ChatBasedContentEditor; use App\ChatBasedContentEditor\Presentation\Service\PromptSuggestionsService; +use InvalidArgumentException; +use OutOfRangeException; use PHPUnit\Framework\TestCase; final class PromptSuggestionsServiceTest extends TestCase @@ -40,10 +42,19 @@ private function recursiveDelete(string $path): void private function createSuggestionsFile(string $content): void { $dir = $this->tempDir . '/.sitebuilder'; - mkdir($dir, 0755, true); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } file_put_contents($dir . '/prompt-suggestions.md', $content); } + private function readSuggestionsFile(): string + { + return file_get_contents($this->tempDir . '/.sitebuilder/prompt-suggestions.md') ?: ''; + } + + // ─── getSuggestions ───────────────────────────────────────────── + public function testReturnsEmptyArrayWhenWorkspacePathIsNull(): void { $service = new PromptSuggestionsService(); @@ -146,4 +157,187 @@ public function testHandlesTrailingNewline(): void self::assertSame(['First', 'Second'], $result); } + + // ─── addSuggestion ───────────────────────────────────────────── + + public function testAddSuggestionPrependsToExistingFile(): void + { + $this->createSuggestionsFile("First\nSecond\n"); + + $service = new PromptSuggestionsService(); + $result = $service->addSuggestion($this->tempDir, 'Third'); + + self::assertSame(['Third', 'First', 'Second'], $result); + self::assertSame("Third\nFirst\nSecond\n", $this->readSuggestionsFile()); + } + + public function testAddSuggestionCreatesFileIfNotExists(): void + { + $service = new PromptSuggestionsService(); + $result = $service->addSuggestion($this->tempDir, 'Brand new suggestion'); + + self::assertSame(['Brand new suggestion'], $result); + self::assertSame("Brand new suggestion\n", $this->readSuggestionsFile()); + } + + public function testAddSuggestionTrimsText(): void + { + $service = new PromptSuggestionsService(); + $result = $service->addSuggestion($this->tempDir, ' Trimmed suggestion '); + + self::assertSame(['Trimmed suggestion'], $result); + } + + public function testAddSuggestionThrowsOnEmptyText(): void + { + $service = new PromptSuggestionsService(); + + $this->expectException(InvalidArgumentException::class); + $service->addSuggestion($this->tempDir, ''); + } + + public function testAddSuggestionThrowsOnWhitespaceOnlyText(): void + { + $service = new PromptSuggestionsService(); + + $this->expectException(InvalidArgumentException::class); + $service->addSuggestion($this->tempDir, ' '); + } + + // ─── updateSuggestion ────────────────────────────────────────── + + public function testUpdateSuggestionReplacesAtIndex(): void + { + $this->createSuggestionsFile("First\nSecond\nThird\n"); + + $service = new PromptSuggestionsService(); + $result = $service->updateSuggestion($this->tempDir, 1, 'Updated second'); + + self::assertSame(['First', 'Updated second', 'Third'], $result); + self::assertSame("First\nUpdated second\nThird\n", $this->readSuggestionsFile()); + } + + public function testUpdateSuggestionReplacesFirstItem(): void + { + $this->createSuggestionsFile("First\nSecond\n"); + + $service = new PromptSuggestionsService(); + $result = $service->updateSuggestion($this->tempDir, 0, 'New first'); + + self::assertSame(['New first', 'Second'], $result); + } + + public function testUpdateSuggestionReplacesLastItem(): void + { + $this->createSuggestionsFile("First\nSecond\nThird\n"); + + $service = new PromptSuggestionsService(); + $result = $service->updateSuggestion($this->tempDir, 2, 'New third'); + + self::assertSame(['First', 'Second', 'New third'], $result); + } + + public function testUpdateSuggestionTrimsText(): void + { + $this->createSuggestionsFile("First\nSecond\n"); + + $service = new PromptSuggestionsService(); + $result = $service->updateSuggestion($this->tempDir, 0, ' Trimmed '); + + self::assertSame(['Trimmed', 'Second'], $result); + } + + public function testUpdateSuggestionThrowsOnEmptyText(): void + { + $this->createSuggestionsFile("First\nSecond\n"); + + $service = new PromptSuggestionsService(); + + $this->expectException(InvalidArgumentException::class); + $service->updateSuggestion($this->tempDir, 0, ''); + } + + public function testUpdateSuggestionThrowsOnNegativeIndex(): void + { + $this->createSuggestionsFile("First\nSecond\n"); + + $service = new PromptSuggestionsService(); + + $this->expectException(OutOfRangeException::class); + $service->updateSuggestion($this->tempDir, -1, 'Text'); + } + + public function testUpdateSuggestionThrowsOnOutOfBoundsIndex(): void + { + $this->createSuggestionsFile("First\nSecond\n"); + + $service = new PromptSuggestionsService(); + + $this->expectException(OutOfRangeException::class); + $service->updateSuggestion($this->tempDir, 5, 'Text'); + } + + // ─── deleteSuggestion ────────────────────────────────────────── + + public function testDeleteSuggestionRemovesAtIndex(): void + { + $this->createSuggestionsFile("First\nSecond\nThird\n"); + + $service = new PromptSuggestionsService(); + $result = $service->deleteSuggestion($this->tempDir, 1); + + self::assertSame(['First', 'Third'], $result); + self::assertSame("First\nThird\n", $this->readSuggestionsFile()); + } + + public function testDeleteSuggestionRemovesFirstItem(): void + { + $this->createSuggestionsFile("First\nSecond\nThird\n"); + + $service = new PromptSuggestionsService(); + $result = $service->deleteSuggestion($this->tempDir, 0); + + self::assertSame(['Second', 'Third'], $result); + } + + public function testDeleteSuggestionRemovesLastItem(): void + { + $this->createSuggestionsFile("First\nSecond\nThird\n"); + + $service = new PromptSuggestionsService(); + $result = $service->deleteSuggestion($this->tempDir, 2); + + self::assertSame(['First', 'Second'], $result); + } + + public function testDeleteSuggestionRemovesOnlyItem(): void + { + $this->createSuggestionsFile("Only one\n"); + + $service = new PromptSuggestionsService(); + $result = $service->deleteSuggestion($this->tempDir, 0); + + self::assertSame([], $result); + self::assertSame("\n", $this->readSuggestionsFile()); + } + + public function testDeleteSuggestionThrowsOnNegativeIndex(): void + { + $this->createSuggestionsFile("First\nSecond\n"); + + $service = new PromptSuggestionsService(); + + $this->expectException(OutOfRangeException::class); + $service->deleteSuggestion($this->tempDir, -1); + } + + public function testDeleteSuggestionThrowsOnOutOfBoundsIndex(): void + { + $this->createSuggestionsFile("First\nSecond\n"); + + $service = new PromptSuggestionsService(); + + $this->expectException(OutOfRangeException::class); + $service->deleteSuggestion($this->tempDir, 5); + } } diff --git a/tests/frontend/unit/ChatBasedContentEditor/prompt_suggestions_controller.test.ts b/tests/frontend/unit/ChatBasedContentEditor/prompt_suggestions_controller.test.ts index 22c366f..f475330 100644 --- a/tests/frontend/unit/ChatBasedContentEditor/prompt_suggestions_controller.test.ts +++ b/tests/frontend/unit/ChatBasedContentEditor/prompt_suggestions_controller.test.ts @@ -1,30 +1,62 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import PromptSuggestionsController from "../../../../src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts"; describe("PromptSuggestionsController", () => { - const createController = (): { + interface ControllerFixture { controller: PromptSuggestionsController; suggestionButtons: HTMLButtonElement[]; expandButton: HTMLButtonElement; collapseButton: HTMLButtonElement; - } => { + expandCollapseWrapper: HTMLElement; + suggestionList: HTMLElement; + formModal: HTMLElement; + formInput: HTMLTextAreaElement; + formTitle: HTMLElement; + deleteModal: HTMLElement; + } + + const createController = (): ControllerFixture => { const controller = Object.create(PromptSuggestionsController.prototype) as PromptSuggestionsController; - // Create suggestion buttons + // Create suggestion buttons wrapped in row divs + const suggestionList = document.createElement("div"); const suggestionButtons: HTMLButtonElement[] = []; for (let i = 0; i < 5; i++) { - const btn = document.createElement("button"); - btn.dataset.text = `Suggestion ${i + 1}`; + const row = document.createElement("div"); + row.dataset.index = String(i); + row.className = "group flex items-start gap-1"; + if (i >= 3) { - btn.classList.add("hidden"); + row.classList.add("hidden"); } + + const btn = document.createElement("button"); + btn.dataset.text = `Suggestion ${i + 1}`; suggestionButtons.push(btn); + + const actions = document.createElement("div"); + actions.className = "flex-shrink-0"; + + row.appendChild(btn); + row.appendChild(actions); + suggestionList.appendChild(row); } - // Create expand/collapse buttons + // Create expand/collapse buttons wrapped in a container + const expandCollapseWrapper = document.createElement("div"); const expandButton = document.createElement("button"); const collapseButton = document.createElement("button"); collapseButton.classList.add("hidden"); + expandCollapseWrapper.appendChild(expandButton); + expandCollapseWrapper.appendChild(collapseButton); + + // Create modal elements + const formModal = document.createElement("div"); + formModal.classList.add("hidden"); + const formInput = document.createElement("textarea"); + const formTitle = document.createElement("h3"); + const deleteModal = document.createElement("div"); + deleteModal.classList.add("hidden"); // Set up controller state const state = controller as unknown as { @@ -33,8 +65,29 @@ describe("PromptSuggestionsController", () => { expandButtonTarget: HTMLButtonElement; hasCollapseButtonTarget: boolean; collapseButtonTarget: HTMLButtonElement; + hasExpandCollapseWrapperTarget: boolean; + expandCollapseWrapperTarget: HTMLElement; maxVisibleValue: number; dispatch: (name: string, options: unknown) => void; + hasSuggestionListTarget: boolean; + suggestionListTarget: HTMLElement; + hasFormModalTarget: boolean; + formModalTarget: HTMLElement; + hasFormInputTarget: boolean; + formInputTarget: HTMLTextAreaElement; + hasFormTitleTarget: boolean; + formTitleTarget: HTMLElement; + hasDeleteModalTarget: boolean; + deleteModalTarget: HTMLElement; + createUrlValue: string; + updateUrlTemplateValue: string; + deleteUrlTemplateValue: string; + csrfTokenValue: string; + addTitleValue: string; + editTitleValue: string; + placeholderValue: string; + showMoreTemplateValue: string; + showLessLabelValue: string; }; state.suggestionTargets = suggestionButtons; @@ -42,10 +95,42 @@ describe("PromptSuggestionsController", () => { state.expandButtonTarget = expandButton; state.hasCollapseButtonTarget = true; state.collapseButtonTarget = collapseButton; + state.hasExpandCollapseWrapperTarget = true; + state.expandCollapseWrapperTarget = expandCollapseWrapper; state.maxVisibleValue = 3; state.dispatch = vi.fn(); - - return { controller, suggestionButtons, expandButton, collapseButton }; + state.hasSuggestionListTarget = true; + state.suggestionListTarget = suggestionList; + state.hasFormModalTarget = true; + state.formModalTarget = formModal; + state.hasFormInputTarget = true; + state.formInputTarget = formInput; + state.hasFormTitleTarget = true; + state.formTitleTarget = formTitle; + state.hasDeleteModalTarget = true; + state.deleteModalTarget = deleteModal; + state.createUrlValue = "/conversation/123/prompt-suggestions"; + state.updateUrlTemplateValue = "/conversation/123/prompt-suggestions/99999"; + state.deleteUrlTemplateValue = "/conversation/123/prompt-suggestions/99999"; + state.csrfTokenValue = "test-csrf-token"; + state.addTitleValue = "Add new suggestion"; + state.editTitleValue = "Edit suggestion"; + state.placeholderValue = "Enter suggestion..."; + state.showMoreTemplateValue = "+{count} more"; + state.showLessLabelValue = "Show less"; + + return { + controller, + suggestionButtons, + expandButton, + collapseButton, + expandCollapseWrapper, + suggestionList, + formModal, + formInput, + formTitle, + deleteModal, + }; }; describe("insert", () => { @@ -101,15 +186,18 @@ describe("PromptSuggestionsController", () => { it("shows all hidden suggestions", () => { const { controller, suggestionButtons } = createController(); - // Initially buttons 3,4 are hidden - expect(suggestionButtons[3].classList.contains("hidden")).toBe(true); - expect(suggestionButtons[4].classList.contains("hidden")).toBe(true); + // Initially rows 3,4 are hidden + const row3 = suggestionButtons[3].closest("[data-index]") as HTMLElement; + const row4 = suggestionButtons[4].closest("[data-index]") as HTMLElement; + expect(row3.classList.contains("hidden")).toBe(true); + expect(row4.classList.contains("hidden")).toBe(true); controller.expand(); - // After expand, all should be visible + // After expand, all rows should be visible suggestionButtons.forEach((btn) => { - expect(btn.classList.contains("hidden")).toBe(false); + const row = btn.closest("[data-index]") as HTMLElement; + expect(row.classList.contains("hidden")).toBe(false); }); }); @@ -129,17 +217,21 @@ describe("PromptSuggestionsController", () => { it("hides suggestions beyond maxVisible", () => { const { controller, suggestionButtons } = createController(); - // First expand to show all - suggestionButtons.forEach((btn) => btn.classList.remove("hidden")); + // First expand to show all rows + suggestionButtons.forEach((btn) => { + const row = btn.closest("[data-index]") as HTMLElement; + row.classList.remove("hidden"); + }); controller.collapse(); - // First 3 should be visible, rest hidden - expect(suggestionButtons[0].classList.contains("hidden")).toBe(false); - expect(suggestionButtons[1].classList.contains("hidden")).toBe(false); - expect(suggestionButtons[2].classList.contains("hidden")).toBe(false); - expect(suggestionButtons[3].classList.contains("hidden")).toBe(true); - expect(suggestionButtons[4].classList.contains("hidden")).toBe(true); + // First 3 rows should be visible, rest hidden + const rows = suggestionButtons.map((btn) => btn.closest("[data-index]") as HTMLElement); + expect(rows[0].classList.contains("hidden")).toBe(false); + expect(rows[1].classList.contains("hidden")).toBe(false); + expect(rows[2].classList.contains("hidden")).toBe(false); + expect(rows[3].classList.contains("hidden")).toBe(true); + expect(rows[4].classList.contains("hidden")).toBe(true); }); it("shows expand button and hides collapse button", () => { @@ -178,4 +270,445 @@ describe("PromptSuggestionsController", () => { expect(button.classList.contains("suggestion-expanded")).toBe(false); }); }); + + describe("showAddModal", () => { + it("opens form modal with empty input and add title", () => { + const { controller, formModal, formInput, formTitle } = createController(); + + controller.showAddModal(); + + expect(formModal.classList.contains("hidden")).toBe(false); + expect(formInput.value).toBe(""); + expect(formTitle.textContent).toBe("Add new suggestion"); + }); + + it("sets placeholder on textarea", () => { + const { controller, formInput } = createController(); + + controller.showAddModal(); + + expect(formInput.placeholder).toBe("Enter suggestion..."); + }); + }); + + describe("showEditModal", () => { + it("opens form modal prefilled with suggestion text and edit title", () => { + const { controller, formModal, formInput, formTitle } = createController(); + + const button = document.createElement("button"); + button.dataset.index = "1"; + button.dataset.text = "Existing suggestion"; + const event = { currentTarget: button } as unknown as Event; + + controller.showEditModal(event); + + expect(formModal.classList.contains("hidden")).toBe(false); + expect(formInput.value).toBe("Existing suggestion"); + expect(formTitle.textContent).toBe("Edit suggestion"); + }); + }); + + describe("hideFormModal", () => { + it("hides the form modal", () => { + const { controller, formModal } = createController(); + + // Open the modal first + controller.showAddModal(); + expect(formModal.classList.contains("hidden")).toBe(false); + + controller.hideFormModal(); + + expect(formModal.classList.contains("hidden")).toBe(true); + }); + }); + + describe("handleFormKeydown", () => { + it("prevents default when Enter is pressed", () => { + const { controller } = createController(); + const event = new KeyboardEvent("keydown", { key: "Enter" }); + const spy = vi.spyOn(event, "preventDefault"); + + controller.handleFormKeydown(event); + + expect(spy).toHaveBeenCalled(); + }); + + it("prevents default when Shift+Enter is pressed", () => { + const { controller } = createController(); + const event = new KeyboardEvent("keydown", { + key: "Enter", + shiftKey: true, + }); + const spy = vi.spyOn(event, "preventDefault"); + + controller.handleFormKeydown(event); + + expect(spy).toHaveBeenCalled(); + }); + + it("does not prevent default for other keys", () => { + const { controller } = createController(); + const event = new KeyboardEvent("keydown", { key: "a" }); + const spy = vi.spyOn(event, "preventDefault"); + + controller.handleFormKeydown(event); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe("confirmDelete", () => { + it("opens delete confirmation modal", () => { + const { controller, deleteModal } = createController(); + + const button = document.createElement("button"); + button.dataset.index = "2"; + const event = { currentTarget: button } as unknown as Event; + + controller.confirmDelete(event); + + expect(deleteModal.classList.contains("hidden")).toBe(false); + }); + }); + + describe("cancelDelete", () => { + it("hides delete confirmation modal", () => { + const { controller, deleteModal } = createController(); + + // Open first + const button = document.createElement("button"); + button.dataset.index = "0"; + controller.confirmDelete({ + currentTarget: button, + } as unknown as Event); + expect(deleteModal.classList.contains("hidden")).toBe(false); + + controller.cancelDelete(); + + expect(deleteModal.classList.contains("hidden")).toBe(true); + }); + }); + + describe("submitForm", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends POST request in add mode", async () => { + const { controller, formInput } = createController(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + suggestions: ["Existing", "New suggestion"], + }), + }); + + controller.showAddModal(); + formInput.value = "New suggestion"; + + await controller.submitForm(); + + expect(fetchSpy).toHaveBeenCalledWith( + "/conversation/123/prompt-suggestions", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ text: "New suggestion" }), + }), + ); + }); + + it("sends PUT request in edit mode", async () => { + const { controller, formInput } = createController(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + suggestions: ["Updated suggestion", "Second"], + }), + }); + + const button = document.createElement("button"); + button.dataset.index = "0"; + button.dataset.text = "Original"; + controller.showEditModal({ + currentTarget: button, + } as unknown as Event); + formInput.value = "Updated suggestion"; + + await controller.submitForm(); + + expect(fetchSpy).toHaveBeenCalledWith( + "/conversation/123/prompt-suggestions/0", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ text: "Updated suggestion" }), + }), + ); + }); + + it("does not send request when input is empty", async () => { + const { controller, formInput } = createController(); + + controller.showAddModal(); + formInput.value = " "; + + await controller.submitForm(); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("includes CSRF token in headers", async () => { + const { controller, formInput } = createController(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ suggestions: ["New suggestion"] }), + }); + + controller.showAddModal(); + formInput.value = "New suggestion"; + + await controller.submitForm(); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + "X-CSRF-Token": "test-csrf-token", + }), + }), + ); + }); + + it("hides modal after successful submit", async () => { + const { controller, formModal, formInput } = createController(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ suggestions: ["New suggestion"] }), + }); + + controller.showAddModal(); + formInput.value = "New suggestion"; + + await controller.submitForm(); + + expect(formModal.classList.contains("hidden")).toBe(true); + }); + }); + + describe("executeDelete", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends DELETE request with correct index", async () => { + const { controller } = createController(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ suggestions: ["Remaining suggestion"] }), + }); + + const button = document.createElement("button"); + button.dataset.index = "1"; + controller.confirmDelete({ + currentTarget: button, + } as unknown as Event); + + await controller.executeDelete(); + + expect(fetchSpy).toHaveBeenCalledWith( + "/conversation/123/prompt-suggestions/1", + expect.objectContaining({ method: "DELETE" }), + ); + }); + + it("does not send request when deleteIndex is null", async () => { + const { controller } = createController(); + + await controller.executeDelete(); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("hides delete modal after execution", async () => { + const { controller, deleteModal } = createController(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ suggestions: [] }), + }); + + const button = document.createElement("button"); + button.dataset.index = "0"; + controller.confirmDelete({ + currentTarget: button, + } as unknown as Event); + + await controller.executeDelete(); + + expect(deleteModal.classList.contains("hidden")).toBe(true); + }); + }); + + describe("refreshSuggestionsList", () => { + it("rebuilds the suggestion list from new data", () => { + const { controller, suggestionList } = createController(); + + controller.refreshSuggestionsList(["Alpha", "Beta", "Gamma"]); + + // Use direct children to avoid matching nested elements with data-index + const rows = Array.from(suggestionList.children); + expect(rows.length).toBe(3); + + const buttons = suggestionList.querySelectorAll('[data-prompt-suggestions-target="suggestion"]'); + expect(buttons.length).toBe(3); + expect(buttons[0].textContent).toBe("Alpha"); + expect(buttons[1].textContent).toBe("Beta"); + expect(buttons[2].textContent).toBe("Gamma"); + }); + + it("hides rows beyond maxVisible", () => { + const { controller, suggestionList } = createController(); + + controller.refreshSuggestionsList(["A", "B", "C", "D", "E"]); + + const rows = Array.from(suggestionList.children) as HTMLElement[]; + expect(rows[0].classList.contains("hidden")).toBe(false); + expect(rows[2].classList.contains("hidden")).toBe(false); + expect(rows[3].classList.contains("hidden")).toBe(true); + expect(rows[4].classList.contains("hidden")).toBe(true); + }); + + it("clears existing content before rebuilding", () => { + const { controller, suggestionList } = createController(); + + // Initial state has 5 suggestions + expect(suggestionList.children.length).toBe(5); + + controller.refreshSuggestionsList(["Only one"]); + + expect(suggestionList.children.length).toBe(1); + }); + + it("handles empty suggestions list", () => { + const { controller, suggestionList } = createController(); + + controller.refreshSuggestionsList([]); + + expect(suggestionList.children.length).toBe(0); + }); + + it("sets correct data-index on each row", () => { + const { controller, suggestionList } = createController(); + + controller.refreshSuggestionsList(["First", "Second"]); + + // Use direct children to avoid matching nested elements with data-index + const rows = Array.from(suggestionList.children) as HTMLElement[]; + expect(rows[0].dataset.index).toBe("0"); + expect(rows[1].dataset.index).toBe("1"); + }); + + it("includes edit and delete action buttons per suggestion", () => { + const { controller, suggestionList } = createController(); + + controller.refreshSuggestionsList(["Test"]); + + const editBtn = suggestionList.querySelector('[data-action*="showEditModal"]'); + const deleteBtn = suggestionList.querySelector('[data-action*="confirmDelete"]'); + + expect(editBtn).not.toBeNull(); + expect(deleteBtn).not.toBeNull(); + }); + + it("shows expand/collapse wrapper when suggestions exceed maxVisible", () => { + const { controller, expandCollapseWrapper } = createController(); + + // Start hidden + expandCollapseWrapper.classList.add("hidden"); + + controller.refreshSuggestionsList(["A", "B", "C", "D"]); + + expect(expandCollapseWrapper.classList.contains("hidden")).toBe(false); + }); + + it("hides expand/collapse wrapper when suggestions are within maxVisible", () => { + const { controller, expandCollapseWrapper } = createController(); + + // Start visible (simulating previous > 3 state) + expandCollapseWrapper.classList.remove("hidden"); + + controller.refreshSuggestionsList(["A", "B"]); + + expect(expandCollapseWrapper.classList.contains("hidden")).toBe(true); + }); + + it("hides expand/collapse wrapper when suggestions equal maxVisible", () => { + const { controller, expandCollapseWrapper } = createController(); + + expandCollapseWrapper.classList.remove("hidden"); + + controller.refreshSuggestionsList(["A", "B", "C"]); + + expect(expandCollapseWrapper.classList.contains("hidden")).toBe(true); + }); + + it("updates expand button text with correct hidden count", () => { + const { controller, expandButton } = createController(); + + controller.refreshSuggestionsList(["A", "B", "C", "D", "E"]); + + expect(expandButton.textContent).toBe("+2 more"); + }); + + it("resets to collapsed state after refresh", () => { + const { controller, expandButton, collapseButton } = createController(); + + // Simulate expanded state + expandButton.classList.add("hidden"); + collapseButton.classList.remove("hidden"); + + controller.refreshSuggestionsList(["A", "B", "C", "D"]); + + expect(expandButton.classList.contains("hidden")).toBe(false); + expect(collapseButton.classList.contains("hidden")).toBe(true); + }); + + it("sets collapse button text from showLessLabel value", () => { + const { controller, collapseButton } = createController(); + + controller.refreshSuggestionsList(["A", "B", "C", "D"]); + + expect(collapseButton.textContent).toBe("Show less"); + }); + + it("hides wrapper when list is emptied", () => { + const { controller, expandCollapseWrapper } = createController(); + + expandCollapseWrapper.classList.remove("hidden"); + + controller.refreshSuggestionsList([]); + + expect(expandCollapseWrapper.classList.contains("hidden")).toBe(true); + }); + }); }); diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 0068e40..39b9964 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -199,7 +199,17 @@ editor: preview_pages: "Vorschauseiten" prompt_suggestions_headline: "Prompt-Vorschläge" show_more_suggestions: "+%count% weitere" + show_more_suggestions_template: "+{count} weitere" hide_suggestions: "Weniger anzeigen" + prompt_suggestion_add: "Hinzufügen" + prompt_suggestion_edit: "Bearbeiten" + prompt_suggestion_delete: "Löschen" + prompt_suggestion_save: "Speichern" + prompt_suggestion_cancel: "Abbrechen" + prompt_suggestion_delete_confirm: "Vorschlag wirklich löschen?" + prompt_suggestion_add_title: "Neuen Vorschlag hinzufügen" + prompt_suggestion_edit_title: "Vorschlag bearbeiten" + prompt_suggestion_add_placeholder: "Vorschlag eingeben..." details_troubleshooting: "Details (zur Fehlerbehebung)" session_label: "Sitzung:" work_area_label: "Arbeitsbereich:" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 17447c7..1d30d2a 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -199,7 +199,17 @@ editor: preview_pages: "Preview pages" prompt_suggestions_headline: "Prompt suggestions" show_more_suggestions: "+%count% more" + show_more_suggestions_template: "+{count} more" hide_suggestions: "Show less" + prompt_suggestion_add: "Add" + prompt_suggestion_edit: "Edit" + prompt_suggestion_delete: "Delete" + prompt_suggestion_save: "Save" + prompt_suggestion_cancel: "Cancel" + prompt_suggestion_delete_confirm: "Really delete this suggestion?" + prompt_suggestion_add_title: "Add new suggestion" + prompt_suggestion_edit_title: "Edit suggestion" + prompt_suggestion_add_placeholder: "Enter suggestion..." details_troubleshooting: "Details (for troubleshooting)" session_label: "Session:" work_area_label: "Work area:"