forked from mirror/crumbs-original
The reactive {#html} binding inside contenteditable replaced innerHTML on
every keystroke, destroying the cursor and causing text duplication.
Instead, use a use:editableContent action that sets innerHTML on mount
and only syncs when the element is NOT focused. Linkification happens
on blur, matching Google Keep's behavior. Update e2e tests to blur
before asserting link visibility.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
460 lines
21 KiB
TypeScript
460 lines
21 KiB
TypeScript
import { test, expect, noteCard } from './helpers/fixtures.js';
|
|
import type { Page } from '@playwright/test';
|
|
|
|
/** Toggle checklist mode via the overflow menu. */
|
|
async function toggleChecklistMode(page: Page) {
|
|
await page.getByTestId('overflow-menu-btn').click();
|
|
await page.getByTestId('checklist-toggle').click();
|
|
}
|
|
|
|
/** Create a checklist note via UI. Leaves the editor closed. */
|
|
async function createChecklistNote(page: Page, title: string, items: string[]) {
|
|
await page.getByTestId('new-note-btn').click();
|
|
await page.getByTestId('note-title-input').fill(title);
|
|
await toggleChecklistMode(page);
|
|
for (let i = 0; i < items.length; i++) {
|
|
await page.getByTestId('checklist-input').nth(i).focus();
|
|
await page.keyboard.type(items[i]);
|
|
if (i < items.length - 1) {
|
|
await page.getByTestId('checklist-input').nth(i).press('Enter');
|
|
}
|
|
}
|
|
await page.getByTestId('close-editor-btn').click();
|
|
await expect(noteCard(page, title)).toBeVisible();
|
|
}
|
|
|
|
test.describe('Checklist', () => {
|
|
test('Scenario: Checklist replaces the rich text editor when enabled', async ({ authenticatedPage: page }) => {
|
|
// Given the user is creating a new note
|
|
await page.getByTestId('new-note-btn').click();
|
|
|
|
// When the user enables checklist mode
|
|
await toggleChecklistMode(page);
|
|
|
|
// Then the checklist component is displayed
|
|
await expect(page.getByTestId('checklist')).toBeVisible();
|
|
|
|
// And the rich text editor is hidden
|
|
await expect(page.getByTestId('tiptap-editor')).not.toBeVisible();
|
|
});
|
|
|
|
test('Scenario: Checklist item persists after closing and reopening the note', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist note with an item "Buy milk" exists
|
|
await createChecklistNote(page, 'Shopping List', ['Buy milk']);
|
|
|
|
// When the user reopens the note
|
|
await noteCard(page, 'Shopping List').click();
|
|
|
|
// Then the checklist is displayed with the saved item
|
|
await expect(page.getByTestId('checklist')).toBeVisible();
|
|
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
|
|
});
|
|
|
|
test('Scenario: Checked item moves to the done section', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist note with an item "Buy milk" exists
|
|
await createChecklistNote(page, 'Tasks', ['Buy milk']);
|
|
|
|
// When the user reopens the note and checks the item
|
|
await noteCard(page, 'Tasks').click();
|
|
await page.getByTestId('checklist-checkbox').first().click();
|
|
|
|
// Then the item moves to the done section
|
|
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
|
await page.getByTestId('close-editor-btn').click();
|
|
|
|
// And the done state persists after reopening
|
|
await noteCard(page, 'Tasks').click();
|
|
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
|
|
});
|
|
|
|
test('Scenario: Sequential check and uncheck both persist correctly', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist note with an item "Buy milk" exists
|
|
await createChecklistNote(page, 'Tasks2', ['Buy milk']);
|
|
|
|
// When the user checks the item and closes
|
|
await noteCard(page, 'Tasks2').click();
|
|
await page.getByTestId('checklist-checkbox').first().click();
|
|
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
|
await page.getByTestId('close-editor-btn').click();
|
|
|
|
// Then the checked state persists
|
|
await noteCard(page, 'Tasks2').click();
|
|
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
|
|
|
|
// When the user unchecks the item from the done section and closes
|
|
await page.getByTestId('checklist-done-checkbox').first().click();
|
|
await expect(page.getByTestId('checklist-checkbox')).toHaveCount(1);
|
|
await page.getByTestId('close-editor-btn').click();
|
|
|
|
// Then the unchecked state also persists (not reverted by 3-way merge)
|
|
await noteCard(page, 'Tasks2').click();
|
|
await expect(page.getByTestId('checklist-checkbox')).toHaveCount(1);
|
|
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
|
|
});
|
|
|
|
test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist with one item
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('First item');
|
|
|
|
// When the user presses Enter
|
|
await page.getByTestId('checklist-input').first().press('Enter');
|
|
|
|
// Then a second checklist item appears
|
|
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
|
});
|
|
|
|
test('Scenario: Enter key focuses new item on reopened note with checked items before active ones', async ({ authenticatedPage: page }) => {
|
|
// Given a saved checklist note where checked items precede active items
|
|
// (this happens when reopening a note — parseChecklist preserves the
|
|
// saved order rather than grouping active items first)
|
|
await createChecklistNote(page, 'Groceries', ['Milk', 'Eggs', 'Bread', 'Butter']);
|
|
|
|
// Reopen and check items 1 and 2 so saved content has checked items first
|
|
await noteCard(page, 'Groceries').click();
|
|
await page.getByTestId('checklist-checkbox').nth(0).click(); // Milk → done
|
|
await page.getByTestId('checklist-checkbox').nth(0).click(); // Eggs (now first active) → done
|
|
await page.getByTestId('close-editor-btn').click();
|
|
|
|
// Reopen — parseChecklist restores saved order: [x]Milk, [x]Eggs, [ ]Bread, [ ]Butter
|
|
// The checked items precede active ones in items[], but only active ones render as inputs
|
|
await noteCard(page, 'Groceries').click();
|
|
await expect(page.getByTestId('checklist-input')).toHaveCount(2); // Bread, Butter active
|
|
|
|
// When the user presses Enter on the first active item (Bread)
|
|
await page.getByTestId('checklist-input').first().press('Enter');
|
|
|
|
// Then focus moves to the newly created empty item
|
|
await expect(page.getByTestId('checklist-input')).toHaveCount(3);
|
|
await expect(page.getByTestId('checklist-input').nth(1)).toBeFocused();
|
|
await expect(page.getByTestId('checklist-input').nth(1)).toHaveText('');
|
|
});
|
|
|
|
test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist with two items where the second is empty
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('First item');
|
|
await page.getByTestId('checklist-input').first().press('Enter');
|
|
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
|
|
|
// When the user presses Backspace on the empty item
|
|
await page.getByTestId('checklist-input').nth(1).press('Backspace');
|
|
|
|
// Then only the first item remains
|
|
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
|
});
|
|
|
|
test('Scenario: Completed items are separated into a done section', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist note with two items
|
|
await page.getByTestId('new-note-btn').click();
|
|
await page.getByTestId('note-title-input').fill('Hide Done Test');
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('Done task');
|
|
await page.getByTestId('checklist-input').first().press('Enter');
|
|
await page.getByTestId('checklist-input').nth(1).focus();
|
|
await page.keyboard.type('Pending task');
|
|
|
|
// When the user completes the first item
|
|
await page.getByTestId('checklist-checkbox').first().click();
|
|
|
|
// Then only the pending item remains in the active list
|
|
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
|
await expect(page.getByTestId('checklist-input').first()).toHaveText('Pending task');
|
|
|
|
// And the done section shows the completed item
|
|
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
|
await expect(page.getByTestId('checklist-done-section')).toBeVisible();
|
|
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
|
|
});
|
|
|
|
test('Scenario: Arrow keys navigate between checklist items', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist with three items
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('First');
|
|
await page.getByTestId('checklist-input').first().press('Enter');
|
|
await page.getByTestId('checklist-input').nth(1).focus();
|
|
await page.keyboard.type('Second');
|
|
await page.getByTestId('checklist-input').nth(1).press('Enter');
|
|
await page.getByTestId('checklist-input').nth(2).focus();
|
|
await page.keyboard.type('Third');
|
|
|
|
// When the user presses ArrowUp from the third item
|
|
await page.getByTestId('checklist-input').nth(2).press('ArrowUp');
|
|
|
|
// Then focus moves to the second item
|
|
await expect(page.getByTestId('checklist-input').nth(1)).toBeFocused();
|
|
|
|
// When the user presses ArrowDown
|
|
await page.getByTestId('checklist-input').nth(1).press('ArrowDown');
|
|
|
|
// Then focus moves back to the third item
|
|
await expect(page.getByTestId('checklist-input').nth(2)).toBeFocused();
|
|
});
|
|
|
|
test('Scenario: Tab indents an item and Shift+Tab outdents it', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist with two top-level items
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('Parent');
|
|
await page.getByTestId('checklist-input').first().press('Enter');
|
|
await page.getByTestId('checklist-input').nth(1).focus();
|
|
await page.keyboard.type('Child');
|
|
|
|
// When the user presses Tab on the second item
|
|
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
|
|
|
// Then the item becomes indented
|
|
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
|
|
|
|
// When the user presses Shift+Tab
|
|
await page.getByTestId('checklist-input').nth(1).press('Shift+Tab');
|
|
|
|
// Then the item returns to top-level
|
|
await expect(page.getByTestId('checklist-child-row')).toHaveCount(0);
|
|
});
|
|
|
|
test('Scenario: Checking a parent checks all its children', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist with a parent and two children
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('Buy groceries');
|
|
await page.getByTestId('checklist-input').first().press('Enter');
|
|
await page.getByTestId('checklist-input').nth(1).focus();
|
|
await page.keyboard.type('Milk');
|
|
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
|
await page.getByTestId('checklist-input').nth(1).press('Enter');
|
|
await page.getByTestId('checklist-input').nth(2).focus();
|
|
await page.keyboard.type('Eggs');
|
|
|
|
// When the user checks the parent
|
|
await page.getByTestId('checklist-checkbox').first().click();
|
|
|
|
// Then all items move to done section
|
|
await expect(page.getByTestId('checklist-toggle-done')).toContainText('3 done');
|
|
await expect(page.getByTestId('checklist-input')).toHaveCount(0);
|
|
});
|
|
|
|
test('Scenario: Checking a child shows read-only parent label in done section', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist with a parent and a child
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('Groceries');
|
|
await page.getByTestId('checklist-input').first().press('Enter');
|
|
await page.getByTestId('checklist-input').nth(1).focus();
|
|
await page.keyboard.type('Milk');
|
|
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
|
|
|
// When the user checks only the child
|
|
await page.getByTestId('checklist-checkbox').nth(1).click();
|
|
|
|
// Then the done section shows a parent label above the checked child
|
|
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
|
await expect(page.getByTestId('checklist-done-parent-label')).toBeVisible();
|
|
await expect(page.getByTestId('checklist-done-parent-label')).toContainText('Groceries');
|
|
});
|
|
|
|
test('Scenario: Unchecking a parent from done restores group to active', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist with a parent and child, all checked
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('Shopping');
|
|
await page.getByTestId('checklist-input').first().press('Enter');
|
|
await page.getByTestId('checklist-input').nth(1).focus();
|
|
await page.keyboard.type('Milk');
|
|
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
|
await page.getByTestId('checklist-checkbox').first().click();
|
|
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
|
|
|
|
// When the user unchecks the parent from the done section
|
|
await page.getByTestId('checklist-done-checkbox').first().click();
|
|
|
|
// Then both parent and child return to active
|
|
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
|
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
|
|
});
|
|
|
|
test('Scenario: Nested items persist after close and reopen', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist with nested items
|
|
await createChecklistNote(page, 'Nested Persist', ['Parent', 'Child']);
|
|
|
|
// Reopen and indent the second item
|
|
await noteCard(page, 'Nested Persist').click();
|
|
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
|
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
|
|
await page.getByTestId('close-editor-btn').click();
|
|
|
|
// When the user reopens the note
|
|
await noteCard(page, 'Nested Persist').click();
|
|
|
|
// Then the nesting is preserved
|
|
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
|
|
});
|
|
|
|
test('Scenario: Enter on a child creates a sibling at the same level', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist with a parent and a child
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('Parent');
|
|
await page.getByTestId('checklist-input').first().press('Enter');
|
|
await page.getByTestId('checklist-input').nth(1).focus();
|
|
await page.keyboard.type('Child 1');
|
|
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
|
|
|
// When the user presses Enter on the child
|
|
await page.getByTestId('checklist-input').nth(1).press('Enter');
|
|
|
|
// Then a new child is created at the same level
|
|
await expect(page.getByTestId('checklist-child-row')).toHaveCount(2);
|
|
await expect(page.getByTestId('checklist-input').nth(2)).toBeFocused();
|
|
});
|
|
|
|
test('Scenario: NoteCard preview shows indentation for nested items', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist note with nested items
|
|
await createChecklistNote(page, 'Indent Preview Note', ['Parent', 'Child']);
|
|
await noteCard(page, 'Indent Preview Note').click();
|
|
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
|
await page.getByTestId('close-editor-btn').click();
|
|
|
|
// Then the NoteCard preview shows the child indented (scoped to this card)
|
|
const card = noteCard(page, 'Indent Preview Note');
|
|
await expect(card.getByTestId('card-checklist-child')).toHaveCount(1);
|
|
});
|
|
|
|
test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => {
|
|
// Given checklist mode is enabled on a new note
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await expect(page.getByTestId('checklist')).toBeVisible();
|
|
|
|
// When the user disables checklist mode
|
|
await toggleChecklistMode(page);
|
|
|
|
// Then the rich text editor is displayed again
|
|
await expect(page.getByTestId('tiptap-editor')).toBeVisible();
|
|
await expect(page.getByTestId('checklist')).not.toBeVisible();
|
|
});
|
|
|
|
test('Scenario: Delete checked items removes only done items', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist note with two checked and one unchecked item
|
|
await createChecklistNote(page, 'Cleanup', ['Keep me', 'Done 1', 'Done 2']);
|
|
await noteCard(page, 'Cleanup').click();
|
|
await page.getByTestId('checklist-checkbox').nth(1).click();
|
|
await page.getByTestId('checklist-checkbox').nth(1).click();
|
|
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
|
|
|
|
// When the user deletes checked items via the overflow menu
|
|
await page.getByTestId('overflow-menu-btn').click();
|
|
await page.getByTestId('delete-checked-btn').click();
|
|
|
|
// Then only the unchecked item remains
|
|
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
|
await expect(page.getByTestId('checklist-input').first()).toHaveText('Keep me');
|
|
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
|
|
});
|
|
|
|
test('Scenario: Uncheck all items moves everything back to active', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist note with checked items
|
|
await createChecklistNote(page, 'Reset', ['Item A', 'Item B']);
|
|
await noteCard(page, 'Reset').click();
|
|
await page.getByTestId('checklist-checkbox').first().click();
|
|
await page.getByTestId('checklist-checkbox').first().click();
|
|
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
|
|
|
|
// When the user unchecks all via the overflow menu
|
|
await page.getByTestId('overflow-menu-btn').click();
|
|
await page.getByTestId('uncheck-all-btn').click();
|
|
|
|
// Then all items are back in the active section
|
|
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
|
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
|
|
});
|
|
|
|
test('Scenario: URL typed in a checklist item becomes a clickable link', async ({ authenticatedPage: page }) => {
|
|
// Given a new checklist note
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
|
|
// When the user types a URL and leaves the field
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('check https://example.com today');
|
|
await page.getByTestId('note-title-input').focus();
|
|
|
|
// Then the URL is rendered as a link
|
|
const link = page.getByTestId('checklist-input').first().locator('a');
|
|
await expect(link).toBeVisible();
|
|
await expect(link).toHaveAttribute('href', 'https://example.com');
|
|
});
|
|
|
|
test('Scenario: Clicking a link in a checklist item shows the link popover', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist item with a URL
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('https://example.com');
|
|
await page.getByTestId('note-title-input').focus();
|
|
|
|
// When the user clicks the link
|
|
await page.getByTestId('checklist-input').first().locator('a').click();
|
|
|
|
// Then the link popover appears with an Open button
|
|
await expect(page.getByTestId('link-popover')).toBeVisible();
|
|
await expect(page.getByTestId('link-popover-open')).toBeVisible();
|
|
});
|
|
|
|
test('Scenario: Link popover dismisses on Escape', async ({ authenticatedPage: page }) => {
|
|
// Given a link popover is open
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('https://example.com');
|
|
await page.getByTestId('note-title-input').focus();
|
|
await page.getByTestId('checklist-input').first().locator('a').click();
|
|
await expect(page.getByTestId('link-popover')).toBeVisible();
|
|
|
|
// When the user presses Escape
|
|
await page.keyboard.press('Escape');
|
|
|
|
// Then the popover is dismissed
|
|
await expect(page.getByTestId('link-popover')).not.toBeVisible();
|
|
});
|
|
|
|
test('Scenario: Links in done section are clickable', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist item with a URL is checked
|
|
await page.getByTestId('new-note-btn').click();
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('https://example.com');
|
|
await page.getByTestId('checklist-checkbox').first().click();
|
|
|
|
// Then the done section shows the URL as a link
|
|
await expect(page.getByTestId('checklist-done-section').locator('a.checklist-link')).toBeVisible();
|
|
});
|
|
|
|
test('Scenario: Links in NoteCard preview are rendered', async ({ authenticatedPage: page }) => {
|
|
// Given a checklist note with a URL
|
|
await page.getByTestId('new-note-btn').click();
|
|
await page.getByTestId('note-title-input').fill('Link Note');
|
|
await toggleChecklistMode(page);
|
|
await page.getByTestId('checklist-input').first().focus();
|
|
await page.keyboard.type('visit https://example.com');
|
|
await page.getByTestId('close-editor-btn').click();
|
|
|
|
// Then the NoteCard preview shows the URL as a link
|
|
const card = noteCard(page, 'Link Note');
|
|
await expect(card.locator('a.checklist-link')).toBeVisible();
|
|
await expect(card.locator('a.checklist-link')).toHaveAttribute('href', 'https://example.com');
|
|
});
|
|
});
|