feat(search): add debounced search bar with server-side TaskService filtering
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 52s

This commit is contained in:
Chneemann 2026-08-17 04:48:06 +02:00
parent d6209c900d
commit 07d80a2674
No known key found for this signature in database
6 changed files with 199 additions and 44 deletions

View file

@ -1,6 +1,6 @@
/** /**
* @file dashboard/page.tsx * @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"; import { auth } from "@/auth";
@ -11,22 +11,34 @@ import { DbUser } from "@/types/user";
import { TaskService } from "@/services/task.service"; import { TaskService } from "@/services/task.service";
/** /**
* Renders the dashboard page component with user session validation, * Renders the primary dashboard view after performing authentication checks,
* optimized database queries for active tasks, and team assignees. * executing database queries for task data with optional search filters,
* and structuring creator and assignee relationships.
* *
* @async * @async
* @param {Object} props - The component props.
* @param {Promise<{ search?: string }>} props.searchParams - Promise resolving to the current URL search parameters.
* @returns {Promise<JSX.Element>} The rendered dashboard page component. * @returns {Promise<JSX.Element>} The rendered dashboard page component.
*/ */
export default async function Dashboard() { export default async function Dashboard({
searchParams,
}: {
searchParams: Promise<{ search?: string }>;
}) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const currentUserId = session.user.id; 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 allTaskIds = rawTasksWithCreator.map((item) => item.task.id);
const assigneesData = await TaskService.findAssigneesForTasks(allTaskIds); const assigneesData = await TaskService.findAssigneesForTasks(allTaskIds);
const assigneesMap = new Map<string, DbUser[]>(); const assigneesMap = new Map<string, DbUser[]>();

View file

@ -1,6 +1,6 @@
/** /**
* @file trash/page.tsx * @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"; import { auth } from "@/auth";
@ -11,20 +11,31 @@ import { TaskService } from "@/services/task.service";
import { Task } from "@/types/task"; import { Task } from "@/types/task";
/** /**
* Renders the trash page verifying user authentication, querying soft-deleted tasks, * Renders the trash page view, fetching soft-deleted tasks for the authenticated user
* and passing them down to the list and header components. * with optional search query filtering applied at the database level.
* *
* @async * @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<JSX.Element>} The rendered trash page component. * @returns {Promise<JSX.Element>} The rendered trash page component.
*/ */
export default async function TrashPage() { export default async function TrashPage({
searchParams,
}: {
searchParams: Promise<{ search?: string }>;
}) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const currentUserId = session.user.id; const currentUserId = session.user.id;
const { search } = await searchParams;
const searchQuery = search?.trim() || "";
const rawTrashedTasks = // The search is passed directly to the SQL query
await TaskService.findTrashTasksForUser(currentUserId); const rawTrashedTasks = await TaskService.findTrashTasksForUser(
currentUserId,
searchQuery,
);
const tasks: Task[] = rawTrashedTasks.map(({ task, user }) => ({ const tasks: Task[] = rawTrashedTasks.map(({ task, user }) => ({
...task, ...task,

View file

@ -1,5 +1,5 @@
/** /**
* @file BrandLogo.tsx * @file components/layout/BrandLogo.tsx
* @description Client/Server component rendering the application brand logo and title header. * @description Client/Server component rendering the application brand logo and title header.
*/ */
@ -12,7 +12,7 @@ import React from "react";
*/ */
export default function BrandLogo() { export default function BrandLogo() {
return ( return (
<div className="flex items-center gap-3 mr-2"> <div className="flex items-center gap-3 mr-1 md:mr-0">
<div className="w-9 h-9"> <div className="w-9 h-9">
<img <img
src="/logo.png" src="/logo.png"
@ -20,13 +20,15 @@ export default function BrandLogo() {
className="w-full h-full object-contain" className="w-full h-full object-contain"
/> />
</div> </div>
<div> <div className="hidden md:flex">
<h1 className="text-lg font-bold tracking-widest flex items-center gap-1.5"> <div>
Flowstate <h1 className="text-lg font-bold tracking-widest flex items-center gap-1.5">
</h1> Flowstate
<p className="text-xs text-foreground-muted font-medium tracking-widest"> </h1>
Workspace Edition <p className="text-xs text-foreground-muted font-medium tracking-widest">
</p> Workspace Edition
</p>
</div>
</div> </div>
</div> </div>
); );

View file

@ -1,27 +1,29 @@
/** /**
* @file Header.tsx * @file components/layout/Header.tsx
* @description Application header component containing the mobile brand logo and the user badge navigation. * @description Server component header arranging mobile logo, responsive search bar glued to the logo on mobile, and user badge.
*/ */
import BrandLogo from "./BrandLogo"; import BrandLogo from "./BrandLogo";
import SearchBar from "./SearchBar";
import UserBadge from "./UserBadge"; 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() { export default function Header() {
return ( return (
<header className="h-20 border-b border-border backdrop-blur-md p-4 flex items-center md:justify-end justify-between sticky top-0 z-50"> <header className="h-16 md:h-20 border-b border-border backdrop-blur-md px-3 sm:px-4 md:px-6 flex items-center justify-between sticky top-0 z-50 bg-background/80">
{/* Brand Element */} {/* Left Sidebar: Logo & Search Bar */}
<div className="md:hidden flex items-center gap-3"> <div className="flex items-center gap-2 flex-1 max-w-sm md:max-w-md mr-6">
<BrandLogo /> <div className="md:hidden flex items-center shrink-0">
<BrandLogo />
</div>
<div className="flex-1">
<SearchBar />
</div>
</div> </div>
{/* User Badge */} {/* Right-hand side: User Badge */}
<UserBadge /> <div className="shrink-0 flex items-center">
<UserBadge />
</div>
</header> </header>
); );
} }

View file

@ -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 (
<div
className={`flex items-center bg-card border border-border rounded-lg px-2.5 sm:px-3 py-1.25 md:py-1.5 shadow-sm text-sm focus-within:border-primary focus-within:ring-1 focus-within:ring-primary transition-all w-full ${
isPending ? "opacity-70" : "opacity-100"
}`}
>
<Search className="w-4 h-4 text-foreground-muted shrink-0 mr-2" />
<input
type="text"
placeholder={placeholder}
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
autoFocus={autoFocus}
className="bg-transparent focus:outline-none w-full placeholder:text-foreground-muted/60"
/>
{localValue && (
<button
type="button"
onClick={handleClear}
className="p-1 text-foreground-muted hover:text-foreground shrink-0 ml-1 cursor-pointer rounded-md hover:bg-foreground/5 transition-colors"
aria-label="Clear search"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
}

View file

@ -6,7 +6,16 @@
import { db } from "@/db"; import { db } from "@/db";
import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema"; import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema";
import { DbTask, TaskPayload, TaskStatus } from "@/types/task"; 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. * 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 * @async
* @param {string} userId - The unique identifier of the user. * @param {string} userId - The unique identifier of the user.
* @returns {Promise<Array<{ task: DbTask; user: any }>>} An array of tasks joined with their creator users. * @param {string} [searchQuery] - An optional search string to filter task titles or descriptions.
* @returns {Promise<Array<{ task: DbTask; user: any }>>} 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 return await db
.select({ task: tasksTable, user: usersTable }) .select({ task: tasksTable, user: usersTable })
.from(tasksTable) .from(tasksTable)
@ -135,6 +152,7 @@ export class TaskService {
and( and(
isNull(tasksTable.deletedAt), isNull(tasksTable.deletedAt),
this.userHasAccessCondition(userId, tasksTable.id), 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 * @async
* @param {string} userId - The unique identifier of the user. * @param {string} userId - The unique identifier of the user.
* @returns {Promise<Array<{ task: DbTask; user: any }>>} An array of deleted tasks joined with users. * @param {string} [searchQuery] - An optional search string to filter task titles or descriptions.
* @returns {Promise<Array<{ task: DbTask; user: any }>>} 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 return await db
.select({ task: tasksTable, user: usersTable }) .select({ task: tasksTable, user: usersTable })
.from(tasksTable) .from(tasksTable)
.innerJoin(usersTable, eq(tasksTable.userId, usersTable.id)) .innerJoin(usersTable, eq(tasksTable.userId, usersTable.id))
.where( .where(
and(eq(tasksTable.userId, userId), isNotNull(tasksTable.deletedAt)), and(
eq(tasksTable.userId, userId),
isNotNull(tasksTable.deletedAt),
searchFilter,
),
); );
} }