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
* @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<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();
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<string, DbUser[]>();

View file

@ -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<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();
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,

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.
*/
@ -12,7 +12,7 @@ import React from "react";
*/
export default function BrandLogo() {
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">
<img
src="/logo.png"
@ -20,13 +20,15 @@ export default function BrandLogo() {
className="w-full h-full object-contain"
/>
</div>
<div>
<h1 className="text-lg font-bold tracking-widest flex items-center gap-1.5">
Flowstate
</h1>
<p className="text-xs text-foreground-muted font-medium tracking-widest">
Workspace Edition
</p>
<div className="hidden md:flex">
<div>
<h1 className="text-lg font-bold tracking-widest flex items-center gap-1.5">
Flowstate
</h1>
<p className="text-xs text-foreground-muted font-medium tracking-widest">
Workspace Edition
</p>
</div>
</div>
</div>
);

View file

@ -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 (
<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">
{/* Brand Element */}
<div className="md:hidden flex items-center gap-3">
<BrandLogo />
<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">
{/* Left Sidebar: Logo & Search Bar */}
<div className="flex items-center gap-2 flex-1 max-w-sm md:max-w-md mr-6">
<div className="md:hidden flex items-center shrink-0">
<BrandLogo />
</div>
<div className="flex-1">
<SearchBar />
</div>
</div>
{/* User Badge */}
<UserBadge />
{/* Right-hand side: User Badge */}
<div className="shrink-0 flex items-center">
<UserBadge />
</div>
</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 { 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<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
.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<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
.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,
),
);
}