feat: Memento avec dates, Markdown, reminders et auth

Tests Playwright validés :
- Création de notes: OK
- Modification titre: OK
- Modification contenu: OK
- Markdown éditable avec preview: OK

Fonctionnalités:
- date-fns: dates relatives sur cards
- react-markdown + remark-gfm
- Markdown avec toggle edit/preview
- Recherche améliorée (titre/contenu/labels/checkItems)
- Reminder recurrence/location (schema)
- NextAuth.js + User/Account/Session
- userId dans Note (optionnel)
- 4 migrations créées

Ready for production + auth integration
This commit is contained in:
2026-01-04 16:04:24 +01:00
parent 2de2958b7a
commit f0b41572bc
25 changed files with 4220 additions and 142 deletions

View File

@@ -0,0 +1,181 @@
import { test, expect } from '@playwright/test';
test.describe('Note Grid - Drag and Drop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Create multiple notes for testing drag and drop
for (let i = 1; i <= 4; i++) {
await page.click('input[placeholder="Take a note..."]');
await page.fill('input[placeholder="Title"]', `Note ${i}`);
await page.fill('textarea[placeholder="Take a note..."]', `Content ${i}`);
await page.click('button:has-text("Add")');
await page.waitForTimeout(500);
}
});
test('should have draggable notes', async ({ page }) => {
// Wait for notes to appear
await page.waitForSelector('text=Note 1');
// Check that notes have draggable attribute
const noteCards = page.locator('[draggable="true"]');
const count = await noteCards.count();
expect(count).toBeGreaterThanOrEqual(4);
});
test('should show cursor-move on note cards', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Check CSS class for cursor-move
const firstNote = page.locator('[draggable="true"]').first();
const className = await firstNote.getAttribute('class');
expect(className).toContain('cursor-move');
});
test('should change opacity when dragging', async ({ page }) => {
await page.waitForSelector('text=Note 1');
const firstNote = page.locator('[draggable="true"]').first();
// Start drag
const box = await firstNote.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
// Check if opacity changed (isDragging class)
await page.waitForTimeout(100);
const className = await firstNote.getAttribute('class');
// The dragged note should have opacity-30 class
// Note: This is tricky with Playwright, might need visual regression testing
await page.mouse.up();
}
});
test('should reorder notes when dropped on another note', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Get initial order
const notes = page.locator('[draggable="true"]');
const firstNoteText = await notes.first().textContent();
const secondNoteText = await notes.nth(1).textContent();
expect(firstNoteText).toContain('Note');
expect(secondNoteText).toContain('Note');
// Drag first note to second position
const firstNote = notes.first();
const secondNote = notes.nth(1);
const firstBox = await firstNote.boundingBox();
const secondBox = await secondNote.boundingBox();
if (firstBox && secondBox) {
await page.mouse.move(firstBox.x + firstBox.width / 2, firstBox.y + firstBox.height / 2);
await page.mouse.down();
await page.mouse.move(secondBox.x + secondBox.width / 2, secondBox.y + secondBox.height / 2);
await page.mouse.up();
// Wait for reorder to complete
await page.waitForTimeout(1000);
// Check that order changed
// Note: This depends on the order persisting in the database
await page.reload();
await page.waitForSelector('text=Note');
// Verify the order changed (implementation dependent)
}
});
test('should work with pinned and unpinned notes separately', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Pin first note
const firstNote = page.locator('text=Note 1').first();
await firstNote.hover();
await page.click('button[title*="Pin"]:visible').first();
await page.waitForTimeout(500);
// Check that "Pinned" section appears
await expect(page.locator('text=Pinned')).toBeVisible();
// Verify note is in pinned section
const pinnedSection = page.locator('h2:has-text("Pinned")').locator('..').locator('..');
await expect(pinnedSection.locator('text=Note 1')).toBeVisible();
});
test('should not mix pinned and unpinned notes when dragging', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Pin first note
const firstNote = page.locator('text=Note 1').first();
await firstNote.hover();
await page.click('button[title*="Pin"]:visible').first();
await page.waitForTimeout(500);
// Should have both Pinned and Others sections
await expect(page.locator('text=Pinned')).toBeVisible();
await expect(page.locator('text=Others')).toBeVisible();
// Count notes in each section
const pinnedNotes = page.locator('h2:has-text("Pinned") ~ div [draggable="true"]');
const unpinnedNotes = page.locator('h2:has-text("Others") ~ div [draggable="true"]');
expect(await pinnedNotes.count()).toBeGreaterThanOrEqual(1);
expect(await unpinnedNotes.count()).toBeGreaterThanOrEqual(3);
});
test('should persist note order after page reload', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Get initial order
const notes = page.locator('[draggable="true"]');
const initialOrder: string[] = [];
const count = await notes.count();
for (let i = 0; i < Math.min(count, 4); i++) {
const text = await notes.nth(i).textContent();
if (text) initialOrder.push(text);
}
// Reload page
await page.reload();
await page.waitForSelector('text=Note');
// Get order after reload
const notesAfterReload = page.locator('[draggable="true"]');
const reloadedOrder: string[] = [];
const countAfterReload = await notesAfterReload.count();
for (let i = 0; i < Math.min(countAfterReload, 4); i++) {
const text = await notesAfterReload.nth(i).textContent();
if (text) reloadedOrder.push(text);
}
// Order should be the same
expect(reloadedOrder.length).toBe(initialOrder.length);
});
test.afterEach(async ({ page }) => {
// Clean up created notes
const notes = page.locator('[draggable="true"]');
const count = await notes.count();
for (let i = 0; i < count; i++) {
const note = notes.first();
await note.hover();
await page.click('button:has(svg.lucide-more-vertical)').first();
await page.click('text=Delete').first();
// Confirm delete
page.once('dialog', dialog => dialog.accept());
await page.waitForTimeout(300);
}
});
});

