From 44fc8b136a3b5b47a13badf5482cb068c3610147 Mon Sep 17 00:00:00 2001 From: Dennis Schramm Date: Tue, 10 Feb 2026 13:45:42 +0100 Subject: [PATCH 01/10] Issue #71 - Implemented first version of prompt suggestions CRUD --- .../PromptSuggestionsController.php | 184 ++++++++ .../prompt_suggestions_controller.ts | 304 ++++++++++++- .../templates/chat_based_content_editor.twig | 138 +++++- .../Service/PromptSuggestionsService.php | 99 +++- .../PromptSuggestionsServiceTest.php | 196 +++++++- .../prompt_suggestions_controller.test.ts | 421 +++++++++++++++++- translations/messages.de.yaml | 9 + translations/messages.en.yaml | 9 + 8 files changed, 1326 insertions(+), 34 deletions(-) create mode 100644 src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php diff --git a/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php b/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php new file mode 100644 index 0000000..637077e --- /dev/null +++ b/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php @@ -0,0 +1,184 @@ + '[a-f0-9-]{36}'] + )] + public function create( + string $conversationId, + Request $request, + #[CurrentUser] UserInterface $user, + ): JsonResponse { + $workspace = $this->resolveEditableWorkspace($conversationId, $request, $user); + + $text = $this->getRequestText($request); + if ($text === null) { + return $this->json(['error' => 'Missing or empty "text" field.'], Response::HTTP_BAD_REQUEST); + } + + try { + $suggestions = $this->promptSuggestionsService->addSuggestion($workspace->workspacePath, $text); + } catch (InvalidArgumentException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); + } + + 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 { + $workspace = $this->resolveEditableWorkspace($conversationId, $request, $user); + + $text = $this->getRequestText($request); + if ($text === null) { + return $this->json(['error' => 'Missing or empty "text" field.'], Response::HTTP_BAD_REQUEST); + } + + try { + $suggestions = $this->promptSuggestionsService->updateSuggestion($workspace->workspacePath, $index, $text); + } 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); + } + + 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 { + $workspace = $this->resolveEditableWorkspace($conversationId, $request, $user); + + try { + $suggestions = $this->promptSuggestionsService->deleteSuggestion($workspace->workspacePath, $index); + } catch (OutOfRangeException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } + + return $this->json(['suggestions' => $suggestions]); + } + + /** + * Resolve conversation → workspace and enforce authorization + CSRF. + * + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + */ + private function resolveEditableWorkspace( + string $conversationId, + Request $request, + UserInterface $user, + ): \App\WorkspaceMgmt\Facade\Dto\WorkspaceInfoDto { + 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 $workspace; + } + + private 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/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts index ee995c5..f1a5863 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts @@ -2,12 +2,36 @@ import { Controller } from "@hotwired/stimulus"; /** * Stimulus controller for prompt suggestions. - * Displays clickable suggestion buttons that dispatch events for insertion into the chat input. + * Handles display (expand/collapse, insert) and CRUD (add, edit, delete) via modals. */ export default class extends Controller { - static targets = ["suggestion", "expandButton", "collapseButton"]; + static targets = [ + "suggestion", + "expandButton", + "collapseButton", + "suggestionList", + "formModal", + "formInput", + "formTitle", + "deleteModal", + ]; + static values = { maxVisible: { type: Number, default: 3 }, + createUrl: { type: String, default: "" }, + updateUrlTemplate: { type: String, default: "" }, + deleteUrlTemplate: { type: String, default: "" }, + csrfToken: { type: String, default: "" }, + addTitle: { type: String, default: "Add new suggestion" }, + editTitle: { type: String, default: "Edit suggestion" }, + placeholder: { type: String, default: "Enter suggestion..." }, + saveLabel: { type: String, default: "Save" }, + cancelLabel: { type: String, default: "Cancel" }, + deleteConfirmText: { + type: String, + default: "Really delete this suggestion?", + }, + deleteLabel: { type: String, default: "Delete" }, }; declare readonly suggestionTargets: HTMLButtonElement[]; @@ -17,6 +41,33 @@ export default class extends Controller { declare readonly collapseButtonTarget: HTMLButtonElement; declare readonly maxVisibleValue: number; + declare readonly hasSuggestionListTarget: boolean; + declare readonly suggestionListTarget: HTMLElement; + declare readonly hasFormModalTarget: boolean; + declare readonly formModalTarget: HTMLElement; + declare readonly hasFormInputTarget: boolean; + declare readonly formInputTarget: HTMLTextAreaElement; + declare readonly hasFormTitleTarget: boolean; + declare readonly formTitleTarget: HTMLElement; + declare readonly hasDeleteModalTarget: boolean; + declare readonly deleteModalTarget: HTMLElement; + + declare readonly createUrlValue: string; + declare readonly updateUrlTemplateValue: string; + declare readonly deleteUrlTemplateValue: string; + declare readonly csrfTokenValue: string; + declare readonly addTitleValue: string; + declare readonly editTitleValue: string; + declare readonly placeholderValue: string; + + /** null = add mode, number = edit mode (index of suggestion being edited) */ + editIndex: number | null = null; + + /** Index of the suggestion pending deletion */ + deleteIndex: number | null = null; + + // ─── Display: insert, hover, expand/collapse ──────────────── + /** * Handle click on a suggestion button. * Dispatches a custom event with the suggestion text for the chat controller to handle. @@ -53,12 +104,18 @@ export default class extends Controller { * Show all hidden suggestions and toggle expand/collapse buttons. */ expand(): void { - // Show all hidden suggestions + // Show all hidden suggestion rows (the parent wrapper divs) this.suggestionTargets.forEach((button) => { button.classList.remove("hidden"); + const row = button.closest("[data-index]"); + if (row) { + const actions = row.querySelector(".flex-shrink-0") as HTMLElement | null; + if (actions) { + actions.classList.remove("hidden"); + } + } }); - // Hide expand button, show collapse button if (this.hasExpandButtonTarget) { this.expandButtonTarget.classList.add("hidden"); } @@ -71,14 +128,19 @@ 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]"); + if (row) { + const actions = row.querySelector(".flex-shrink-0") as HTMLElement | null; + if (actions) { + actions.classList.add("hidden"); + } + } } }); - // Show expand button, hide collapse button if (this.hasExpandButtonTarget) { this.expandButtonTarget.classList.remove("hidden"); } @@ -86,4 +148,234 @@ export default class extends Controller { this.collapseButtonTarget.classList.add("hidden"); } } + + // ─── Add / Edit modal ─────────────────────────────────────── + + /** + * 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"; + if (index >= this.maxVisibleValue) { + button.classList.add("hidden"); + } + 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) { + actions.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); + }); + } } 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..ac0cba0 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig @@ -259,41 +259,139 @@ {# Prompt suggestions from .sitebuilder/prompt-suggestions.md #} - {% if promptSuggestions is not empty %} -
-

