feat(web): Notiz-Widget — Aufgabenlisten in der Ansicht abhakbar

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:36:17 +02:00
parent 3c890afd92
commit 684f063a8e
4 changed files with 384 additions and 12 deletions
@@ -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(
<MDEditor.Markdown
source={SRC}
rehypePlugins={[[rehypeSanitize]]}
components={{ input: NoteCheckbox }}
/>,
);
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);
});
});
});
@@ -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 `<input>` 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 (
<input
type="checkbox"
checked={!!checked}
readOnly
className="cursor-pointer"
/>
);
}
@@ -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;
}) => (
<textarea
data-testid={testId ?? 'md-editor'}
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
);
}) => {
if (preview === 'preview') {
const InputComponent = previewOptions?.components?.input;
const lines = (value ?? '').split('\n');
return (
<div data-testid={testId ?? 'md-editor'}>
{lines.map((line, i) => {
const match = line.match(TASK_MOCK_RE);
if (!match) return null;
const checked = match[1] !== ' ';
return InputComponent ? (
<InputComponent key={i} checked={checked} />
) : (
<input key={i} type="checkbox" readOnly checked={checked} />
);
})}
</div>
);
}
return (
<textarea
data-testid={testId ?? 'md-editor'}
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
);
};
MDEditor.displayName = 'MDEditor';
return {
@@ -171,4 +201,93 @@ describe('NoteWidget', () => {
abortSpy.mockRestore();
});
// quick-260916-iex: Aufgabenlisten in der Ansicht abhakbar
it('kippt beim Klick auf ein Kaestchen in der Ansicht genau diese Zeile und speichert sofort', async () => {
render(
<NoteWidget
instanceId="note-1"
config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}
isEditMode={false}
/>,
);
const boxes = screen.getAllByRole('checkbox');
expect(boxes).toHaveLength(3);
fireEvent.click(boxes[2]);
await act(async () => {});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/dashboard/widgets/note-1/config');
expect(init.method).toBe('PATCH');
expect(JSON.parse(init.body as string)).toEqual({
config: { content: '- [ ] Milch\n- [x] Brot\n- [x] Eier', title: 'Einkauf' },
});
expect((screen.getAllByRole('checkbox')[2] as HTMLInputElement).checked).toBe(true);
});
it('kippt ein angehaktes Kaestchen beim Klick wieder ab', async () => {
render(
<NoteWidget
instanceId="note-1"
config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}
isEditMode={false}
/>,
);
const boxes = screen.getAllByRole('checkbox');
fireEvent.click(boxes[1]);
await act(async () => {});
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toEqual({
config: { content: '- [ ] Milch\n- [ ] Brot\n- [ ] Eier', title: 'Einkauf' },
});
});
it('verwirft einen noch laufenden Entprell-Timer aus dem Tippen beim Abhaken', async () => {
render(
<NoteWidget
instanceId="note-1"
config={{ content: '- [ ] Milch\n- [ ] Brot', title: 'Einkauf' }}
isEditMode={false}
/>,
);
// Stift an: in den Bearbeitungsmodus wechseln und tippen
await act(async () => {
fireEvent.click(screen.getByRole('button'));
});
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: '- [ ] Milch\n- [ ] Brot' } });
await act(async () => {
vi.advanceTimersByTime(200);
});
expect(fetchSpy).not.toHaveBeenCalled();
// Stift wieder aus: zurueck in die Ansicht
await act(async () => {
fireEvent.click(screen.getByRole('button'));
});
// Erstes Kaestchen anklicken -> sofortiges Speichern, alter Timer verworfen
const boxes = screen.getAllByRole('checkbox');
fireEvent.click(boxes[0]);
await act(async () => {});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toEqual({
config: { content: '- [x] Milch\n- [ ] Brot', title: 'Einkauf' },
});
// Der alte, eigentlich noch laufende Timer darf nichts mehr nachschieben
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
@@ -6,9 +6,27 @@ import MDEditor, { commands } from '@uiw/react-md-editor';
import rehypeSanitize from 'rehype-sanitize';
import type { WidgetProps } from '../widget-registry';
import { updateWidgetConfig } from '@/lib/dashboard-api';
import { NoteCheckbox, toggleTaskLine } from './note-task-list';
const DEBOUNCE_MS = 1500;
// quick-260916-iex: Aufgabenlisten sind in der Ansicht direkt abhakbar —
// `components.input` ersetzt das per rehypeSanitize deaktivierte `input`
// durch NoteCheckbox (siehe note-task-list.tsx). Stabile Konstante ausserhalb
// der Komponente, damit react-markdown nicht bei jedem Render neu diffed.
// Typ direkt von MDEditor abgeleitet statt aus dem transitiven Paket
// `@uiw/react-markdown-preview` importiert (das ist keine direkte
// Dependency von apps/web und daher unter pnpms strikter Isolation nicht
// von hier aus aufloesbar).
type PreviewOptions = NonNullable<
React.ComponentProps<typeof MDEditor>['previewOptions']
>;
const PREVIEW_OPTIONS: PreviewOptions = {
rehypePlugins: [[rehypeSanitize]],
components: { input: NoteCheckbox },
};
const NOTE_COMMANDS = [
commands.bold,
commands.italic,
@@ -83,6 +101,32 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
[scheduleSave, content],
);
// quick-260916-iex: Klick auf ein Aufgaben-Kaestchen in der Ansicht kippt
// genau diese Zeile und speichert sofort (kein Entprellen — ein Klick ist
// eine abgeschlossene Handlung). Ein noch laufender Entprell-Timer aus dem
// Tippen wird verworfen, damit er den ungekippten Text nicht nachschiebt.
const handlePreviewClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (isEditing) return;
const target = event.target;
if (!(target instanceof HTMLInputElement) || target.type !== 'checkbox') {
return;
}
const boxes = Array.from(
event.currentTarget.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"]',
),
);
const index = boxes.indexOf(target);
const next = toggleTaskLine(content, index);
if (next === content) return;
setContent(next);
clearTimeout(timerRef.current);
void save(next, title);
},
[isEditing, content, title, save],
);
return (
<div className="flex h-full flex-col overflow-hidden" data-color-mode="auto">
{/* Header: title + pencil toggle */}
@@ -123,7 +167,11 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
</div>
{/* Content: preview or editor */}
<div className="flex-1 overflow-auto">
<div
className="flex-1 overflow-auto"
data-testid="note-preview"
onClick={handlePreviewClick}
>
<MDEditor
data-testid="md-editor"
value={content}
@@ -133,9 +181,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
hideToolbar={!isEditing}
height="100%"
visibleDragbar={false}
previewOptions={{
rehypePlugins: [[rehypeSanitize]],
}}
previewOptions={PREVIEW_OPTIONS}
/>
</div>
</div>