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
@@ -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);
});
});