Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat search component #214

Merged
merged 12 commits into from
Jul 30, 2024
Binary file added .DS_Store
Binary file not shown.
Binary file added backend/.DS_Store
Binary file not shown.
28 changes: 18 additions & 10 deletions backend/src/controllers/timetable/searchTimetable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ import { validate } from "../../middleware/zodValidateRequest.js";

const searchTimetableSchema = z.object({
query: z.object({
query: namedNonEmptyStringType("search query"),
query: namedNonEmptyStringType("search query").optional(),
// note that this implies a limit of 500 on the number of search results
from: namedIntegerType("search results start index")
page: namedIntegerType("search results page")
.gte(0, {
message: "invalid search results start index",
message: "invalid search results page",
})
.lte(500, {
message: "invalid search results start index",
.lte(50, {
message: "invalid search results page",
})
.optional(),
year: namedCollegeYearType("search filter").optional(),
Expand Down Expand Up @@ -54,7 +54,7 @@ export const searchTimetable = async (req: Request, res: Response) => {
try {
const {
query,
from,
page,
year,
name,
authorId,
Expand All @@ -66,7 +66,8 @@ export const searchTimetable = async (req: Request, res: Response) => {
} = req.query;

const usefulQueryParams = {
from,
query,
from: parseInt((page as string | undefined) ?? "0") * 12,
year,
name,
authorId,
Expand All @@ -77,19 +78,26 @@ export const searchTimetable = async (req: Request, res: Response) => {
instructor: instructorQuery,
};

let searchServiceURL = `${env.SEARCH_SERVICE_URL}/timetable/search?query=${query}`;
let searchServiceURL = `${env.SEARCH_SERVICE_URL}/timetable/search?`;

for (const [key, value] of Object.entries(usefulQueryParams)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
for (const v of value) {
searchServiceURL += `&${key}=${v}`;
searchServiceURL += `${key}=${v}&`;
}
} else {
searchServiceURL += `&${key}=${value}`;
searchServiceURL += `${key}=${value}&`;
}
}

if (searchServiceURL.endsWith("&")) {
searchServiceURL = searchServiceURL.substring(
0,
searchServiceURL.length - 1,
);
}

const response = await fetch(searchServiceURL, {
method: "GET",
headers: { "Content-Type": "application/json" },
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"axios": "^1.7.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"html-to-image": "^1.11.11",
"lucide": "^0.302.0",
"lucide-react": "^0.302.0",
Expand Down
393 changes: 227 additions & 166 deletions frontend/pnpm-lock.yaml

Large diffs are not rendered by default.

136 changes: 122 additions & 14 deletions frontend/src/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { ErrorComponent, Route } from "@tanstack/react-router";
import axios, { AxiosError } from "axios";
import { z } from "zod";
import { timetableWithSectionsType } from "../../lib/src";
import type { z } from "zod";
import type { timetableWithSectionsType } from "../../lib/src";
import authenticatedRoute from "./AuthenticatedRoute";
import { ToastAction } from "./components/ui/toast";
import { useToast } from "./components/ui/use-toast";
import { router } from "./main";

import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "./components/ui/pagination";

const fetchSearchDetails = async (
query: string,
): Promise<z.infer<typeof timetableWithSectionsType>[]> => {
const response = await axios.get<z.infer<typeof timetableWithSectionsType>[]>(
`/api/timetable/search?query=${query}`,
`/api/timetable/search?${query}`,
{
headers: {
"Content-Type": "application/json",
Expand All @@ -22,19 +40,26 @@ const fetchSearchDetails = async (
return response.data;
};

const searchQueryOptions = (query: string) =>
queryOptions({
const searchQueryOptions = (deps: Record<string, any>) => {
for (const key of Object.keys(deps)) {
if (deps[key] === undefined) delete deps[key];
}
const query = new URLSearchParams(deps).toString();
return queryOptions({
queryKey: ["search_timetables", query],
queryFn: () => fetchSearchDetails(query),
});
};

const searchRoute = new Route({
getParentRoute: () => authenticatedRoute,
path: "search/$query",
path: "/search",
component: SearchResults,
loader: ({ context: { queryClient }, params }) =>
validateSearch: (search) => search,
loaderDeps: ({ search }) => search,
loader: ({ context: { queryClient }, deps }) =>
queryClient
.ensureQueryData(searchQueryOptions(params.query))
.ensureQueryData(searchQueryOptions(deps))
.catch((error: Error) => {
if (
error instanceof AxiosError &&
Expand Down Expand Up @@ -114,14 +139,97 @@ const searchRoute = new Route({
});

function SearchResults() {
const { query } = searchRoute.useParams();
// @ts-ignore Suppress unused variable warning, needs to be removed when the page is finished
const searchQueryResult = useQuery(searchQueryOptions(query));
const initDeps = searchRoute.useLoaderDeps();
const [deps, setDeps] = useState(initDeps);
const searchQueryResult = useQuery(searchQueryOptions(deps));

return (
<main className="text-foreground py-6 md:py-12 px-10 md:px-16">
<h1 className="text-xl font-bold text-center sm:text-left md:text-4xl">
Search Results
</h1>
<div className="w-full flex gap-2 justify-between items-center">
<h1 className="text-xl font-bold text-center sm:text-left md:text-4xl">
Search Results
</h1>
<div className="flex flex-col items-center">
<h2 className="text-muted-foreground font-bold">
Page {((deps.page as number) ?? 0) + 1}
</h2>
<Pagination className="w-fit mx-0 text-2xl text-foreground">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() =>
setDeps((deps) => ({
...deps,
page: Math.max(
0,
((deps.page as number | undefined) ?? 0) - 1,
),
}))
}
/>
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={() =>
setDeps((deps) => ({
...deps,
page: Math.min(
50,
((deps.page as number | undefined) ?? 0) + 1,
),
}))
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
<div className="my-10 grid lg:grid-cols-3 md:grid-cols-2 sm:grid-cols-1 gap-5">
{searchQueryResult.data?.map((timetable) => {
return (
<Card
key={timetable.id}
className="w-md cursor-pointer"
onClick={() => router.navigate({ to: `/view/${timetable.id}` })}
>
<CardHeader>
<CardTitle>{timetable.name}</CardTitle>
<CardDescription>By: {timetable.authorId}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Badge>
{timetable.year}-{timetable.semester}
</Badge>
<Badge>
{timetable.acadYear}-
{(timetable.acadYear + 1).toString().substring(2)}
</Badge>
<Badge>{timetable.degrees}</Badge>
{timetable.archived ? (
<Badge variant="destructive">Archived</Badge>
) : null}
</div>
</CardContent>
</Card>
);
})}
{searchQueryResult.isFetching ? (
<div className="w-md h-96 bg-background">
<p className="text-center text-lg font-bold text-muted-foreground">
<Loader2 className="h-10 w-10 animate-spin" />
</p>
</div>
) : null}
{searchQueryResult.data?.length === 0 ? (
<div className="w-md h-96 bg-background">
<p className="text-lg font-bold text-muted-foreground">
No results found
</p>
</div>
) : null}
</div>
</main>
);
}
Expand Down
119 changes: 72 additions & 47 deletions frontend/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,94 @@
import { router } from "@/main";
import { ListFilter, Search } from "lucide-react";
import { useRef } from "react";
import { Button } from "./ui/button";
import { useState } from "react";

import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Input } from "./ui/input";
import { useToast } from "./ui/use-toast";
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { useLocation } from "@tanstack/react-router";
import { ChevronDown, Search } from "lucide-react";

const SearchBar = () => {
const { toast } = useToast();
const searchRef = useRef<HTMLInputElement>(null);
const handleSearch = async (query: string | undefined) => {
if (query === undefined || query.length < 2) {
toast({
title: "Error",
variant: "destructive",
description: "Search query has to be atleast 2 characters long",
});
return;
const [query, setQuery] = useState<string | undefined>();
const [year, setYear] = useState<string | undefined>();
const [semester, setSemester] = useState<string | undefined>();
const location = useLocation();

const handleSearch = async (
query: string | undefined,
semester: string | undefined,
year: string | undefined,
) => {
let searchString = `?${query ? `query=${query}&` : ""}${
semester ? `semester=${semester}&` : ""
}${year ? `year=${year}&` : ""}`;
if (searchString.endsWith("&") || searchString.endsWith("?"))
searchString = searchString.substring(0, searchString.length - 1);

if (location.pathname === "/search") {
router.navigate({ to: "/" });
setTimeout(
() => router.navigate({ to: `/search${searchString}`, replace: true }),
100,
);
} else {
router.navigate({ to: `/search${searchString}` });
}
router.navigate({
to: "/search/$query",
params: { query },
});
};
return (
<div className="flex items-center gap-2 m-1">
<div className="relative ml-auto flex-1 md:grow-0">
<Input
type="search"
placeholder="Search Timetables..."
className="w-full rounded-lg bg-background pl-4 md:w-48 lg:w-80"
ref={searchRef}
/>
<Search
className="absolute right-4 top-2.5 h-4 w-4 text-muted-foreground cursor-pointer"
onClick={() => handleSearch(searchRef.current?.value)}
/>
</div>
<div className="flex items-center w-full max-w-md gap-2 md:ml-10">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
type="search"
placeholder="Search (optional)..."
className="flex-1 rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-1 hidden">
<ListFilter className="h-3.5 w-3.5" />
<span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
Filter
</span>
<Button
variant="outline"
className="h-10 px-4 flex items-center gap-2"
>
<span>Filters</span>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="border border-slate-900 p-1 bg-slate-950 mt-2 rounded-sm"
>
<DropdownMenuLabel>Filter by</DropdownMenuLabel>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuLabel>Year</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={year}
onValueChange={(value) => setYear(value)}
>
<DropdownMenuRadioItem value="1">Year 1</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="2">Year 2</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="3">Year 3</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="4">Year 4</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="5">Year 5</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>Semester</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked>Course</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem>Name</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem>Archived</DropdownMenuCheckboxItem>
<DropdownMenuRadioGroup
value={semester}
onValueChange={(value) => setSemester(value)}
>
<DropdownMenuRadioItem value="1">Sem 1</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="2">Sem 2</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button onClick={() => handleSearch(query, semester, year)} size="icon">
<Search className="h-4 w-4" />
</Button>
</div>
);
};
Expand Down
Loading
Loading