View File

@@ -0,0 +1,423 @@
import { test, expect } from '@playwright/test';
test.describe('Note Input - Reminder Dialog', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Expand the note input
await page.click('input[placeholder="Take a note..."]');
await expect(page.locator('input[placeholder="Title"]')).toBeVisible();
});
test('should open dialog when clicking Bell icon (not prompt)', async ({ page }) => {
// Set up listener for prompt dialogs - should NOT appear
let promptAppeared = false;
page.on('dialog', () => {
promptAppeared = true;
});
// Click the Bell button
const bellButton = page.locator('button:has(svg.lucide-bell)');
await bellButton.click();
// Verify dialog opened (NOT a browser prompt)
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible();
// Verify dialog title
await expect(page.locator('h2:has-text("Set Reminder")')).toBeVisible();
// Verify no prompt appeared
expect(promptAppeared).toBe(false);
// Verify date and time inputs exist
await expect(page.locator('input[type="date"]')).toBeVisible();
await expect(page.locator('input[type="time"]')).toBeVisible();
// Verify buttons
await expect(page.locator('button:has-text("Cancel")')).toBeVisible();
await expect(page.locator('button:has-text("Set Reminder")')).toBeVisible();
});
test('should have default values (tomorrow 9am)', async ({ page }) => {
// Click Bell
await page.click('button:has(svg.lucide-bell)');
// Get tomorrow's date
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const expectedDate = tomorrow.toISOString().split('T')[0];
// Check date input
const dateInput = page.locator('input[type="date"]');
await expect(dateInput).toHaveValue(expectedDate);
// Check time input
const timeInput = page.locator('input[type="time"]');
await expect(timeInput).toHaveValue('09:00');
});
test('should close dialog on Cancel', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
await expect(page.locator('[role="dialog"]')).toBeVisible();
// Click Cancel
await page.click('button:has-text("Cancel")');
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
test('should close dialog on X button', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
await expect(page.locator('[role="dialog"]')).toBeVisible();
// Click X button (close button in dialog)
const closeButton = page.locator('[role="dialog"] button[data-slot="dialog-close"]');
await closeButton.click();
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
test('should validate empty date/time', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Clear date
const dateInput = page.locator('input[type="date"]');
await dateInput.fill('');
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show warning toast (not close dialog)
// We look for the toast notification
await expect(page.locator('text=Please enter date and time')).toBeVisible();
// Dialog should still be open
await expect(page.locator('[role="dialog"]')).toBeVisible();
});
test('should validate past date', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set date to yesterday
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const pastDate = yesterday.toISOString().split('T')[0];
const dateInput = page.locator('input[type="date"]');
await dateInput.fill(pastDate);
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show error toast
await expect(page.locator('text=Reminder must be in the future')).toBeVisible();
// Dialog should still be open
await expect(page.locator('[role="dialog"]')).toBeVisible();
});
test('should set reminder successfully with valid date', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set future date (tomorrow already default)
const timeInput = page.locator('input[type="time"]');
await timeInput.fill('14:30');
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show success toast
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
test('should clear fields after successful reminder', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set and confirm
await page.click('button:has-text("Set Reminder")');
// Wait for dialog to close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
// Open again
await page.click('button:has(svg.lucide-bell)');
// Should have default values again (tomorrow 9am)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const expectedDate = tomorrow.toISOString().split('T')[0];
await expect(page.locator('input[type="date"]')).toHaveValue(expectedDate);
await expect(page.locator('input[type="time"]')).toHaveValue('09:00');
});
test('should allow custom date and time selection', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set custom date (next week)
const nextWeek = new Date();
nextWeek.setDate(nextWeek.getDate() + 7);
const customDate = nextWeek.toISOString().split('T')[0];
const dateInput = page.locator('input[type="date"]');
await dateInput.fill(customDate);
// Set custom time
const timeInput = page.locator('input[type="time"]');
await timeInput.fill('15:45');
// Submit
await page.click('button:has-text("Set Reminder")');
// Should show success with the date/time
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
});
test.describe('Note Editor - Reminder Dialog', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Create a test note
await page.click('input[placeholder="Take a note..."]');
await page.fill('input[placeholder="Title"]', 'Test Note for Reminder');
await page.fill('textarea[placeholder="Take a note..."]', 'This note will have a reminder');
await page.click('button:has-text("Add")');
await page.waitForTimeout(500);
// Open the note for editing
await page.click('text=Test Note for Reminder');
await page.waitForTimeout(300);
});
test('should open reminder dialog in note editor', async ({ page }) => {
// Click the Bell button in note editor
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
await bellButton.click();
// Should open a second dialog for reminder
await page.waitForTimeout(300);
// Verify reminder dialog opened
await expect(page.locator('h2:has-text("Set Reminder")')).toBeVisible();
// Verify date and time inputs exist
await expect(page.locator('input[type="date"]')).toBeVisible();
await expect(page.locator('input[type="time"]')).toBeVisible();
});
test('should set reminder on existing note', async ({ page }) => {
// Click Bell button
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
// Set date and time
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '14:30');
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show success toast
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
// Reminder dialog should close
await page.waitForTimeout(300);
await expect(page.locator('h2:has-text("Set Reminder")')).not.toBeVisible();
});
test('should show bell button as active when reminder is set', async ({ page }) => {
// Set reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Bell button should have active styling (text-blue-600)
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).toContain('text-blue-600');
});
test('should allow editing existing reminder', async ({ page }) => {
// Set initial reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Open reminder dialog again
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
// Should show previous values
const dateInput = page.locator('input[type="date"]');
const timeInput = page.locator('input[type="time"]');
await expect(dateInput).toHaveValue(dateString);
await expect(timeInput).toHaveValue('10:00');
// Change time
await timeInput.fill('15:00');
await page.click('button:has-text("Set Reminder")');
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
});
test('should allow removing reminder', async ({ page }) => {
// Set reminder first
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Open reminder dialog again
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
// Should see "Remove Reminder" button
await expect(page.locator('button:has-text("Remove Reminder")')).toBeVisible();
// Click Remove Reminder
await page.click('button:has-text("Remove Reminder")');
// Should show success toast
await expect(page.locator('text=Reminder removed')).toBeVisible();
// Bell button should not be active anymore
await page.waitForTimeout(300);
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).not.toContain('text-blue-600');
});
test('should persist reminder after saving note', async ({ page }) => {
// Set reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '14:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Save the note
await page.click('button:has-text("Save")');
await page.waitForTimeout(500);
// Reopen the note
await page.click('text=Test Note for Reminder');
await page.waitForTimeout(300);
// Bell button should still be active
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).toContain('text-blue-600');
// Open reminder dialog to verify values
await bellButton.click();
await page.waitForTimeout(200);
const dateInput = page.locator('input[type="date"]');
const timeInput = page.locator('input[type="time"]');
await expect(dateInput).toHaveValue(dateString);
await expect(timeInput).toHaveValue('14:00');
});
test('should show bell icon on note card when reminder is set', async ({ page }) => {
// Set reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Save and close
await page.click('button:has-text("Save")');
await page.waitForTimeout(500);
// Check that note card has bell icon
const noteCard = page.locator('text=Test Note for Reminder').locator('..');
await expect(noteCard.locator('svg.lucide-bell')).toBeVisible();
});
test.afterEach(async ({ page }) => {
// Close any open dialogs
const dialogs = page.locator('[role="dialog"]');
const count = await dialogs.count();
for (let i = 0; i < count; i++) {
const cancelButton = page.locator('button:has-text("Cancel")').first();
if (await cancelButton.isVisible()) {
await cancelButton.click();
await page.waitForTimeout(200);
}
}
// Delete test note
try {
const testNote = page.locator('text=Test Note for Reminder').first();
if (await testNote.isVisible()) {
await testNote.hover();
await page.click('button:has(svg.lucide-more-vertical)').first();
await page.click('text=Delete').first();
// Confirm delete
page.once('dialog', dialog => dialog.accept());
await page.waitForTimeout(300);
}
} catch (e) {
// Note might already be deleted
}
});
});

