feat: task search and save feedback fixes
- Add task search with debounce (title + description filtering) - Add sonner toast library for notifications - Visual save feedback: spinner → green 'Saved!' → toast confirmation - Error handling with descriptive toast messages - Works across Kanban and Backlog views
This commit is contained in:
parent
caa0bf1893
commit
9453f88df4
13
package-lock.json
generated
13
package-lock.json
generated
@ -20,7 +20,8 @@
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"dotenv": "^16.6.1",
|
||||
"firebase": "^12.9.0",
|
||||
"resend": "^6.9.2"
|
||||
"resend": "^6.9.2",
|
||||
"sonner": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
@ -9328,6 +9329,16 @@
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@ -20,7 +20,8 @@
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"dotenv": "^16.6.1",
|
||||
"firebase": "^12.9.0",
|
||||
"resend": "^6.9.2"
|
||||
"resend": "^6.9.2",
|
||||
"sonner": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { JetBrains_Mono, Lexend, Source_Sans_3 } from 'next/font/google'
|
||||
import { Toaster } from 'sonner'
|
||||
import './globals.css'
|
||||
|
||||
const headingFont = Lexend({
|
||||
@ -31,6 +32,16 @@ export default function RootLayout({
|
||||
<html lang="en">
|
||||
<body className={`${headingFont.variable} ${bodyFont.variable} ${monoFont.variable} antialiased`}>
|
||||
{children}
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: '#1e293b',
|
||||
color: '#f1f5f9',
|
||||
border: '1px solid #334155',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useMemo, type ChangeEvent, type ReactNode } from "react"
|
||||
import { useDebounce } from "@/hooks/useDebounce"
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
@ -34,7 +35,7 @@ import {
|
||||
} from "@/lib/attachments"
|
||||
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
|
||||
import { BacklogView } from "@/components/BacklogView"
|
||||
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react"
|
||||
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search } from "lucide-react"
|
||||
|
||||
interface AssignableUser {
|
||||
id: string
|
||||
@ -379,6 +380,8 @@ export default function Home() {
|
||||
const [authReady, setAuthReady] = useState(false)
|
||||
const [initialSyncComplete, setInitialSyncComplete] = useState(false)
|
||||
const [users, setUsers] = useState<AssignableUser[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
|
||||
const getTags = (taskLike: { tags?: unknown }) => {
|
||||
if (!Array.isArray(taskLike.tags)) return [] as string[]
|
||||
@ -589,7 +592,17 @@ export default function Home() {
|
||||
|
||||
// Filter tasks to only show current sprint tasks in Kanban (from ALL projects)
|
||||
const sprintTasks = currentSprint
|
||||
? tasks.filter((t) => t.sprintId === currentSprint.id)
|
||||
? tasks.filter((t) => {
|
||||
if (t.sprintId !== currentSprint.id) return false
|
||||
// Apply search filter
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
const query = debouncedSearchQuery.toLowerCase()
|
||||
const matchesTitle = t.title.toLowerCase().includes(query)
|
||||
const matchesDescription = t.description?.toLowerCase().includes(query) ?? false
|
||||
return matchesTitle || matchesDescription
|
||||
}
|
||||
return true
|
||||
})
|
||||
: []
|
||||
const activeKanbanTask = activeKanbanTaskId
|
||||
? sprintTasks.find((task) => task.id === activeKanbanTaskId)
|
||||
@ -893,6 +906,25 @@ export default function Home() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search Input */}
|
||||
<div className="relative hidden sm:block">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search tasks..."
|
||||
className="w-48 lg:w-64 pl-9 pr-8 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-slate-700 rounded text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isLoading && (
|
||||
<span className="flex items-center gap-1 text-xs text-blue-400">
|
||||
<svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@ -937,7 +969,15 @@ export default function Home() {
|
||||
{currentSprint ? currentSprint.name : "Work Board"}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
{debouncedSearchQuery.trim() ? (
|
||||
<>
|
||||
{sprintTasks.length} of {currentSprint ? tasks.filter((t) => t.sprintId === currentSprint.id).length : 0} tasks match "{debouncedSearchQuery}"
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sprintTasks.length} tasks · {sprintTasks.filter((t) => t.status === "done").length} done
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -973,9 +1013,31 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Search - shown only on small screens */}
|
||||
<div className="sm:hidden mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search tasks..."
|
||||
className="w-full pl-9 pr-8 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Content */}
|
||||
{viewMode === 'backlog' ? (
|
||||
<BacklogView />
|
||||
<BacklogView searchQuery={debouncedSearchQuery} />
|
||||
) : (
|
||||
<>
|
||||
{/* Current Sprint Header */}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState, type ChangeEvent } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Download, MessageSquare, Paperclip, Trash2, X } from "lucide-react"
|
||||
import { ArrowLeft, Check, Download, Loader2, MessageSquare, Paperclip, Save, Trash2, X } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@ -28,6 +28,7 @@ import {
|
||||
type TaskType,
|
||||
type UserProfile,
|
||||
} from "@/stores/useTaskStore"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface AssignableUser {
|
||||
id: string
|
||||
@ -239,6 +240,7 @@ export default function TaskDetailPage() {
|
||||
const [newComment, setNewComment] = useState("")
|
||||
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saveSuccess, setSaveSuccess] = useState(false)
|
||||
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
|
||||
const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({})
|
||||
const [authReady, setAuthReady] = useState(false)
|
||||
@ -448,15 +450,40 @@ export default function TaskDetailPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
if (!editedTask) return
|
||||
setIsSaving(true)
|
||||
updateTask(editedTask.id, {
|
||||
setSaveSuccess(false)
|
||||
|
||||
try {
|
||||
const success = await updateTask(editedTask.id, {
|
||||
...editedTask,
|
||||
comments: getComments(editedTask.comments),
|
||||
})
|
||||
|
||||
if (success) {
|
||||
setSaveSuccess(true)
|
||||
toast.success("Task saved successfully", {
|
||||
description: "Your changes have been saved to the server.",
|
||||
duration: 3000,
|
||||
})
|
||||
// Reset success state after 2 seconds
|
||||
setTimeout(() => setSaveSuccess(false), 2000)
|
||||
} else {
|
||||
toast.error("Failed to save task", {
|
||||
description: "Changes were saved locally but could not sync to the server. Please try again.",
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Error saving task", {
|
||||
description: error instanceof Error ? error.message : "An unexpected error occurred.",
|
||||
duration: 5000,
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAttachment = async (attachment: TaskAttachment) => {
|
||||
try {
|
||||
@ -954,8 +981,27 @@ export default function TaskDetailPage() {
|
||||
<Button variant="ghost" onClick={() => router.push("/")}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className={saveSuccess ? "bg-green-600 hover:bg-green-700" : undefined}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : saveSuccess ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Saved!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -228,7 +228,11 @@ function TaskSection({
|
||||
)
|
||||
}
|
||||
|
||||
export function BacklogView() {
|
||||
interface BacklogViewProps {
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
||||
const router = useRouter()
|
||||
const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([])
|
||||
const {
|
||||
@ -270,6 +274,15 @@ export function BacklogView() {
|
||||
return assignableUsers.find((user) => user.id === task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl
|
||||
}
|
||||
|
||||
// Filter tasks by search query
|
||||
const matchesSearch = (task: Task): boolean => {
|
||||
if (!searchQuery.trim()) return true
|
||||
const query = searchQuery.toLowerCase()
|
||||
const matchesTitle = task.title.toLowerCase().includes(query)
|
||||
const matchesDescription = task.description?.toLowerCase().includes(query) ?? false
|
||||
return matchesTitle || matchesDescription
|
||||
}
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
||||
current: true,
|
||||
@ -306,10 +319,10 @@ export function BacklogView() {
|
||||
|
||||
// Get tasks by section
|
||||
const currentSprintTasks = currentSprint
|
||||
? tasks.filter((t) => t.sprintId === currentSprint.id)
|
||||
? tasks.filter((t) => t.sprintId === currentSprint.id && matchesSearch(t))
|
||||
: []
|
||||
|
||||
const backlogTasks = tasks.filter((t) => !t.sprintId)
|
||||
const backlogTasks = tasks.filter((t) => !t.sprintId && matchesSearch(t))
|
||||
|
||||
// Get active task for drag overlay
|
||||
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
|
||||
@ -400,7 +413,7 @@ export function BacklogView() {
|
||||
{otherSprints
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())
|
||||
.map((sprint) => {
|
||||
const sprintTasks = tasks.filter((t) => t.sprintId === sprint.id)
|
||||
const sprintTasks = tasks.filter((t) => t.sprintId === sprint.id && matchesSearch(t))
|
||||
console.log(`Sprint ${sprint.name}: ${sprintTasks.length} tasks`, sprintTasks.map(t => t.title))
|
||||
return (
|
||||
<SectionDropZone key={sprint.id} id={`sprint-${sprint.id}`}>
|
||||
|
||||
17
src/hooks/useDebounce.ts
Normal file
17
src/hooks/useDebounce.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@ -108,7 +108,7 @@ interface TaskStore {
|
||||
|
||||
// Task actions
|
||||
addTask: (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'>) => void
|
||||
updateTask: (id: string, updates: Partial<Task>) => void
|
||||
updateTask: (id: string, updates: Partial<Task>) => Promise<boolean>
|
||||
deleteTask: (id: string) => void
|
||||
selectTask: (id: string | null) => void
|
||||
|
||||
@ -753,8 +753,11 @@ export const useTaskStore = create<TaskStore>()(
|
||||
})
|
||||
},
|
||||
|
||||
updateTask: (id, updates) => {
|
||||
updateTask: async (id, updates) => {
|
||||
console.log('updateTask called:', id, updates)
|
||||
let syncSuccess = false
|
||||
let updatedTask: Task | undefined
|
||||
|
||||
set((state) => {
|
||||
const actor = profileToCommentAuthor(state.currentUser)
|
||||
const newTasks = state.tasks.map((t) =>
|
||||
@ -771,14 +774,17 @@ export const useTaskStore = create<TaskStore>()(
|
||||
} as Task)
|
||||
: t
|
||||
)
|
||||
const updatedTask = newTasks.find(t => t.id === id)
|
||||
updatedTask = newTasks.find(t => t.id === id)
|
||||
console.log('updateTask: updated task:', updatedTask)
|
||||
// Sync individual task to server (lightweight)
|
||||
if (updatedTask) {
|
||||
syncTaskToServer(updatedTask)
|
||||
}
|
||||
return { tasks: newTasks }
|
||||
})
|
||||
|
||||
// Sync individual task to server (lightweight)
|
||||
if (updatedTask) {
|
||||
syncSuccess = await syncTaskToServer(updatedTask)
|
||||
}
|
||||
|
||||
return syncSuccess
|
||||
},
|
||||
|
||||
deleteTask: (id) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user