feat(search): add debounced search bar with server-side TaskService filtering
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 52s
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 52s
This commit is contained in:
parent
d6209c900d
commit
07d80a2674
6 changed files with 199 additions and 44 deletions
|
|
@ -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[]>();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
98
app/components/layout/SearchBar.tsx
Normal file
98
app/components/layout/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue