Skip to content

Commit

Permalink
Merge pull request #214 from crux-bphc/feat-search-component
Browse files Browse the repository at this point in the history
Feat search component
  • Loading branch information
skoriop committed Jul 30, 2024
2 parents c9987a0 + da69b6c commit 7fb67ba
Show file tree
Hide file tree
Showing 9 changed files with 710 additions and 237 deletions.
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

0 comments on commit 7fb67ba

Please sign in to comment.