From 07d80a26743506082c0d471c4d04f2832617a46d Mon Sep 17 00:00:00 2001 From: Chneemann Date: Mon, 17 Aug 2026 04:48:06 +0200 Subject: [PATCH] feat(search): add debounced search bar with server-side TaskService filtering --- app/(app)/dashboard/page.tsx | 26 +++++--- app/(app)/trash/page.tsx | 23 +++++-- app/components/layout/BrandLogo.tsx | 20 +++--- app/components/layout/Header.tsx | 30 ++++----- app/components/layout/SearchBar.tsx | 98 +++++++++++++++++++++++++++++ services/task.service.ts | 46 +++++++++++--- 6 files changed, 199 insertions(+), 44 deletions(-) create mode 100644 app/components/layout/SearchBar.tsx diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 1eb1ed1..93c8d8e 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -1,6 +1,6 @@ /** * @file dashboard/page.tsx - * @description Server component rendering the main dashboard page using the TaskService. + * @description Server component rendering the main dashboard page using the TaskService with DB search filtering. */ import { auth } from "@/auth"; @@ -11,22 +11,34 @@ import { DbUser } from "@/types/user"; import { TaskService } from "@/services/task.service"; /** - * Renders the dashboard page component with user session validation, - * optimized database queries for active tasks, and team assignees. + * Renders the primary dashboard view after performing authentication checks, + * executing database queries for task data with optional search filters, + * and structuring creator and assignee relationships. * * @async + * @param {Object} props - The component props. + * @param {Promise<{ search?: string }>} props.searchParams - Promise resolving to the current URL search parameters. * @returns {Promise} The rendered dashboard page component. */ -export default async function Dashboard() { +export default async function Dashboard({ + searchParams, +}: { + searchParams: Promise<{ search?: string }>; +}) { const session = await auth(); if (!session?.user?.id) redirect("/login"); const currentUserId = session.user.id; + const { search } = await searchParams; + const searchQuery = search?.trim() || ""; + + // The search is passed directly to the SQL query + const rawTasksWithCreator = await TaskService.findActiveTasksForUser( + currentUserId, + searchQuery, + ); - const rawTasksWithCreator = - await TaskService.findActiveTasksForUser(currentUserId); const allTaskIds = rawTasksWithCreator.map((item) => item.task.id); - const assigneesData = await TaskService.findAssigneesForTasks(allTaskIds); const assigneesMap = new Map(); diff --git a/app/(app)/trash/page.tsx b/app/(app)/trash/page.tsx index e146267..d92ea9d 100644 --- a/app/(app)/trash/page.tsx +++ b/app/(app)/trash/page.tsx @@ -1,6 +1,6 @@ /** * @file trash/page.tsx - * @description Server component rendering the trash management view using the TaskService. + * @description Server component rendering the trash management view with database search filtering. */ import { auth } from "@/auth"; @@ -11,20 +11,31 @@ import { TaskService } from "@/services/task.service"; import { Task } from "@/types/task"; /** - * Renders the trash page verifying user authentication, querying soft-deleted tasks, - * and passing them down to the list and header components. + * Renders the trash page view, fetching soft-deleted tasks for the authenticated user + * with optional search query filtering applied at the database level. * * @async + * @param {Object} props - The page component props. + * @param {Promise<{ search?: string }>} props.searchParams - Promise resolving to the route's search query parameters. * @returns {Promise} The rendered trash page component. */ -export default async function TrashPage() { +export default async function TrashPage({ + searchParams, +}: { + searchParams: Promise<{ search?: string }>; +}) { const session = await auth(); if (!session?.user?.id) redirect("/login"); const currentUserId = session.user.id; + const { search } = await searchParams; + const searchQuery = search?.trim() || ""; - const rawTrashedTasks = - await TaskService.findTrashTasksForUser(currentUserId); + // The search is passed directly to the SQL query + const rawTrashedTasks = await TaskService.findTrashTasksForUser( + currentUserId, + searchQuery, + ); const tasks: Task[] = rawTrashedTasks.map(({ task, user }) => ({ ...task, diff --git a/app/components/layout/BrandLogo.tsx b/app/components/layout/BrandLogo.tsx index 1fedcec..e5daea7 100644 --- a/app/components/layout/BrandLogo.tsx +++ b/app/components/layout/BrandLogo.tsx @@ -1,5 +1,5 @@ /** - * @file BrandLogo.tsx + * @file components/layout/BrandLogo.tsx * @description Client/Server component rendering the application brand logo and title header. */ @@ -12,7 +12,7 @@ import React from "react"; */ export default function BrandLogo() { return ( -
+
-
-

- Flowstate -

-

- Workspace Edition -

+
+
+

+ Flowstate +

+

+ Workspace Edition +

+
); diff --git a/app/components/layout/Header.tsx b/app/components/layout/Header.tsx index 2640d2a..ea12f30 100644 --- a/app/components/layout/Header.tsx +++ b/app/components/layout/Header.tsx @@ -1,27 +1,29 @@ /** - * @file Header.tsx - * @description Application header component containing the mobile brand logo and the user badge navigation. + * @file components/layout/Header.tsx + * @description Server component header arranging mobile logo, responsive search bar glued to the logo on mobile, and user badge. */ import BrandLogo from "./BrandLogo"; +import SearchBar from "./SearchBar"; import UserBadge from "./UserBadge"; -/** - * Renders the sticky top navigation header, displaying the brand logo on mobile views - * and the user profile badge on the right side. - * - * @returns {JSX.Element} The rendered header component. - */ export default function Header() { return ( -
- {/* Brand Element */} -
- +
+ {/* Left Sidebar: Logo & Search Bar */} +
+
+ +
+
+ +
- {/* User Badge */} - + {/* Right-hand side: User Badge */} +
+ +
); } diff --git a/app/components/layout/SearchBar.tsx b/app/components/layout/SearchBar.tsx new file mode 100644 index 0000000..15bb3e7 --- /dev/null +++ b/app/components/layout/SearchBar.tsx @@ -0,0 +1,98 @@ +/** + * @file components/layout/SearchBar.tsx + * @description Client component managing URL search parameters with debounced input and smooth transition state. + */ + +"use client"; + +import { useState, useEffect, useTransition } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Search, X } from "lucide-react"; + +/** + * Properties for the SearchBar component. + * + * @interface SearchBarProps + * @property {string} [placeholder] - Placeholder text displayed inside the search input. + * @property {number} [debounceMs] - Delay in milliseconds before updating the URL search parameter. + * @property {boolean} [autoFocus] - Flag indicating whether the input should automatically gain focus on mount. + */ +interface SearchBarProps { + placeholder?: string; + debounceMs?: number; + autoFocus?: boolean; +} + +/** + * Renders a debounced search bar input that syncs its local state with the URL's "search" query parameter. + * + * @param {SearchBarProps} props - The component props. + * @returns {JSX.Element} The rendered search bar component. + */ +export default function SearchBar({ + placeholder = "Search...", + debounceMs = 400, + autoFocus = false, +}: SearchBarProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const searchQuery = searchParams.get("search") || ""; + + const [localValue, setLocalValue] = useState(searchQuery); + const [isPending, startTransition] = useTransition(); + + // Debounced URL Update + useEffect(() => { + const timer = setTimeout(() => { + if (localValue !== searchQuery) { + const params = new URLSearchParams(searchParams.toString()); + if (localValue.trim()) { + params.set("search", localValue.trim()); + } else { + params.delete("search"); + } + + startTransition(() => { + router.push(`?${params.toString()}`, { scroll: false }); + }); + } + }, debounceMs); + + return () => clearTimeout(timer); + }, [localValue, searchQuery, searchParams, debounceMs, router]); + + /** + * Resets the local search input value to an empty string. + */ + const handleClear = () => { + setLocalValue(""); + }; + + return ( +
+ + setLocalValue(e.target.value)} + autoFocus={autoFocus} + className="bg-transparent focus:outline-none w-full placeholder:text-foreground-muted/60" + /> + {localValue && ( + + )} +
+ ); +} diff --git a/services/task.service.ts b/services/task.service.ts index cd38726..81a3e47 100644 --- a/services/task.service.ts +++ b/services/task.service.ts @@ -6,7 +6,16 @@ import { db } from "@/db"; import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema"; import { DbTask, TaskPayload, TaskStatus } from "@/types/task"; -import { and, eq, or, exists, isNotNull, isNull, inArray } from "drizzle-orm"; +import { + and, + eq, + or, + exists, + isNotNull, + isNull, + inArray, + ilike, +} from "drizzle-orm"; /** * Service class for handling task-related operations, access control, and database interactions. @@ -120,13 +129,21 @@ export class TaskService { } /** - * Retrieves all active (non-deleted) tasks for a specific user. + * Finds all active (non-deleted) tasks for a specific user, matching optionally provided search criteria. * * @async * @param {string} userId - The unique identifier of the user. - * @returns {Promise>} An array of tasks joined with their creator users. + * @param {string} [searchQuery] - An optional search string to filter task titles or descriptions. + * @returns {Promise>} An array of task records mapped with their respective creators. */ - static async findActiveTasksForUser(userId: string) { + static async findActiveTasksForUser(userId: string, searchQuery?: string) { + const searchFilter = searchQuery + ? or( + ilike(tasksTable.title, `%${searchQuery}%`), + ilike(tasksTable.description, `%${searchQuery}%`), + ) + : undefined; + return await db .select({ task: tasksTable, user: usersTable }) .from(tasksTable) @@ -135,6 +152,7 @@ export class TaskService { and( isNull(tasksTable.deletedAt), this.userHasAccessCondition(userId, tasksTable.id), + searchFilter, ), ); } @@ -156,19 +174,31 @@ export class TaskService { } /** - * Retrieves all soft-deleted tasks in the trash for a specific user. + * Finds all soft-deleted tasks in the trash created by a specific user, matching optionally provided search criteria. * * @async * @param {string} userId - The unique identifier of the user. - * @returns {Promise>} An array of deleted tasks joined with users. + * @param {string} [searchQuery] - An optional search string to filter task titles or descriptions. + * @returns {Promise>} An array of soft-deleted task records mapped with their respective creators. */ - static async findTrashTasksForUser(userId: string) { + static async findTrashTasksForUser(userId: string, searchQuery?: string) { + const searchFilter = searchQuery + ? or( + ilike(tasksTable.title, `%${searchQuery}%`), + ilike(tasksTable.description, `%${searchQuery}%`), + ) + : undefined; + return await db .select({ task: tasksTable, user: usersTable }) .from(tasksTable) .innerJoin(usersTable, eq(tasksTable.userId, usersTable.id)) .where( - and(eq(tasksTable.userId, userId), isNotNull(tasksTable.deletedAt)), + and( + eq(tasksTable.userId, userId), + isNotNull(tasksTable.deletedAt), + searchFilter, + ), ); }