From 684f063a8ea9c56062715b31f93c18ff8f8cbf07 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 16 Sep 2026 13:36:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Notiz-Widget=20=E2=80=94=20Aufgabe?= =?UTF-8?q?nlisten=20in=20der=20Ansicht=20abhakbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hilfsmodul note-task-list.tsx mit isTaskLine/toggleTaskLine (GFM-konforme Regex, Code-Zaeune werden uebersprungen) und NoteCheckbox (kein disabled) - previewOptions des Notiz-Widgets reicht components.input = NoteCheckbox durch react-markdown nach rehypeSanitize durch (bleibt aktiv, T-IEX-01) - Klick im Vorschau-Container kippt genau die N-te Aufgabenzeile und speichert sofort (verwirft einen laufenden Tipp-Entprell-Timer) - Hinweis: note-task-list als .tsx statt .ts angelegt (Komponente braucht JSX) — Abweichung dokumentiert in SUMMARY.md - 16 neue/erweiterte Tests gruen, tsc Exit 0 Co-Authored-By: Claude Opus 5 (1M context) --- .../dashboard/widgets/note-task-list.test.tsx | 107 ++++++++++++++ .../dashboard/widgets/note-task-list.tsx | 100 +++++++++++++ .../dashboard/widgets/note-widget.test.tsx | 135 ++++++++++++++++-- .../dashboard/widgets/note-widget.tsx | 54 ++++++- 4 files changed, 384 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/dashboard/widgets/note-task-list.test.tsx create mode 100644 apps/web/src/components/dashboard/widgets/note-task-list.tsx diff --git a/apps/web/src/components/dashboard/widgets/note-task-list.test.tsx b/apps/web/src/components/dashboard/widgets/note-task-list.test.tsx new file mode 100644 index 0000000..5409235 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/note-task-list.test.tsx @@ -0,0 +1,107 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import MDEditor from '@uiw/react-md-editor'; +import rehypeSanitize from 'rehype-sanitize'; +import { NoteCheckbox, isTaskLine, toggleTaskLine } from './note-task-list'; + +describe('note-task-list', () => { + describe('isTaskLine', () => { + it('erkennt gueltige Aufgabenzeilen', () => { + expect(isTaskLine('- [ ] Milch')).toBe(true); + expect(isTaskLine('- [x] Brot')).toBe(true); + expect(isTaskLine('* [X] Eier')).toBe(true); + expect(isTaskLine('+ [ ] Butter')).toBe(true); + expect(isTaskLine('1. [ ] Mehl')).toBe(true); + expect(isTaskLine('2) [x] Salz')).toBe(true); + expect(isTaskLine(' - [ ] eingerueckt')).toBe(true); + expect(isTaskLine('- [\t] Tab')).toBe(true); + }); + + it('lehnt Nicht-Aufgabenzeilen ab', () => { + expect(isTaskLine('- [ ]')).toBe(false); + expect(isTaskLine('- [ ]Text')).toBe(false); + expect(isTaskLine('- Milch')).toBe(false); + expect(isTaskLine('[ ] ohne Punkt')).toBe(false); + expect(isTaskLine('- [y] falsch')).toBe(false); + expect(isTaskLine('')).toBe(false); + }); + }); + + describe('toggleTaskLine', () => { + it('kippt genau die Zeile am angegebenen Index auf [x]', () => { + const content = '- [ ] Milch\n- [ ] Brot\n- [ ] Eier'; + expect(toggleTaskLine(content, 0)).toBe( + '- [x] Milch\n- [ ] Brot\n- [ ] Eier', + ); + expect(toggleTaskLine(content, 2)).toBe( + '- [ ] Milch\n- [ ] Brot\n- [x] Eier', + ); + }); + + it('kippt [x]/[X] zurueck auf [ ]', () => { + expect(toggleTaskLine('- [x] Brot', 0)).toBe('- [ ] Brot'); + expect(toggleTaskLine('- [X] Brot', 0)).toBe('- [ ] Brot'); + }); + + it('zaehlt Nicht-Aufgabenzeilen nicht mit, alle anderen Zeilen bleiben unveraendert', () => { + const content = '# Einkauf\n\nText\n- [ ] Milch\n- normal\n- [ ] Brot'; + const result = toggleTaskLine(content, 1); + expect(result).toBe('# Einkauf\n\nText\n- [ ] Milch\n- normal\n- [x] Brot'); + }); + + it('behaelt Einrueckung und Nummerierung bei', () => { + const content = '- [ ] A\n - [ ] B\n1. [ ] C'; + expect(toggleTaskLine(content, 1)).toBe('- [ ] A\n - [x] B\n1. [ ] C'); + expect(toggleTaskLine(content, 2)).toBe('- [ ] A\n - [ ] B\n1. [x] C'); + }); + + it('gibt bei Index ausserhalb des Bereichs die unveraenderte Eingabe zurueck', () => { + const content = '- [ ] A\n- [ ] B\n- [ ] C'; + expect(toggleTaskLine(content, -1)).toBe(content); + expect(toggleTaskLine(content, 3)).toBe(content); + expect(toggleTaskLine('', 0)).toBe(''); + }); + + it('ueberspringt Zeilen innerhalb von Code-Zaeunen', () => { + const withBackticks = '```\n- [ ] nicht\n```\n- [ ] echt'; + expect(toggleTaskLine(withBackticks, 0)).toBe( + '```\n- [ ] nicht\n```\n- [x] echt', + ); + + const withTilde = '~~~\n- [ ] nicht\n~~~\n- [ ] echt'; + expect(toggleTaskLine(withTilde, 0)).toBe( + '~~~\n- [ ] nicht\n~~~\n- [x] echt', + ); + }); + }); + + describe('echte Vorschau (MDEditor.Markdown + rehypeSanitize + components-Override)', () => { + it('rendert genau die Aufgabenzeilen als anklickbare Kaestchen', () => { + const SRC = + '- [ ] eins\n- [x] zwei\n* [X] drei\n- [ ]\n- [ ]kein\n1. [ ] vier'; + + const { container } = render( + , + ); + + const boxes = container.querySelectorAll('input[type="checkbox"]'); + const expectedCount = SRC.split('\n').filter(isTaskLine).length; + + expect(expectedCount).toBe(4); + expect(boxes.length).toBe(4); + + for (const box of Array.from(boxes)) { + expect((box as HTMLInputElement).disabled).toBe(false); + } + + expect((boxes[0] as HTMLInputElement).checked).toBe(false); + expect((boxes[1] as HTMLInputElement).checked).toBe(true); + expect((boxes[2] as HTMLInputElement).checked).toBe(true); + expect((boxes[3] as HTMLInputElement).checked).toBe(false); + }); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/note-task-list.tsx b/apps/web/src/components/dashboard/widgets/note-task-list.tsx new file mode 100644 index 0000000..dc2d7ee --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/note-task-list.tsx @@ -0,0 +1,100 @@ +/** + * note-task-list — reine Hilfsfunktionen + Kaestchen-Komponente fuer das + * Notiz-Widget (quick-260916-iex). + * + * `rehypeSanitize` (hast-util-sanitize) erzwingt bei `input`-Elementen + * grundsaetzlich `disabled=true`. Die Loesung ersetzt NICHT das + * Sanitize-Verhalten, sondern das gerenderte Element selbst: react-markdown + * (ueber `@uiw/react-md-editor`s `previewOptions.components`) laesst uns das + * `input`-Tag durch eine eigene Komponente ersetzen, die NACH dem Sanitize + * ausgefuehrt wird und deshalb selbst entscheidet, ob sie `disabled` setzt. + * `rehypeSanitize` bleibt aktiv (XSS-Schranke fuer den Rest des Markdowns, + * T-IEX-01). + * + * Die Zuordnung Klick -> Zeile laeuft ueber einen Index (n-tes Kaestchen im + * DOM == n-te Aufgabenzeile im Markdown). Damit das stimmt, MUSS + * `isTaskLine` exakt dieselben Zeilen als Aufgabe erkennen wie die + * GFM-Erkennung von react-markdown (remark-gfm / + * micromark-extension-gfm-task-list-item): nach der schliessenden Klammer + * muss Leerraum UND mindestens ein Nicht-Leerraum-Zeichen folgen. Eine + * laxere Regex wuerde bei einer noch leeren Aufgabenzeile (`- [ ]`, typisch + * beim Tippen einer neuen Aufgabe) den Index verschieben und beim Klick die + * falsche Zeile kippen. + * + * Vorbild: `toggleMarkdownCheckbox` im alten persoenlichen Dashboard des + * Anwenders (user-files/personal-dashboard/src/app/page.tsx, ~Z. 1399). + */ + +/** + * Erkennt eine Markdown-Aufgabenzeile (`- [ ] Text`, `* [x] Text`, + * `+ [X] Text`, `1. [ ] Text`, `1) [ ] Text`, auch eingerueckt). Bewusst + * streng: nach `]` muss Leerraum und danach mindestens ein + * Nicht-Leerraum-Zeichen folgen (siehe Modulkommentar). + */ +export const TASK_LINE_RE = + /^(\s*(?:[-*+]|\d+[.)])\s+\[)([ \txX])(\]\s+\S.*)$/; + +const FENCE_RE = /^\s*(```|~~~)/; + +export function isTaskLine(line: string): boolean { + return TASK_LINE_RE.test(line); +} + +/** + * Kippt die `index`-te Aufgabenzeile (0-basiert, Zeilen in Code-Zaeunen + * zaehlen nicht mit) zwischen `[ ]` und `[x]`. Gibt bei ungueltigem Index + * (< 0 oder >= Anzahl Aufgabenzeilen) dieselbe Referenz wie `content` + * zurueck, damit Aufrufer per `===` erkennen, dass nichts zu speichern ist. + */ +export function toggleTaskLine(content: string, index: number): string { + if (index < 0) return content; + + const lines = content.split('\n'); + let inFence = false; + let taskCounter = 0; + let changed = false; + + const nextLines = lines.map((line) => { + if (FENCE_RE.test(line)) { + inFence = !inFence; + return line; + } + if (inFence) return line; + if (!isTaskLine(line)) return line; + + const isTarget = taskCounter === index; + taskCounter += 1; + if (!isTarget) return line; + + const match = line.match(TASK_LINE_RE); + if (!match) return line; + + const [, prefix, mark, suffix] = match; + const newMark = mark === ' ' || mark === '\t' ? 'x' : ' '; + changed = true; + return `${prefix}${newMark}${suffix}`; + }); + + if (!changed) return content; + return nextLines.join('\n'); +} + +/** + * Ersetzt das von react-markdown gerenderte `` einer Aufgabenzeile. + * Zieht bewusst NUR `checked` aus den Props (react-markdown reicht + * zusaetzlich `node`, `disabled`, `type` durch) — sonst landen fremde + * Attribute (insbesondere `node`) im DOM. Kein `disabled`: das Kaestchen + * bleibt anklickbar. `readOnly` unterdrueckt Reacts Warnung + * "checked ohne onChange" — der Klick wird nicht am Kaestchen selbst, + * sondern delegiert am Vorschau-Container verarbeitet. + */ +export function NoteCheckbox({ checked }: { checked?: boolean }) { + return ( + + ); +} diff --git a/apps/web/src/components/dashboard/widgets/note-widget.test.tsx b/apps/web/src/components/dashboard/widgets/note-widget.test.tsx index 9888bc1..a6a9873 100644 --- a/apps/web/src/components/dashboard/widgets/note-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/note-widget.test.tsx @@ -12,23 +12,53 @@ vi.mock('next-intl', () => ({ }, })); -// Mock @uiw/react-md-editor — provide a minimal textarea-based substitute +// Mock @uiw/react-md-editor — provide a minimal textarea-based substitute. +// Im Modus 'preview' wird stattdessen ein Vorschau-Div gerendert, das fuer +// jede Aufgabenzeile ein Kaestchen enthaelt (quick-260916-iex) — nutzt die +// echte `components.input`-Komponente aus `previewOptions`, wenn vorhanden, +// damit auch NoteCheckbox im Klickpfad landet. +const TASK_MOCK_RE = /^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+\S/; + vi.mock('@uiw/react-md-editor', () => { const MDEditor = ({ value, onChange, + preview, + previewOptions, 'data-testid': testId, }: { value?: string; onChange?: (val?: string) => void; + preview?: string; + previewOptions?: { components?: { input?: React.ComponentType<{ checked?: boolean }> } }; 'data-testid'?: string; - }) => ( -