From 4ec0f787dd8224d4b085b6b453f053f4aca30565 Mon Sep 17 00:00:00 2001 From: Gulum Date: Mon, 22 Jun 2026 22:51:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(tickets):=20Markdown=20in=20Texten=20+=20E?= =?UTF-8?q?ditier-Regeln=20f=C3=BCr=20R=C3=BCckfragen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ticket-Texte (Frage/Antwort/Nachricht/Changelog/Verlauf) rendern eine Markdown-Teilmenge: **fett**, `code`, Aufzählungen, Links, Tier-/Ticket-IDs - Offene Rückfrage (NeedsInfo): nicht bearbeitbar — die Züchterin soll antworten - Beantwortet (Answered): Antwort nachträglich änderbar, bevor die KI übernimmt Co-Authored-By: Claude Opus 4.8 --- gerbil-manager-web/e2e/mock-data.ts | 3 +- gerbil-manager-web/e2e/tickets.spec.ts | 15 ++ gerbil-manager-web/src/pages/TicketsPage.tsx | 238 +++++++++++++------ gerbil-manager-web/src/pages/tickets.css | 18 ++ gerbil-manager-web/src/strings/de.ts | 2 + 5 files changed, 196 insertions(+), 80 deletions(-) diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index e4a45c4..d810d23 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -433,7 +433,8 @@ export function seedDb(): MockDb { createdAt: '2026-06-14T08:00:00Z', status: 'NeedsInfo', resolvedAt: null, - question: 'Welches Geburtsdatum ist korrekt — der 1. oder der 2. Juni?', + question: + 'Welches Geburtsdatum ist korrekt — der **1.** oder der **2. Juni**?\n\nBitte wähle:\n- 1. Juni\n- 2. Juni', answer: null, answeredAt: null, fixNote: null, diff --git a/gerbil-manager-web/e2e/tickets.spec.ts b/gerbil-manager-web/e2e/tickets.spec.ts index f6bceb4..7585084 100644 --- a/gerbil-manager-web/e2e/tickets.spec.ts +++ b/gerbil-manager-web/e2e/tickets.spec.ts @@ -158,6 +158,21 @@ test.describe('Meine Tickets', () => { await expect(openFilter).toHaveClass(/tickets-filter--active/) }) + test('Rückfrage rendert Markdown und ist (offen) nicht bearbeitbar', async ({ page }) => { + await page.goto('/hilfe/tickets') + await selectView(page, tt.filters.dialog) + const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' }) + await expect(card).toBeVisible() + + // Markdown: **fett** → , „- "-Aufzählung →
  • . + await expect(card.locator('.ticket-card__question-text strong').first()).toBeVisible() + await expect(card.locator('.ticket-card__question-text li')).toHaveCount(2) + + // Offene Rückfrage (NeedsInfo): kein „Bearbeiten"-Button, dafür ein Antwort-Feld. + await expect(card.getByRole('button', { name: tt.edit })).toHaveCount(0) + await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible() + }) + test('Ticket löschen', async ({ page }) => { await page.goto('/hilfe/tickets') await selectView(page, tt.filters.closed) diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx index 71c2c2e..7f8b01e 100644 --- a/gerbil-manager-web/src/pages/TicketsPage.tsx +++ b/gerbil-manager-web/src/pages/TicketsPage.tsx @@ -11,7 +11,7 @@ * Rückfragen kommen hierher zurück), „Geschlossen" (Resolved, zeigt den Changelog/fixNote). * Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS. */ -import { createContext, Fragment, useContext, useEffect, useMemo, useState } from 'react' +import { createContext, Fragment, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { de } from '../strings/de' import { ApiError } from '../api/client' @@ -57,43 +57,38 @@ function entityHref(ticket: FeedbackTicket): string | null { const GUID_RE = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' -type RichToken = { kind: 'text'; value: string } | { kind: 'link'; href: string; label: string } +type RichBlock = { type: 'p'; text: string } | { type: 'ul'; items: string[] } -/** - * Zerlegt Ticket-Text in Text- und Link-Token. Erkannt werden: - * - Markdown-Links `[Label](/route)` → interner/externer Link, - * - nackte GUIDs: gehört sie zu einem bekannten Ticket → Link auf dieses Ticket; sonst, wenn - * sie einen Tier-Namen auflöst → Name + Link zur Rennmausakte (so kann die KI einfach die - * Tier-ID in den Text schreiben). Unauflösbare GUIDs bleiben Text. - */ -function tokenizeRich( - text: string, - ticketIds: Set, - gerbilNames: Record, -): RichToken[] { - const tokens: RichToken[] = [] - const re = new RegExp(`\\[([^\\]]+)\\]\\(([^)]+)\\)|(${GUID_RE})`, 'g') - let last = 0 - let m: RegExpExecArray | null - while ((m = re.exec(text)) !== null) { - if (m.index > last) tokens.push({ kind: 'text', value: text.slice(last, m.index) }) - if (m[1] !== undefined && m[2] !== undefined) { - tokens.push({ kind: 'link', href: m[2], label: m[1] }) - } else { - const guid = m[3] - const gerbilName = gerbilNames[guid] - if (ticketIds.has(guid)) { - tokens.push({ kind: 'link', href: `/hilfe/tickets?focus=${guid}`, label: t.ticketRefLabel }) - } else if (gerbilName) { - tokens.push({ kind: 'link', href: `/rennmaeuse/${guid}`, label: gerbilName }) - } else { - tokens.push({ kind: 'text', value: guid }) - } +/** Block-Ebene: aufeinanderfolgende Aufzählungszeilen (-, *, •) zu einer Liste gruppieren. */ +function parseBlocks(text: string): RichBlock[] { + const blocks: RichBlock[] = [] + let para: string | null = null + let list: string[] | null = null + const flushPara = () => { + if (para !== null) { + blocks.push({ type: 'p', text: para }) + para = null } - last = re.lastIndex } - if (last < text.length) tokens.push({ kind: 'text', value: text.slice(last) }) - return tokens + const flushList = () => { + if (list) { + blocks.push({ type: 'ul', items: list }) + list = null + } + } + for (const line of text.split('\n')) { + const bullet = line.match(/^\s*[-*•]\s+(.*)$/) + if (bullet) { + flushPara() + ;(list ??= []).push(bullet[1]) + } else { + flushList() + para = para === null ? line : `${para}\n${line}` + } + } + flushPara() + flushList() + return blocks } /** Alle nackten GUIDs in einem Text, die KEINE Markdown-Link-Ziele sind (mögliche Tier-IDs). */ @@ -117,28 +112,81 @@ function TextWithBreaks({ value }: { value: string }) { ) } -/** Rendert Ticket-Text mit klickbaren Tier-/Ticket-Links und Zeilenumbrüchen. */ -function RichText({ text }: { text: string }) { +/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Tier-/Ticket-IDs, Zeilenumbrüche. */ +function InlineMarkdown({ text }: { text: string }) { const { ticketIds, gerbilNames } = useContext(TicketRefsContext) - const tokens = useMemo( - () => tokenizeRich(text, ticketIds, gerbilNames), - [text, ticketIds, gerbilNames], + const re = new RegExp( + `\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|(${GUID_RE})`, + 'g', ) + const nodes: ReactNode[] = [] + let last = 0 + let m: RegExpExecArray | null + let k = 0 + while ((m = re.exec(text)) !== null) { + if (m.index > last) nodes.push() + if (m[1] !== undefined && m[2] !== undefined) { + const href = m[2] + nodes.push( + href.startsWith('/') ? ( + + {m[1]} + + ) : ( + + {m[1]} + + ), + ) + } else if (m[3] !== undefined) { + nodes.push({m[3]}) + } else if (m[4] !== undefined) { + nodes.push( + + {m[4]} + , + ) + } else { + const guid = m[5] + const gn = gerbilNames[guid] + if (ticketIds.has(guid)) { + nodes.push( + + {t.ticketRefLabel} + , + ) + } else if (gn) { + nodes.push( + + {gn} + , + ) + } else { + nodes.push() + } + } + last = re.lastIndex + } + if (last < text.length) nodes.push() + return <>{nodes} +} + +/** Rendert Ticket-Text als Markdown-Teilmenge (Absätze, Aufzählungen, Inline-Formatierung). */ +function RichText({ text }: { text: string }) { + const blocks = useMemo(() => parseBlocks(text), [text]) return ( <> - {tokens.map((tok, i) => - tok.kind === 'link' ? ( - tok.href.startsWith('/') ? ( - - {tok.label} - - ) : ( - - {tok.label} - - ) + {blocks.map((b, i) => + b.type === 'ul' ? ( +
      + {b.items.map((it, j) => ( +
    • + +
    • + ))} +
    ) : ( - + ), )} @@ -408,9 +456,26 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete const entityLink = entityHref(ticket) const resolved = ticket.status === 'Resolved' const badge = statusBadge(ticket.status) - // Die Züchterin darf antworten, solange eine Rückfrage offen ist und noch keine - // Antwort vorliegt (NeedsInfo, oder eine Rückfrage ohne Antwort). - const canAnswer = !ticket.answer && (ticket.status === 'NeedsInfo' || (!!ticket.question && !resolved)) + const [answerEditing, setAnswerEditing] = useState(false) + // Erstantwort: Rückfrage offen, noch keine Antwort. + const canAnswerNew = !ticket.answer && (ticket.status === 'NeedsInfo' || (!!ticket.question && !resolved)) + // Nachträglich ändern: bereits beantwortet (Answered) — die Züchterin darf ihre letzte + // Info noch korrigieren, BEVOR die KI das Ticket bearbeitet. + const canAmend = !!ticket.answer && ticket.status === 'Answered' + // Antwort-Formular zeigen: neue Antwort, aktives Ändern, oder ein gepufferter Änderungs-Entwurf. + const showAnswerForm = canAnswerNew || answerEditing || (canAmend && !!answerDraft) + // Bearbeiten (Beschreibung) ist gesperrt, solange eine Rückfrage offen ist (NeedsInfo): + // dann soll die Züchterin ANTWORTEN, nicht den Text ändern. + const canEditMessage = ticket.status !== 'NeedsInfo' + + function startAmend() { + if (!answerDraft) setAnswerDraft(ticket.answer ?? '') + setAnswerEditing(true) + } + function cancelAmend() { + setAnswerEditing(false) + setAnswerDraft('') + } function startEdit() { setDraft(ticket.message) @@ -444,6 +509,7 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete try { await onAnswer(trimmed) setAnswerDraft('') + setAnswerEditing(false) } catch (err) { toast.error(err instanceof ApiError ? err.message : t.updateError) } finally { @@ -490,9 +556,9 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete ) : ( <> -

    +

    -

    +
    {ticket.thread.length > 0 && (
    @@ -506,9 +572,9 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete {entry.role === 'maintainer' ? t.threadMaintainer : t.threadBreeder} -

    +

    -

    +
    {entry.at && ( {formatDate(entry.at)} )} @@ -521,25 +587,13 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete {ticket.question && (
    {t.questionLabel} -

    +

    -

    +
    )} - {ticket.answer ? ( -
    - {t.answerLabel} -

    - -

    - {ticket.answeredAt && ( - - {t.answeredOn}: {formatDate(ticket.answeredAt)} - - )} -
    - ) : canAnswer ? ( + {showAnswerForm ? (
    + ) : ticket.answer ? ( +
    + {t.answerLabel} +
    + +
    + {ticket.answeredAt && ( + + {t.answeredOn}: {formatDate(ticket.answeredAt)} + + )} + {canAmend && ( +
    + +
    + )} +
    ) : null} {resolved && ticket.fixNote && (
    {t.changelogLabel} -

    +

    -

    +
    )} @@ -590,9 +668,11 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete - + {canEditMessage && ( + + )} diff --git a/gerbil-manager-web/src/pages/tickets.css b/gerbil-manager-web/src/pages/tickets.css index 925356d..e5c4f51 100644 --- a/gerbil-manager-web/src/pages/tickets.css +++ b/gerbil-manager-web/src/pages/tickets.css @@ -402,3 +402,21 @@ box-shadow: 0 0 0 0 transparent; } } + +/* Markdown im Ticket-Text: Aufzählungen + Inline-Code. */ +.ticket-md-list { + margin: 0.35rem 0; + padding-left: 1.25rem; +} + +.ticket-md-list li { + margin: 0.15rem 0; +} + +.ticket-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.88em; + background: rgba(0, 0, 0, 0.06); + padding: 0.05em 0.35em; + border-radius: 4px; +} diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 7abefc2..5882c01 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -926,6 +926,8 @@ export const de = { answerLabel: 'Deine Antwort', answerPlaceholder: 'Antwort auf die Rückfrage eingeben …', answerButton: 'Antworten', + /** Button auf einem beantworteten Ticket, um die eigene Antwort noch zu ändern. */ + amendAnswer: 'Antwort ändern', answering: 'Wird gesendet …', answeredOn: 'Beantwortet am', answeredToast: 'Antwort gesendet.',