View File

@@ -0,0 +1,167 @@
import { test, expect } from '@playwright/test';
test.describe('Note Input - Undo/Redo', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Expand the note input
await page.click('input[placeholder="Take a note..."]');
await expect(page.locator('input[placeholder="Title"]')).toBeVisible();
});
test('should save history after 1 second of inactivity', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type "Hello"
await contentArea.fill('Hello');
// Wait for debounce to save
await page.waitForTimeout(1100);
// Type " World"
await contentArea.fill('Hello World');
// Wait for debounce
await page.waitForTimeout(1100);
// Click Undo button
const undoButton = page.locator('button:has(svg.lucide-undo-2)');
await expect(undoButton).toBeEnabled();
await undoButton.click();
// Should show "Hello" only
await expect(contentArea).toHaveValue('Hello');
// Undo should now be disabled (back to initial state)
// Actually not disabled, there's the initial empty state
await undoButton.click();
await expect(contentArea).toHaveValue('');
// Undo should be disabled now
await expect(undoButton).toBeDisabled();
// Click Redo
const redoButton = page.locator('button:has(svg.lucide-redo-2)');
await expect(redoButton).toBeEnabled();
await redoButton.click();
// Should show "Hello"
await expect(contentArea).toHaveValue('Hello');
// Redo again
await redoButton.click();
await expect(contentArea).toHaveValue('Hello World');
// Redo should be disabled now
await expect(redoButton).toBeDisabled();
});
test('should undo/redo with keyboard shortcuts', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type and wait
await contentArea.fill('First');
await page.waitForTimeout(1100);
await contentArea.fill('Second');
await page.waitForTimeout(1100);
// Ctrl+Z to undo
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('First');
// Ctrl+Z again
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('');
// Ctrl+Y to redo
await page.keyboard.press('Control+y');
await expect(contentArea).toHaveValue('First');
// Ctrl+Shift+Z also works for redo
await page.keyboard.press('Control+Shift+z');
await expect(contentArea).toHaveValue('Second');
});
test('should work with title and content', async ({ page }) => {
const titleInput = page.locator('input[placeholder="Title"]');
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type title
await titleInput.fill('My Title');
await page.waitForTimeout(1100);
// Type content
await contentArea.fill('My Content');
await page.waitForTimeout(1100);
// Undo
await page.keyboard.press('Control+z');
await expect(titleInput).toHaveValue('My Title');
await expect(contentArea).toHaveValue('');
// Undo again
await page.keyboard.press('Control+z');
await expect(titleInput).toHaveValue('');
await expect(contentArea).toHaveValue('');
});
test('should reset history after creating note', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
const undoButton = page.locator('button:has(svg.lucide-undo-2)');
// Type something
await contentArea.fill('Test note');
await page.waitForTimeout(1100);
// Undo should be enabled
await expect(undoButton).toBeEnabled();
// Submit note
await page.click('button:has-text("Add")');
// Wait for note to be created and form to reset
await page.waitForTimeout(500);
// Expand again
await page.click('input[placeholder="Take a note..."]');
// Undo should be disabled (fresh start)
await expect(undoButton).toBeDisabled();
});
test('should not create history during undo/redo', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type "A"
await contentArea.fill('A');
await page.waitForTimeout(1100);
// Type "B"
await contentArea.fill('B');
await page.waitForTimeout(1100);
// Type "C"
await contentArea.fill('C');
await page.waitForTimeout(1100);
// Undo to B
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('B');
// Undo to A
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('A');
// Redo to B
await page.keyboard.press('Control+y');
await expect(contentArea).toHaveValue('B');
// Redo to C
await page.keyboard.press('Control+y');
await expect(contentArea).toHaveValue('C');
// Should not be able to redo further
const redoButton = page.locator('button:has(svg.lucide-redo-2)');
await expect(redoButton).toBeDisabled();
});
});