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:
@@ -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;
|
||||
}) => (
|
||||
}) => {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user