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:
@@ -58,8 +58,10 @@ export async function searchNotes(query: string) {
|
||||
where: {
|
||||
isArchived: false,
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ content: { contains: query, mode: 'insensitive' } }
|
||||
{ title: { contains: query } },
|
||||
{ content: { contains: query } },
|
||||
{ labels: { contains: query } },
|
||||
{ checkItems: { contains: query } }
|
||||
]
|
||||
},
|
||||
orderBy: [
|
||||
@@ -68,7 +70,38 @@ export async function searchNotes(query: string) {
|
||||
]
|
||||
})
|
||||
|
||||
return notes.map(parseNote)
|
||||
// Enhanced ranking: prioritize title matches
|
||||
const rankedNotes = notes.map(note => {
|
||||
const parsedNote = parseNote(note)
|
||||
let score = 0
|
||||
|
||||
// Title match gets highest score
|
||||
if (parsedNote.title?.toLowerCase().includes(query.toLowerCase())) {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Content match
|
||||
if (parsedNote.content.toLowerCase().includes(query.toLowerCase())) {
|
||||
score += 5
|
||||
}
|
||||
|
||||
// Label match
|
||||
if (parsedNote.labels?.some(label => label.toLowerCase().includes(query.toLowerCase()))) {
|
||||
score += 3
|
||||
}
|
||||
|
||||
// CheckItems match
|
||||
if (parsedNote.checkItems?.some(item => item.text.toLowerCase().includes(query.toLowerCase()))) {
|
||||
score += 2
|
||||
}
|
||||
|
||||
return { note: parsedNote, score }
|
||||
})
|
||||
|
||||
// Sort by score descending, then by existing order (pinned/updated)
|
||||
return rankedNotes
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(item => item.note)
|
||||
} catch (error) {
|
||||
console.error('Error searching notes:', error)
|
||||
return []
|
||||
@@ -85,6 +118,8 @@ export async function createNote(data: {
|
||||
labels?: string[]
|
||||
images?: string[]
|
||||
isArchived?: boolean
|
||||
reminder?: Date | null
|
||||
isMarkdown?: boolean
|
||||
}) {
|
||||
try {
|
||||
const note = await prisma.note.create({
|
||||
@@ -97,6 +132,8 @@ export async function createNote(data: {
|
||||
labels: data.labels ? JSON.stringify(data.labels) : null,
|
||||
images: data.images ? JSON.stringify(data.images) : null,
|
||||
isArchived: data.isArchived || false,
|
||||
reminder: data.reminder || null,
|
||||
isMarkdown: data.isMarkdown || false,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -119,6 +156,8 @@ export async function updateNote(id: string, data: {
|
||||
checkItems?: CheckItem[] | null
|
||||
labels?: string[] | null
|
||||
images?: string[] | null
|
||||
reminder?: Date | null
|
||||
isMarkdown?: boolean
|
||||
}) {
|
||||
try {
|
||||
// Stringify JSON fields if they exist
|
||||
|
||||
19
keep-notes/components/markdown-content.tsx
Normal file
19
keep-notes/components/markdown-content.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
|
||||
return (
|
||||
<div className={`prose prose-sm dark:prose-invert max-w-none ${className}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,10 +20,14 @@ import {
|
||||
Pin,
|
||||
Tag,
|
||||
Trash2,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note
|
||||
@@ -105,6 +109,16 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
|
||||
<Pin className="absolute top-3 right-3 h-4 w-4 text-gray-600 dark:text-gray-400" fill="currentColor" />
|
||||
)}
|
||||
|
||||
{/* Reminder Icon */}
|
||||
{note.reminder && new Date(note.reminder) > new Date() && (
|
||||
<Bell
|
||||
className={cn(
|
||||
"absolute h-4 w-4 text-blue-600 dark:text-blue-400",
|
||||
note.isPinned ? "top-3 right-9" : "top-3 right-3"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{note.title && (
|
||||
<h3 className="text-base font-medium mb-2 pr-6 text-gray-900 dark:text-gray-100">
|
||||
@@ -173,9 +187,15 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
|
||||
|
||||
{/* Content */}
|
||||
{note.type === 'text' ? (
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
|
||||
{note.content}
|
||||
</p>
|
||||
note.isMarkdown ? (
|
||||
<div className="text-sm line-clamp-10">
|
||||
<MarkdownContent content={note.content} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
|
||||
{note.content}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{note.checkItems?.map((item) => (
|
||||
@@ -217,6 +237,11 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Creation Date */}
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: fr })}
|
||||
</div>
|
||||
|
||||
{/* Action Bar - Shows on Hover */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 p-2 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { X, Plus, Palette, Tag, Image as ImageIcon } from 'lucide-react'
|
||||
import { X, Plus, Palette, Tag, Image as ImageIcon, Bell, FileText, Eye } from 'lucide-react'
|
||||
import { updateNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
|
||||
interface NoteEditorProps {
|
||||
note: Note
|
||||
@@ -28,6 +30,7 @@ interface NoteEditorProps {
|
||||
}
|
||||
|
||||
export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
const { addToast } = useToast()
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
const [content, setContent] = useState(note.content)
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||
@@ -36,7 +39,15 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
const [color, setColor] = useState(note.color)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Reminder state
|
||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||
const [reminderDate, setReminderDate] = useState('')
|
||||
const [reminderTime, setReminderTime] = useState('')
|
||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
|
||||
|
||||
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
|
||||
|
||||
@@ -57,6 +68,49 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
setImages(images.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleReminderOpen = () => {
|
||||
if (currentReminder) {
|
||||
const date = new Date(currentReminder)
|
||||
setReminderDate(date.toISOString().split('T')[0])
|
||||
setReminderTime(date.toTimeString().slice(0, 5))
|
||||
} else {
|
||||
const tomorrow = new Date(Date.now() + 86400000)
|
||||
setReminderDate(tomorrow.toISOString().split('T')[0])
|
||||
setReminderTime('09:00')
|
||||
}
|
||||
setShowReminderDialog(true)
|
||||
}
|
||||
|
||||
const handleReminderSave = () => {
|
||||
if (!reminderDate || !reminderTime) {
|
||||
addToast('Please enter date and time', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const dateTimeString = `${reminderDate}T${reminderTime}`
|
||||
const date = new Date(dateTimeString)
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
addToast('Invalid date or time', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (date < new Date()) {
|
||||
addToast('Reminder must be in the future', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentReminder(date)
|
||||
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
|
||||
setShowReminderDialog(false)
|
||||
}
|
||||
|
||||
const handleRemoveReminder = () => {
|
||||
setCurrentReminder(null)
|
||||
setShowReminderDialog(false)
|
||||
addToast('Reminder removed', 'success')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
@@ -67,6 +121,8 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
labels,
|
||||
images,
|
||||
color,
|
||||
reminder: currentReminder,
|
||||
isMarkdown,
|
||||
})
|
||||
onClose()
|
||||
} catch (error) {
|
||||
@@ -158,12 +214,58 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
|
||||
{/* Content or Checklist */}
|
||||
{note.type === 'text' ? (
|
||||
<Textarea
|
||||
placeholder="Take a note..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{/* Markdown controls */}
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsMarkdown(!isMarkdown)
|
||||
if (isMarkdown) setShowMarkdownPreview(false)
|
||||
}}
|
||||
className={cn("h-7 text-xs", isMarkdown && "text-blue-600")}
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
{isMarkdown ? 'Markdown ON' : 'Markdown OFF'}
|
||||
</Button>
|
||||
|
||||
{isMarkdown && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{showMarkdownPreview ? (
|
||||
<>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Edit
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMarkdownPreview && isMarkdown ? (
|
||||
<MarkdownContent
|
||||
content={content || '*No content*'}
|
||||
className="min-h-[200px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{checkItems.map((item) => (
|
||||
@@ -221,6 +323,17 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Reminder Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReminderOpen}
|
||||
title="Set reminder"
|
||||
className={currentReminder ? "text-blue-600" : ""}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Add Image Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -303,6 +416,58 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
{/* Reminder Dialog */}
|
||||
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Reminder</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-date" className="text-sm font-medium">
|
||||
Date
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-date"
|
||||
type="date"
|
||||
value={reminderDate}
|
||||
onChange={(e) => setReminderDate(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-time" className="text-sm font-medium">
|
||||
Time
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-time"
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
{currentReminder && (
|
||||
<Button variant="outline" onClick={handleRemoveReminder}>
|
||||
Remove Reminder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleReminderSave}>
|
||||
Set Reminder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
Archive,
|
||||
MoreVertical,
|
||||
Undo2,
|
||||
Redo2
|
||||
Redo2,
|
||||
FileText,
|
||||
Eye
|
||||
} from 'lucide-react'
|
||||
import { createNote } from '@/app/actions/notes'
|
||||
import { CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
@@ -34,6 +36,13 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
|
||||
interface HistoryState {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
interface NoteState {
|
||||
title: string
|
||||
@@ -56,6 +65,86 @@ export function NoteInput() {
|
||||
const [content, setContent] = useState('')
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [isMarkdown, setIsMarkdown] = useState(false)
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
||||
|
||||
// Undo/Redo history (title and content only)
|
||||
const [history, setHistory] = useState<HistoryState[]>([{ title: '', content: '' }])
|
||||
const [historyIndex, setHistoryIndex] = useState(0)
|
||||
const isUndoingRef = useRef(false)
|
||||
|
||||
// Reminder dialog
|
||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||
const [reminderDate, setReminderDate] = useState('')
|
||||
const [reminderTime, setReminderTime] = useState('')
|
||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(null)
|
||||
|
||||
// Save to history after 1 second of inactivity
|
||||
useEffect(() => {
|
||||
if (isUndoingRef.current) {
|
||||
isUndoingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const currentState = { title, content }
|
||||
const lastState = history[historyIndex]
|
||||
|
||||
if (lastState.title !== title || lastState.content !== content) {
|
||||
const newHistory = history.slice(0, historyIndex + 1)
|
||||
newHistory.push(currentState)
|
||||
|
||||
if (newHistory.length > 50) {
|
||||
newHistory.shift()
|
||||
} else {
|
||||
setHistoryIndex(historyIndex + 1)
|
||||
}
|
||||
setHistory(newHistory)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [title, content, history, historyIndex])
|
||||
|
||||
// Undo/Redo functions
|
||||
const handleUndo = () => {
|
||||
if (historyIndex > 0) {
|
||||
isUndoingRef.current = true
|
||||
const newIndex = historyIndex - 1
|
||||
setHistoryIndex(newIndex)
|
||||
setTitle(history[newIndex].title)
|
||||
setContent(history[newIndex].content)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRedo = () => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
isUndoingRef.current = true
|
||||
const newIndex = historyIndex + 1
|
||||
setHistoryIndex(newIndex)
|
||||
setTitle(history[newIndex].title)
|
||||
setContent(history[newIndex].content)
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isExpanded) return
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleUndo()
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||
e.preventDefault()
|
||||
handleRedo()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isExpanded, historyIndex, history])
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
@@ -91,27 +180,37 @@ export function NoteInput() {
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleReminder = () => {
|
||||
const reminderDate = prompt('Enter reminder date and time (e.g., 2026-01-10 14:30):')
|
||||
if (!reminderDate) return
|
||||
|
||||
try {
|
||||
const date = new Date(reminderDate)
|
||||
if (isNaN(date.getTime())) {
|
||||
addToast('Invalid date format. Use: YYYY-MM-DD HH:MM', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (date < new Date()) {
|
||||
addToast('Reminder date must be in the future', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Store reminder in database
|
||||
addToast(`Reminder set for: ${date.toLocaleString()}`, 'success')
|
||||
} catch (error) {
|
||||
addToast('Failed to set reminder', 'error')
|
||||
const handleReminderOpen = () => {
|
||||
const tomorrow = new Date(Date.now() + 86400000)
|
||||
setReminderDate(tomorrow.toISOString().split('T')[0])
|
||||
setReminderTime('09:00')
|
||||
setShowReminderDialog(true)
|
||||
}
|
||||
|
||||
const handleReminderSave = () => {
|
||||
if (!reminderDate || !reminderTime) {
|
||||
addToast('Please enter date and time', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const dateTimeString = `${reminderDate}T${reminderTime}`
|
||||
const date = new Date(dateTimeString)
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
addToast('Invalid date or time', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (date < new Date()) {
|
||||
addToast('Reminder must be in the future', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentReminder(date)
|
||||
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
|
||||
setShowReminderDialog(false)
|
||||
setReminderDate('')
|
||||
setReminderTime('')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -139,6 +238,8 @@ export function NoteInput() {
|
||||
color,
|
||||
isArchived,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
reminder: currentReminder,
|
||||
isMarkdown,
|
||||
})
|
||||
|
||||
// Reset form
|
||||
@@ -146,10 +247,15 @@ export function NoteInput() {
|
||||
setContent('')
|
||||
setCheckItems([])
|
||||
setImages([])
|
||||
setIsMarkdown(false)
|
||||
setShowMarkdownPreview(false)
|
||||
setHistory([{ title: '', content: '' }])
|
||||
setHistoryIndex(0)
|
||||
setIsExpanded(false)
|
||||
setType('text')
|
||||
setColor('default')
|
||||
setIsArchived(false)
|
||||
setCurrentReminder(null)
|
||||
|
||||
addToast('Note created successfully', 'success')
|
||||
} catch (error) {
|
||||
@@ -182,9 +288,12 @@ export function NoteInput() {
|
||||
setContent('')
|
||||
setCheckItems([])
|
||||
setImages([])
|
||||
setHistory([{ title: '', content: '' }])
|
||||
setHistoryIndex(0)
|
||||
setType('text')
|
||||
setColor('default')
|
||||
setIsArchived(false)
|
||||
setCurrentReminder(null)
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
@@ -217,6 +326,7 @@ export function NoteInput() {
|
||||
const colorClasses = NOTE_COLORS[color] || NOTE_COLORS.default
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={cn(
|
||||
"p-4 max-w-2xl mx-auto mb-8 shadow-lg border",
|
||||
colorClasses.card
|
||||
@@ -253,13 +363,46 @@ export function NoteInput() {
|
||||
)}
|
||||
|
||||
{type === 'text' ? (
|
||||
<Textarea
|
||||
placeholder="Take a note..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{/* Markdown toggle button */}
|
||||
{isMarkdown && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{showMarkdownPreview ? (
|
||||
<>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Edit
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMarkdownPreview && isMarkdown ? (
|
||||
<MarkdownContent
|
||||
content={content || '*No content*'}
|
||||
className="min-h-[100px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{checkItems.map((item) => (
|
||||
@@ -301,9 +444,12 @@ export function NoteInput() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className={cn(
|
||||
"h-8 w-8",
|
||||
currentReminder && "text-blue-600"
|
||||
)}
|
||||
title="Remind me"
|
||||
onClick={handleReminder}
|
||||
onClick={handleReminderOpen}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -311,6 +457,27 @@ export function NoteInput() {
|
||||
<TooltipContent>Remind me</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8",
|
||||
isMarkdown && "text-blue-600"
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsMarkdown(!isMarkdown)
|
||||
if (isMarkdown) setShowMarkdownPreview(false)
|
||||
}}
|
||||
title="Markdown"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Markdown</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -390,31 +557,54 @@ export function NoteInput() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>More</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleUndo}
|
||||
disabled={historyIndex === 0}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Undo (Ctrl+Z)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
size="sm"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleRedo}
|
||||
disabled={historyIndex >= history.length - 1}
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Redo (Ctrl+Y)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
size="sm"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,5 +617,48 @@ export function NoteInput() {
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Reminder</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-date" className="text-sm font-medium">
|
||||
Date
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-date"
|
||||
type="date"
|
||||
value={reminderDate}
|
||||
onChange={(e) => setReminderDate(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-time" className="text-sm font-medium">
|
||||
Time
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-time"
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleReminderSave}>
|
||||
Set Reminder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ export interface Note {
|
||||
checkItems: CheckItem[] | null;
|
||||
labels: string[] | null;
|
||||
images: string[] | null;
|
||||
reminder: Date | null;
|
||||
reminderRecurrence: string | null;
|
||||
reminderLocation: string | null;
|
||||
isMarkdown: boolean;
|
||||
order: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
1727
keep-notes/package-lock.json
generated
1727
keep-notes/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,13 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@libsql/client": "^0.15.15",
|
||||
"@prisma/adapter-better-sqlite3": "^7.2.0",
|
||||
"@prisma/adapter-libsql": "^7.2.0",
|
||||
@@ -22,15 +26,20 @@
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"prisma": "5.22.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
|
||||
27
keep-notes/playwright.config.ts
Normal file
27
keep-notes/playwright.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Note" ADD COLUMN "reminder" DATETIME;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");
|
||||
@@ -0,0 +1,29 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Note" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL DEFAULT 'default',
|
||||
"isPinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isArchived" BOOLEAN NOT NULL DEFAULT false,
|
||||
"type" TEXT NOT NULL DEFAULT 'text',
|
||||
"checkItems" TEXT,
|
||||
"labels" TEXT,
|
||||
"images" TEXT,
|
||||
"reminder" DATETIME,
|
||||
"isMarkdown" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_Note" ("checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isPinned", "labels", "order", "reminder", "title", "type", "updatedAt") SELECT "checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isPinned", "labels", "order", "reminder", "title", "type", "updatedAt" FROM "Note";
|
||||
DROP TABLE "Note";
|
||||
ALTER TABLE "new_Note" RENAME TO "Note";
|
||||
CREATE INDEX "Note_isPinned_idx" ON "Note"("isPinned");
|
||||
CREATE INDEX "Note_isArchived_idx" ON "Note"("isArchived");
|
||||
CREATE INDEX "Note_order_idx" ON "Note"("order");
|
||||
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Note" ADD COLUMN "reminderLocation" TEXT;
|
||||
ALTER TABLE "Note" ADD COLUMN "reminderRecurrence" TEXT;
|
||||
@@ -0,0 +1,90 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" DATETIME,
|
||||
"image" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Account" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerAccountId" TEXT NOT NULL,
|
||||
"refresh_token" TEXT,
|
||||
"access_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"token_type" TEXT,
|
||||
"scope" TEXT,
|
||||
"id_token" TEXT,
|
||||
"session_state" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
|
||||
PRIMARY KEY ("provider", "providerAccountId"),
|
||||
CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"sessionToken" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expires" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VerificationToken" (
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" DATETIME NOT NULL,
|
||||
|
||||
PRIMARY KEY ("identifier", "token")
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Note" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL DEFAULT 'default',
|
||||
"isPinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isArchived" BOOLEAN NOT NULL DEFAULT false,
|
||||
"type" TEXT NOT NULL DEFAULT 'text',
|
||||
"checkItems" TEXT,
|
||||
"labels" TEXT,
|
||||
"images" TEXT,
|
||||
"reminder" DATETIME,
|
||||
"reminderRecurrence" TEXT,
|
||||
"reminderLocation" TEXT,
|
||||
"isMarkdown" BOOLEAN NOT NULL DEFAULT false,
|
||||
"userId" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Note" ("checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isMarkdown", "isPinned", "labels", "order", "reminder", "reminderLocation", "reminderRecurrence", "title", "type", "updatedAt") SELECT "checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isMarkdown", "isPinned", "labels", "order", "reminder", "reminderLocation", "reminderRecurrence", "title", "type", "updatedAt" FROM "Note";
|
||||
DROP TABLE "Note";
|
||||
ALTER TABLE "new_Note" RENAME TO "Note";
|
||||
CREATE INDEX "Note_isPinned_idx" ON "Note"("isPinned");
|
||||
CREATE INDEX "Note_isArchived_idx" ON "Note"("isArchived");
|
||||
CREATE INDEX "Note_order_idx" ON "Note"("order");
|
||||
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");
|
||||
CREATE INDEX "Note_userId_idx" ON "Note"("userId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
||||
@@ -10,22 +10,82 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
notes Note[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Account {
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String?
|
||||
access_token String?
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String?
|
||||
session_state String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String
|
||||
expires DateTime
|
||||
|
||||
@@id([identifier, token])
|
||||
}
|
||||
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
title String?
|
||||
content String
|
||||
color String @default("default")
|
||||
isPinned Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
type String @default("text") // "text" or "checklist"
|
||||
checkItems String? // For checklist items stored as JSON string
|
||||
labels String? // Array of label names stored as JSON string
|
||||
images String? // Array of image URLs stored as JSON string
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
title String?
|
||||
content String
|
||||
color String @default("default")
|
||||
isPinned Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
type String @default("text") // "text" or "checklist"
|
||||
checkItems String? // For checklist items stored as JSON string
|
||||
labels String? // Array of label names stored as JSON string
|
||||
images String? // Array of image URLs stored as JSON string
|
||||
reminder DateTime? // Reminder date and time
|
||||
reminderRecurrence String? // "none", "daily", "weekly", "monthly", "custom"
|
||||
reminderLocation String? // Location for location-based reminders
|
||||
isMarkdown Boolean @default(false) // Whether content uses Markdown
|
||||
userId String? // Owner of the note (optional for now, will be required after auth)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([isPinned])
|
||||
@@index([isArchived])
|
||||
@@index([order])
|
||||
@@index([reminder])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
181
keep-notes/tests/drag-drop.spec.ts
Normal file
181
keep-notes/tests/drag-drop.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
423
keep-notes/tests/reminder-dialog.spec.ts
Normal file
423
keep-notes/tests/reminder-dialog.spec.ts
Normal 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
|
||||
}
|
||||
});
|
||||
});
|
||||
167
keep-notes/tests/undo-redo.spec.ts
Normal file
167
keep-notes/tests/undo-redo.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user