Skip to content

Commit a11aa90

Browse files
authored
Merge pull request #751 from shnai0/search2
feat: search_bar
2 parents 10e2be7 + fede314 commit a11aa90

File tree

5 files changed

+271
-24
lines changed

5 files changed

+271
-24
lines changed

components/documents/document-card.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export default function DocumentsCard({
212212

213213
<div className="flex-col">
214214
<div className="flex items-center">
215-
<h2 className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md">
215+
<h2 className="min-w-0 max-w-[250px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md">
216216
<Link
217217
href={`/documents/${prismaDocument.id}`}
218218
className="w-full truncate"

components/search-box.tsx

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Inspired by Steven Tey's flawless search implementation in dub.co
2+
// https://github.com/dubinc/dub/blob/450749a29ca2ec2486fb2272a73cfbd8a5d80b3f/apps/web/ui/shared/search-box.tsx
3+
import { useRouter } from "next/router";
4+
5+
import {
6+
forwardRef,
7+
useCallback,
8+
useEffect,
9+
useImperativeHandle,
10+
useRef,
11+
useState,
12+
} from "react";
13+
14+
import { CircleXIcon, SearchIcon } from "lucide-react";
15+
import { useDebouncedCallback } from "use-debounce";
16+
17+
import { cn } from "@/lib/utils";
18+
19+
import LoadingSpinner from "./ui/loading-spinner";
20+
21+
type SearchBoxProps = {
22+
value: string;
23+
loading?: boolean;
24+
showClearButton?: boolean;
25+
onChange: (value: string) => void;
26+
onChangeDebounced?: (value: string) => void;
27+
debounceTimeoutMs?: number;
28+
inputClassName?: string;
29+
};
30+
31+
const SearchBox = forwardRef(
32+
(
33+
{
34+
value,
35+
loading,
36+
showClearButton = true,
37+
onChange,
38+
onChangeDebounced,
39+
debounceTimeoutMs = 500,
40+
inputClassName,
41+
}: SearchBoxProps,
42+
forwardedRef,
43+
) => {
44+
const inputRef = useRef<HTMLInputElement>(null);
45+
useImperativeHandle(forwardedRef, () => inputRef.current);
46+
47+
const debounced = useDebouncedCallback(
48+
(value) => onChangeDebounced?.(value),
49+
debounceTimeoutMs,
50+
);
51+
52+
const onKeyDown = useCallback((e: KeyboardEvent) => {
53+
const target = e.target as HTMLElement;
54+
// only focus on filter input when:
55+
// - user is not typing in an input or textarea
56+
// - there is no existing modal backdrop (i.e. no other modal is open)
57+
if (
58+
e.key === "/" &&
59+
target.tagName !== "INPUT" &&
60+
target.tagName !== "TEXTAREA"
61+
) {
62+
e.preventDefault();
63+
inputRef.current?.focus();
64+
}
65+
}, []);
66+
67+
useEffect(() => {
68+
document.addEventListener("keydown", onKeyDown);
69+
return () => document.removeEventListener("keydown", onKeyDown);
70+
}, [onKeyDown]);
71+
72+
return (
73+
<div className="relative">
74+
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
75+
{loading && value.length > 0 ? (
76+
<LoadingSpinner className="h-4 w-4" />
77+
) : (
78+
<SearchIcon className="h-4 w-4 text-muted-foreground" />
79+
)}
80+
</div>
81+
<input
82+
ref={inputRef}
83+
type="text"
84+
className={cn(
85+
"peer w-full rounded-md border border-border bg-white px-10 text-foreground outline-none placeholder:text-muted-foreground dark:bg-gray-800 sm:text-sm",
86+
"transition-all focus:border-gray-500 focus:ring-0",
87+
inputClassName,
88+
)}
89+
placeholder="Search..."
90+
value={value}
91+
onChange={(e) => {
92+
onChange(e.target.value);
93+
debounced(e.target.value);
94+
}}
95+
autoCapitalize="none"
96+
/>
97+
{showClearButton && value.length > 0 && (
98+
<button
99+
onClick={() => {
100+
onChange("");
101+
onChangeDebounced?.("");
102+
}}
103+
className="pointer-events-auto absolute inset-y-0 right-0 flex items-center pr-4"
104+
>
105+
<CircleXIcon className="h-4 w-4 text-muted-foreground" />
106+
</button>
107+
)}
108+
</div>
109+
);
110+
},
111+
);
112+
SearchBox.displayName = "SearchBox";
113+
114+
export function SearchBoxPersisted({
115+
urlParam = "search",
116+
...props
117+
}: { urlParam?: string } & Partial<SearchBoxProps>) {
118+
const router = useRouter();
119+
const queryParams = router.query;
120+
121+
const [value, setValue] = useState(queryParams[urlParam] ?? "");
122+
const [debouncedValue, setDebouncedValue] = useState(value);
123+
124+
console.log("queryParams", queryParams);
125+
console.log("debouncedValue", debouncedValue);
126+
console.log("value", value);
127+
128+
// Set URL param when debounced value changes
129+
useEffect(() => {
130+
if (queryParams[urlParam] ?? "" !== debouncedValue)
131+
if (debouncedValue === "") {
132+
delete queryParams[urlParam];
133+
router.push(
134+
{
135+
pathname: router.pathname,
136+
query: queryParams,
137+
},
138+
undefined,
139+
{ shallow: true },
140+
);
141+
} else {
142+
queryParams[urlParam] = debouncedValue;
143+
console.log("queryParams", queryParams);
144+
router.push(
145+
{
146+
pathname: router.pathname,
147+
query: queryParams,
148+
},
149+
undefined,
150+
{ shallow: true },
151+
);
152+
}
153+
}, [debouncedValue]);
154+
155+
// Set value when URL param changes
156+
useEffect(() => {
157+
const search = queryParams[urlParam];
158+
// Only update if the value and debouncedValue are synced (the user isn't actively typing)
159+
if ((search ?? "" !== value) && value === debouncedValue) {
160+
setValue(search ?? "");
161+
}
162+
}, [queryParams[urlParam]]);
163+
164+
return (
165+
<SearchBox
166+
value={value as string}
167+
onChange={setValue}
168+
onChangeDebounced={setDebouncedValue}
169+
{...props}
170+
/>
171+
);
172+
}

lib/swr/use-documents.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useRouter } from "next/router";
2+
13
import { useTeam } from "@/context/team-context";
24
import { Folder } from "@prisma/client";
35
import useSWR from "swr";
@@ -6,23 +8,33 @@ import { DocumentWithLinksAndLinkCountAndViewCount } from "@/lib/types";
68
import { fetcher } from "@/lib/utils";
79

810
export default function useDocuments() {
11+
const router = useRouter();
912
const teamInfo = useTeam();
13+
const teamId = teamInfo?.currentTeam?.id;
1014

11-
const { data: documents, error } = useSWR<
12-
DocumentWithLinksAndLinkCountAndViewCount[]
13-
>(
14-
teamInfo?.currentTeam?.id &&
15-
`/api/teams/${teamInfo?.currentTeam?.id}/documents`,
15+
const queryParams = router.query;
16+
const searchQuery = queryParams["search"];
17+
18+
const {
19+
data: documents,
20+
isValidating,
21+
error,
22+
} = useSWR<DocumentWithLinksAndLinkCountAndViewCount[]>(
23+
teamId &&
24+
`/api/teams/${teamId}/documents${searchQuery ? `/search?query=${searchQuery}` : ""}`,
1625
fetcher,
1726
{
1827
revalidateOnFocus: false,
1928
dedupingInterval: 30000,
29+
keepPreviousData: true,
2030
},
2131
);
2232

2333
return {
2434
documents,
35+
isValidating,
2536
loading: !documents && !error,
37+
isSearchResult: !!searchQuery,
2638
error,
2739
};
2840
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { NextApiRequest, NextApiResponse } from "next";
2+
3+
import { authOptions } from "@/pages/api/auth/[...nextauth]";
4+
import { getServerSession } from "next-auth/next";
5+
6+
import { errorhandler } from "@/lib/errorHandler";
7+
import prisma from "@/lib/prisma";
8+
import { CustomUser } from "@/lib/types";
9+
10+
export default async function handle(
11+
req: NextApiRequest,
12+
res: NextApiResponse,
13+
) {
14+
if (req.method === "GET") {
15+
const session = await getServerSession(req, res, authOptions);
16+
if (!session) {
17+
return res.status(401).end("Unauthorized");
18+
}
19+
20+
const { teamId, query } = req.query as { teamId: string; query?: string };
21+
const userId = (session.user as CustomUser).id;
22+
23+
try {
24+
const team = await prisma.team.findUnique({
25+
where: {
26+
id: teamId,
27+
users: {
28+
some: {
29+
userId: userId,
30+
},
31+
},
32+
},
33+
});
34+
35+
if (!team) {
36+
return res.status(404).end("Team not found");
37+
}
38+
39+
const documents = await prisma.document.findMany({
40+
where: {
41+
teamId: teamId,
42+
name: {
43+
contains: query,
44+
mode: "insensitive",
45+
},
46+
},
47+
orderBy: {
48+
createdAt: "desc",
49+
},
50+
include: {
51+
_count: {
52+
select: { links: true, views: true, versions: true },
53+
},
54+
},
55+
});
56+
57+
return res.status(200).json(documents);
58+
} catch (error) {
59+
errorhandler(error, res);
60+
}
61+
} else {
62+
res.setHeader("Allow", ["GET"]);
63+
return res.status(405).end(`Method ${req.method} Not Allowed`);
64+
}
65+
}

pages/documents/index.tsx

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,42 @@
11
import { useTeam } from "@/context/team-context";
22
import { FolderPlusIcon, PlusIcon } from "lucide-react";
3-
import ErrorPage from "next/error";
3+
44
import { AddDocumentModal } from "@/components/documents/add-document-modal";
55
import { DocumentsList } from "@/components/documents/documents-list";
66
import { AddFolderModal } from "@/components/folders/add-folder-modal";
77
import AppLayout from "@/components/layouts/app";
8+
import { SearchBoxPersisted } from "@/components/search-box";
89
import { Button } from "@/components/ui/button";
910
import { Separator } from "@/components/ui/separator";
1011

1112
import useDocuments, { useRootFolders } from "@/lib/swr/use-documents";
1213

1314
export default function Documents() {
14-
const { documents,error } = useDocuments();
15-
const { folders } = useRootFolders();
1615
const teamInfo = useTeam();
1716

18-
if (error && error.status === 404) {
19-
return <ErrorPage statusCode={404} />;
20-
}
21-
if (error && error.status === 500) {
22-
return <ErrorPage statusCode={500} />;
23-
}
17+
const { folders } = useRootFolders();
18+
const { documents, isValidating, isSearchResult } = useDocuments();
2419

2520
return (
2621
<AppLayout>
2722
<div className="sticky top-0 z-50 bg-white p-4 pb-0 dark:bg-gray-900 sm:mx-4 sm:pt-8">
28-
<section className="mb-4 flex items-center justify-between space-x-2 sm:space-x-0 md:mb-8 lg:mb-12">
23+
<section className="mb-4 flex items-center justify-between space-x-2 sm:space-x-0">
2924
<div className="space-y-0 sm:space-y-1">
3025
<h2 className="text-xl font-semibold tracking-tight text-foreground sm:text-2xl">
3126
All Documents
3227
</h2>
33-
<p className="text-xs text-muted-foreground leading-4 sm:leading-none sm:text-sm">
28+
<p className="text-xs leading-4 text-muted-foreground sm:text-sm sm:leading-none">
3429
Manage all your documents in one place.
3530
</p>
3631
</div>
37-
<div className="flex items-center gap-x-1">
32+
<div className="flex items-center gap-x-2">
3833
<AddDocumentModal>
3934
<Button
40-
className="group flex flex-1 items-center justify-start whitespace-nowrap gap-x-1 sm:gap-x-3 px-1 sm:px-3 text-left"
35+
className="group flex flex-1 items-center justify-start gap-x-1 whitespace-nowrap px-1 text-left sm:gap-x-3 sm:px-3"
4136
title="Add New Document"
4237
>
4338
<PlusIcon className="h-5 w-5 shrink-0" aria-hidden="true" />
44-
<span className=" text-xs sm:text-base">Add New Document</span>
39+
<span className="text-xs sm:text-base">Add New Document</span>
4540
</Button>
4641
</AddDocumentModal>
4742
<AddFolderModal>
@@ -59,16 +54,19 @@ export default function Documents() {
5954
</div>
6055
</section>
6156

62-
{/* Portaled in from DocumentsList component */}
57+
<div className="flex justify-end">
58+
<div className="relative w-full sm:max-w-xs">
59+
<SearchBoxPersisted loading={isValidating} inputClassName="h-10" />
60+
</div>
61+
</div>
62+
6363
<section id="documents-header-count" />
6464

6565
<Separator className="mb-5 bg-gray-200 dark:bg-gray-800" />
66-
</div>
6766

68-
<div className="p-4 pt-0 sm:mx-4 sm:mt-4">
6967
<DocumentsList
7068
documents={documents}
71-
folders={folders}
69+
folders={isSearchResult ? [] : folders}
7270
teamInfo={teamInfo}
7371
/>
7472
</div>

0 commit comments

Comments
 (0)