{{ 'editor.prompt_suggestions_headline'|trans }}

-
- {% for suggestion in promptSuggestions %} +
+ {# Headline with add button #} +
+

{{ 'editor.prompt_suggestions_headline'|trans }}

+ +
+ + {# Suggestions list #} +
+ {% for suggestion in promptSuggestions %} +
- {% endfor %} +
+ + +
+
+ {% endfor %} +
+ + {% if promptSuggestions|length > 3 %} +
+ + +
+ {% endif %} + + {# Shared modal for Add / Edit #} + {% else %}

diff --git a/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php b/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php index 8fc3f60..d24e678 100644 --- a/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php +++ b/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php @@ -4,17 +4,26 @@ namespace App\ChatBasedContentEditor\Presentation\Service; +use InvalidArgumentException; +use OutOfRangeException; +use RuntimeException; + use function array_filter; use function array_map; +use function array_splice; use function array_values; +use function dirname; use function explode; use function file_exists; use function file_get_contents; +use function file_put_contents; +use function implode; use function is_dir; +use function mkdir; use function trim; /** - * 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 +67,92 @@ 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); + $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); + } + } } diff --git a/tests/Unit/ChatBasedContentEditor/PromptSuggestionsServiceTest.php b/tests/Unit/ChatBasedContentEditor/PromptSuggestionsServiceTest.php index ee37525..0c10f33 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 testAddSuggestionAppendsToExistingFile(): void + { + $this->createSuggestionsFile("First\nSecond\n"); + + $service = new PromptSuggestionsService(); + $result = $service->addSuggestion($this->tempDir, 'Third'); + + self::assertSame(['First', 'Second', 'Third'], $result); + self::assertSame("First\nSecond\nThird\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..7e447ff 100644 --- a/tests/frontend/unit/ChatBasedContentEditor/prompt_suggestions_controller.test.ts +++ b/tests/frontend/unit/ChatBasedContentEditor/prompt_suggestions_controller.test.ts @@ -1,24 +1,46 @@ -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; - } => { + 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 row = document.createElement("div"); + row.dataset.index = String(i); + row.className = "group flex items-start gap-1"; + const btn = document.createElement("button"); btn.dataset.text = `Suggestion ${i + 1}`; if (i >= 3) { btn.classList.add("hidden"); } suggestionButtons.push(btn); + + const actions = document.createElement("div"); + actions.className = "flex-shrink-0"; + if (i >= 3) { + actions.classList.add("hidden"); + } + + row.appendChild(btn); + row.appendChild(actions); + suggestionList.appendChild(row); } // Create expand/collapse buttons @@ -26,6 +48,14 @@ describe("PromptSuggestionsController", () => { const collapseButton = document.createElement("button"); collapseButton.classList.add("hidden"); + // 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 { suggestionTargets: HTMLButtonElement[]; @@ -35,6 +65,23 @@ describe("PromptSuggestionsController", () => { collapseButtonTarget: HTMLButtonElement; 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; }; state.suggestionTargets = suggestionButtons; @@ -44,8 +91,35 @@ describe("PromptSuggestionsController", () => { state.collapseButtonTarget = collapseButton; 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..."; + + return { + controller, + suggestionButtons, + expandButton, + collapseButton, + suggestionList, + formModal, + formInput, + formTitle, + deleteModal, + }; }; describe("insert", () => { @@ -178,4 +252,339 @@ 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("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 suggestions beyond maxVisible", () => { + const { controller, suggestionList } = createController(); + + controller.refreshSuggestionsList(["A", "B", "C", "D", "E"]); + + const buttons = suggestionList.querySelectorAll('[data-prompt-suggestions-target="suggestion"]'); + expect(buttons[0].classList.contains("hidden")).toBe(false); + expect(buttons[2].classList.contains("hidden")).toBe(false); + expect(buttons[3].classList.contains("hidden")).toBe(true); + expect(buttons[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(); + }); + }); }); diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 0068e40..4a691c1 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -200,6 +200,15 @@ editor: prompt_suggestions_headline: "Prompt-Vorschläge" show_more_suggestions: "+%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..8c1e572 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -200,6 +200,15 @@ editor: prompt_suggestions_headline: "Prompt suggestions" show_more_suggestions: "+%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:" From 503927c7486d1c329c5e84ea74b520e3314a3895 Mon Sep 17 00:00:00 2001 From: Dennis Schramm Date: Tue, 10 Feb 2026 14:28:23 +0100 Subject: [PATCH 02/10] Issue #71 - Added tests --- .../PromptSuggestionsController.php | 48 ++- .../Dto/EditableWorkspaceContextDto.php | 19 + .../PromptSuggestionsControllerTest.php | 345 ++++++++++++++++++ 3 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 src/ChatBasedContentEditor/Presentation/Dto/EditableWorkspaceContextDto.php create mode 100644 tests/Unit/ChatBasedContentEditor/PromptSuggestionsControllerTest.php diff --git a/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php b/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php index 637077e..5f2dc1a 100644 --- a/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php +++ b/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php @@ -7,6 +7,7 @@ use App\Account\Facade\AccountFacadeInterface; use App\ChatBasedContentEditor\Domain\Entity\Conversation; use App\ChatBasedContentEditor\Domain\Enum\ConversationStatus; +use App\ChatBasedContentEditor\Presentation\Dto\EditableWorkspaceContextDto; use App\ChatBasedContentEditor\Presentation\Service\PromptSuggestionsService; use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface; use Doctrine\ORM\EntityManagerInterface; @@ -21,6 +22,7 @@ use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Throwable; use function array_key_exists; use function is_array; @@ -52,7 +54,7 @@ public function create( Request $request, #[CurrentUser] UserInterface $user, ): JsonResponse { - $workspace = $this->resolveEditableWorkspace($conversationId, $request, $user); + $context = $this->resolveEditableWorkspace($conversationId, $request, $user); $text = $this->getRequestText($request); if ($text === null) { @@ -60,9 +62,12 @@ public function create( } try { - $suggestions = $this->promptSuggestionsService->addSuggestion($workspace->workspacePath, $text); + $suggestions = $this->promptSuggestionsService->addSuggestion($context->workspacePath, $text); + $this->commitChanges($context, $conversationId, 'Add prompt suggestion'); } 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]); @@ -80,7 +85,7 @@ public function update( Request $request, #[CurrentUser] UserInterface $user, ): JsonResponse { - $workspace = $this->resolveEditableWorkspace($conversationId, $request, $user); + $context = $this->resolveEditableWorkspace($conversationId, $request, $user); $text = $this->getRequestText($request); if ($text === null) { @@ -88,11 +93,14 @@ public function update( } try { - $suggestions = $this->promptSuggestionsService->updateSuggestion($workspace->workspacePath, $index, $text); + $suggestions = $this->promptSuggestionsService->updateSuggestion($context->workspacePath, $index, $text); + $this->commitChanges($context, $conversationId, 'Update prompt suggestion'); } 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]); @@ -110,12 +118,15 @@ public function delete( Request $request, #[CurrentUser] UserInterface $user, ): JsonResponse { - $workspace = $this->resolveEditableWorkspace($conversationId, $request, $user); + $context = $this->resolveEditableWorkspace($conversationId, $request, $user); try { - $suggestions = $this->promptSuggestionsService->deleteSuggestion($workspace->workspacePath, $index); + $suggestions = $this->promptSuggestionsService->deleteSuggestion($context->workspacePath, $index); + $this->commitChanges($context, $conversationId, 'Remove prompt suggestion'); } 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]); @@ -123,6 +134,7 @@ public function delete( /** * 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 @@ -131,7 +143,7 @@ private function resolveEditableWorkspace( string $conversationId, Request $request, UserInterface $user, - ): \App\WorkspaceMgmt\Facade\Dto\WorkspaceInfoDto { + ): EditableWorkspaceContextDto { if (!$this->isCsrfTokenValid('prompt-suggestions', $request->headers->get('X-CSRF-Token', ''))) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } @@ -159,7 +171,27 @@ private function resolveEditableWorkspace( throw $this->createNotFoundException('Workspace not found.'); } - return $workspace; + return new EditableWorkspaceContextDto( + $workspace->workspacePath, + $workspace->id, + $accountInfo->email, + ); + } + + /** + * Commit and push the prompt suggestions file changes to the remote repository. + */ + private function commitChanges( + EditableWorkspaceContextDto $context, + string $conversationId, + string $message, + ): void { + $this->workspaceMgmtFacade->commitAndPush( + $context->workspaceId, + $message, + $context->authorEmail, + $conversationId, + ); } private function getRequestText(Request $request): ?string 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 @@ +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(['Existing suggestion', 'New 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', + ], + ); + } +} From 2e22beb91c611e723d8765a6e9bf56dcc1147f08 Mon Sep 17 00:00:00 2001 From: Dennis Schramm Date: Tue, 10 Feb 2026 15:31:10 +0100 Subject: [PATCH 03/10] Issue #71 - Correction of updating the prompt suggestions area in content editor --- .../prompt_suggestions_controller.ts | 41 +++++++++ .../templates/chat_based_content_editor.twig | 33 +++---- .../prompt_suggestions_controller.test.ts | 86 ++++++++++++++++++- translations/messages.de.yaml | 1 + translations/messages.en.yaml | 1 + 5 files changed, 145 insertions(+), 17 deletions(-) diff --git a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts index f1a5863..e308d0f 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts @@ -9,6 +9,7 @@ export default class extends Controller { "suggestion", "expandButton", "collapseButton", + "expandCollapseWrapper", "suggestionList", "formModal", "formInput", @@ -32,6 +33,8 @@ export default class extends Controller { default: "Really delete this suggestion?", }, deleteLabel: { type: String, default: "Delete" }, + showMoreTemplate: { type: String, default: "+{count} more" }, + showLessLabel: { type: String, default: "Show less" }, }; declare readonly suggestionTargets: HTMLButtonElement[]; @@ -39,6 +42,8 @@ export default class extends Controller { declare readonly expandButtonTarget: HTMLButtonElement; declare readonly hasCollapseButtonTarget: boolean; declare readonly collapseButtonTarget: HTMLButtonElement; + declare readonly hasExpandCollapseWrapperTarget: boolean; + declare readonly expandCollapseWrapperTarget: HTMLElement; declare readonly maxVisibleValue: number; declare readonly hasSuggestionListTarget: boolean; @@ -59,6 +64,8 @@ export default class extends Controller { declare readonly addTitleValue: string; declare readonly editTitleValue: string; declare readonly placeholderValue: string; + declare readonly showMoreTemplateValue: string; + declare readonly showLessLabelValue: string; /** null = add mode, number = edit mode (index of suggestion being edited) */ editIndex: number | null = null; @@ -377,5 +384,39 @@ export default class extends Controller { 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/chat_based_content_editor.twig b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig index ac0cba0..909bb37 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig @@ -272,6 +272,8 @@ cancelLabel: 'editor.prompt_suggestion_cancel'|trans, deleteConfirmText: 'editor.prompt_suggestion_delete_confirm'|trans, deleteLabel: 'editor.prompt_suggestion_delete'|trans, + showMoreTemplate: 'editor.show_more_suggestions_template'|trans, + showLessLabel: 'editor.hide_suggestions'|trans, }) }} class="mt-3"> {# Headline with add button #} @@ -327,22 +329,21 @@ {% endfor %}

- {% if promptSuggestions|length > 3 %} -
- - -
- {% endif %} +
+ + +
{# Shared modal for Add / Edit #}
{ suggestionButtons: HTMLButtonElement[]; expandButton: HTMLButtonElement; collapseButton: HTMLButtonElement; + expandCollapseWrapper: HTMLElement; suggestionList: HTMLElement; formModal: HTMLElement; formInput: HTMLTextAreaElement; @@ -43,10 +44,13 @@ describe("PromptSuggestionsController", () => { 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"); @@ -63,6 +67,8 @@ describe("PromptSuggestionsController", () => { expandButtonTarget: HTMLButtonElement; hasCollapseButtonTarget: boolean; collapseButtonTarget: HTMLButtonElement; + hasExpandCollapseWrapperTarget: boolean; + expandCollapseWrapperTarget: HTMLElement; maxVisibleValue: number; dispatch: (name: string, options: unknown) => void; hasSuggestionListTarget: boolean; @@ -82,6 +88,8 @@ describe("PromptSuggestionsController", () => { addTitleValue: string; editTitleValue: string; placeholderValue: string; + showMoreTemplateValue: string; + showLessLabelValue: string; }; state.suggestionTargets = suggestionButtons; @@ -89,6 +97,8 @@ describe("PromptSuggestionsController", () => { state.expandButtonTarget = expandButton; state.hasCollapseButtonTarget = true; state.collapseButtonTarget = collapseButton; + state.hasExpandCollapseWrapperTarget = true; + state.expandCollapseWrapperTarget = expandCollapseWrapper; state.maxVisibleValue = 3; state.dispatch = vi.fn(); state.hasSuggestionListTarget = true; @@ -108,12 +118,15 @@ describe("PromptSuggestionsController", () => { 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, @@ -586,5 +599,76 @@ describe("PromptSuggestionsController", () => { 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 4a691c1..39b9964 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -199,6 +199,7 @@ 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" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 8c1e572..1d30d2a 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -199,6 +199,7 @@ 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" From 38933385c48e069ceace9f8be7e21dc0c52791b0 Mon Sep 17 00:00:00 2001 From: Dennis Schramm Date: Wed, 11 Feb 2026 10:42:37 +0100 Subject: [PATCH 04/10] Issue #71 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Funktionen aus Controller vollständig entfernt (commitChanges) und in den Service verlegt --- .../PromptSuggestionsController.php | 68 ++++++------------- .../Service/PromptSuggestionsService.php | 35 ++++++---- 2 files changed, 43 insertions(+), 60 deletions(-) diff --git a/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php b/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php index 5f2dc1a..82b172b 100644 --- a/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php +++ b/src/ChatBasedContentEditor/Presentation/Controller/PromptSuggestionsController.php @@ -24,11 +24,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; use Throwable; -use function array_key_exists; -use function is_array; -use function is_string; -use function trim; - /** * JSON API controller for CRUD operations on prompt suggestions. */ @@ -56,14 +51,19 @@ public function create( ): JsonResponse { $context = $this->resolveEditableWorkspace($conversationId, $request, $user); - $text = $this->getRequestText($request); + $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->commitChanges($context, $conversationId, 'Add prompt suggestion'); + $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) { @@ -87,14 +87,20 @@ public function update( ): JsonResponse { $context = $this->resolveEditableWorkspace($conversationId, $request, $user); - $text = $this->getRequestText($request); + $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->commitChanges($context, $conversationId, 'Update prompt suggestion'); + + $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) { @@ -122,7 +128,13 @@ public function delete( try { $suggestions = $this->promptSuggestionsService->deleteSuggestion($context->workspacePath, $index); - $this->commitChanges($context, $conversationId, 'Remove prompt suggestion'); + + $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) { @@ -177,40 +189,4 @@ private function resolveEditableWorkspace( $accountInfo->email, ); } - - /** - * Commit and push the prompt suggestions file changes to the remote repository. - */ - private function commitChanges( - EditableWorkspaceContextDto $context, - string $conversationId, - string $message, - ): void { - $this->workspaceMgmtFacade->commitAndPush( - $context->workspaceId, - $message, - $context->authorEmail, - $conversationId, - ); - } - - private 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/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php b/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php index d24e678..72dab9d 100644 --- a/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php +++ b/src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php @@ -7,20 +7,7 @@ use InvalidArgumentException; use OutOfRangeException; use RuntimeException; - -use function array_filter; -use function array_map; -use function array_splice; -use function array_values; -use function dirname; -use function explode; -use function file_exists; -use function file_get_contents; -use function file_put_contents; -use function implode; -use function is_dir; -use function mkdir; -use function trim; +use Symfony\Component\HttpFoundation\Request; /** * Reads, creates, updates, and deletes prompt suggestions in .sitebuilder/prompt-suggestions.md. @@ -155,4 +142,24 @@ private function saveSuggestions(string $workspacePath, array $suggestions): voi 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']; + } } From 046c046380f34ecb3e67dfd784e8a8ed0860db2a Mon Sep 17 00:00:00 2001 From: Dennis Schramm Date: Wed, 11 Feb 2026 10:49:56 +0100 Subject: [PATCH 05/10] Issue #71 - Extracted prompt-suggestions-area from main template and put into own template which gets included --- .../templates/_prompt_suggestions.twig | 135 +++++++++++++++++ .../templates/chat_based_content_editor.twig | 136 +----------------- 2 files changed, 136 insertions(+), 135 deletions(-) create mode 100644 src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig 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..dd3f63b --- /dev/null +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig @@ -0,0 +1,135 @@ +{# 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 909bb37..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,141 +258,7 @@
- {# 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 #} - -
+ {% include '@chat_based_content_editor.presentation/_prompt_suggestions.twig' %} {% else %}

From 5cbf3b8904c6e97ce2c534e6091f111249e1f2bd Mon Sep 17 00:00:00 2001 From: Dennis Schramm Date: Wed, 11 Feb 2026 13:20:58 +0100 Subject: [PATCH 06/10] Issue #71 - Blocking the return key in order to prevent newlines within a prompt --- .../prompt_suggestions_controller.ts | 10 ++++++ .../templates/_prompt_suggestions.twig | 1 + .../prompt_suggestions_controller.test.ts | 35 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts index e308d0f..3e20d2b 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts @@ -158,6 +158,16 @@ export default class extends Controller { // ─── 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). */ diff --git a/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig b/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig index dd3f63b..b02dc71 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig @@ -94,6 +94,7 @@

diff --git a/tests/frontend/unit/ChatBasedContentEditor/prompt_suggestions_controller.test.ts b/tests/frontend/unit/ChatBasedContentEditor/prompt_suggestions_controller.test.ts index 3a83df7..ec146fe 100644 --- a/tests/frontend/unit/ChatBasedContentEditor/prompt_suggestions_controller.test.ts +++ b/tests/frontend/unit/ChatBasedContentEditor/prompt_suggestions_controller.test.ts @@ -317,6 +317,41 @@ describe("PromptSuggestionsController", () => { }); }); + 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(); From 0b4b6eba5ff60bbacb58edbc9a514f245d9708dd Mon Sep 17 00:00:00 2001 From: Dennis Schramm Date: Wed, 11 Feb 2026 13:27:09 +0100 Subject: [PATCH 07/10] Issue #71 - Made text area for functions 'New prompt' and 'Update prompt' bigger --- .../Presentation/Resources/templates/_prompt_suggestions.twig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig b/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig index b02dc71..1eb97c0 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig @@ -90,12 +90,12 @@ class="hidden fixed inset-0 z-50 flex items-center justify-center">
-
+

-
+