Skip to content

Commit fc692cb

Browse files
authored
feat: basic workspace search (#203)
* feat: set up command pallette and search endpoint * feat: use placeholder data in search results * feat: add keyboard navigation * feat: add icons for search results * feat: add fuzzy search * feat: add search button to collapsed menu * feat: add keyboard shortcuts * fix: prevent no results flickering * feat: consistent font size for search placeholder * chore: build translations
1 parent adbb25e commit fc692cb

File tree

22 files changed

+3627
-315
lines changed

22 files changed

+3627
-315
lines changed

apps/web/i18n.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ checksums:
235235
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
236236
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
237237
Importing%20your%20Trello%20boards%20into%20Kan%20is%20easy.%20You%20can%20follow%20our%20step-by-step%20guide%20%3C0%3Ehere%3C%2F0%3E./singular: ca88dc22801fef08fee1ecfb1bf6914e
238+
in/singular: 1f06ad6763244d5e9927853e65cc6274
238239
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
239240
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
240241
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
@@ -244,6 +245,7 @@ checksums:
244245
Invite/singular: 181884cea804cbde665f160811ee7ad0
245246
Invite%20link%20copied/singular: 4046f23a78e1cd5166671c3fb8a7ea6e
246247
Invite%20link%20copied%20to%20clipboard/singular: 6fc055a0ea0ed1aa58c5e0c502efe17f
248+
Invite%20links%20require%20a%20Team%20or%20Pro%20subscription.%20Please%20upgrade%20your%20workspace./singular: 18db646fc55f99789572c8df0539ee69
247249
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
248250
Inviting%20members%20requires%20a%20Team%20Plan.%20You'll%20be%20redirected%20to%20upgrade%20your%20workspace./singular: 03eeed2d715e770259f722ba48a61ab3
249251
Join%20workspace/singular: f5d035df672b05abd760bd022309c719
@@ -299,6 +301,7 @@ checksums:
299301
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
300302
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
301303
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
304+
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
302305
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
303306
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
304307
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
@@ -358,6 +361,7 @@ checksums:
358361
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
359362
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
360363
Screening/singular: 0327bfb25db87bdacf00d4dc297f8b26
364+
Search%20boards%20and%20cards.../singular: 137017b2b3e885c4894ca8c899af5bc2
361365
Select%20all/singular: eedc7cdb02de467c15dc418a066a77f2
362366
Select%20source/singular: c7b0ec447415ff62856803a4d7838bd6
363367
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
@@ -383,6 +387,7 @@ checksums:
383387
Social%20Media/singular: 2227508afb0d38a027e323207e47d4a2
384388
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
385389
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
390+
Subscription%20Required/singular: 05f2b4abfc36def17756a6969983cf86
386391
Success/singular: c43827becada6750f7a25890905f38b9
387392
Supercharge%20your%20workspace%20for%20just%20%2429%2Fmonth.%20Here's%20what%20you'll%20get%3A/singular: 21af8119141afcce5f12da489f34db6a
388393
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { useRouter } from "next/router";
2+
import {
3+
Combobox,
4+
ComboboxInput,
5+
ComboboxOption,
6+
ComboboxOptions,
7+
Dialog,
8+
DialogBackdrop,
9+
DialogPanel,
10+
} from "@headlessui/react";
11+
import { t } from "@lingui/macro";
12+
import { useState } from "react";
13+
import { HiDocumentText, HiFolder, HiMagnifyingGlass } from "react-icons/hi2";
14+
15+
import { useDebounce } from "~/hooks/useDebounce";
16+
import { useWorkspace } from "~/providers/workspace";
17+
import { api } from "~/utils/api";
18+
19+
type SearchResult =
20+
| {
21+
publicId: string;
22+
title: string;
23+
description: string | null;
24+
slug: string;
25+
updatedAt: Date | null;
26+
createdAt: Date;
27+
type: "board";
28+
}
29+
| {
30+
publicId: string;
31+
title: string;
32+
description: string | null;
33+
boardPublicId: string;
34+
boardName: string;
35+
listName: string;
36+
updatedAt: Date | null;
37+
createdAt: Date;
38+
type: "card";
39+
};
40+
41+
export default function CommandPallette({
42+
isOpen,
43+
onClose,
44+
}: {
45+
isOpen: boolean;
46+
onClose: () => void;
47+
}) {
48+
const [query, setQuery] = useState("");
49+
const { workspace } = useWorkspace();
50+
const router = useRouter();
51+
52+
// Debounce to avoid too many reqs
53+
const [debouncedQuery] = useDebounce(query, 300);
54+
55+
const {
56+
data: searchResults,
57+
isLoading,
58+
isFetched,
59+
isPlaceholderData,
60+
} = api.workspace.search.useQuery(
61+
{
62+
workspacePublicId: workspace.publicId,
63+
query: debouncedQuery,
64+
},
65+
{
66+
enabled: Boolean(workspace.publicId && debouncedQuery.trim().length > 0),
67+
placeholderData: (previousData) => previousData,
68+
},
69+
);
70+
71+
// Clear results when query is empty, otherwise show search results
72+
const results =
73+
debouncedQuery.trim().length === 0
74+
? []
75+
: ((searchResults ?? []) as SearchResult[]);
76+
77+
const hasSearched = Boolean(debouncedQuery.trim().length > 0);
78+
79+
return (
80+
<Dialog
81+
className="relative z-50"
82+
open={isOpen}
83+
onClose={() => {
84+
onClose();
85+
setQuery("");
86+
}}
87+
>
88+
<DialogBackdrop
89+
transition
90+
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
91+
/>
92+
93+
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
94+
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
95+
<DialogPanel
96+
transition
97+
className="data-closed:opacity-0 data-closed:translate-y-4 data-closed:sm:translate-y-0 data-closed:sm:scale-95 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in relative mt-[25vh] w-full max-w-[550px] transform divide-y divide-gray-100 overflow-hidden rounded-lg border border-light-600 bg-white/90 shadow-3xl-light backdrop-blur-[6px] transition-all dark:divide-white/10 dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark"
98+
>
99+
<Combobox>
100+
<div className="grid grid-cols-1">
101+
<ComboboxInput
102+
autoFocus
103+
className="col-start-1 row-start-1 h-12 w-full border-0 bg-transparent pl-11 pr-4 text-sm text-light-900 placeholder:text-light-700 focus:outline-none focus:ring-0 dark:text-dark-900 dark:placeholder:text-dark-700"
104+
placeholder={t`Search boards and cards...`}
105+
value={query}
106+
onChange={(event) => setQuery(event.target.value)}
107+
onKeyDown={(event) => {
108+
if (event.key === "Enter" && results.length > 0) {
109+
event.preventDefault();
110+
111+
// Find the active option or fallback to first option
112+
const targetOption =
113+
document.querySelector(
114+
'[data-headlessui-state*="active"][role="option"]',
115+
) ?? document.querySelector('[role="option"]');
116+
117+
if (targetOption) {
118+
(targetOption as HTMLElement).click();
119+
}
120+
}
121+
}}
122+
/>
123+
<HiMagnifyingGlass
124+
className="pointer-events-none col-start-1 row-start-1 ml-4 size-5 self-center text-light-700 dark:text-dark-700"
125+
aria-hidden="true"
126+
/>
127+
</div>
128+
129+
{results.length > 0 && (
130+
<ComboboxOptions
131+
static
132+
className={`max-h-72 scroll-py-2 overflow-y-auto py-2 ${
133+
isPlaceholderData ? "opacity-75" : ""
134+
}`}
135+
>
136+
{results.map((result) => {
137+
const url =
138+
result.type === "board"
139+
? `/boards/${result.publicId}`
140+
: `/cards/${result.publicId}`;
141+
142+
return (
143+
<ComboboxOption
144+
key={`${result.type}-${result.publicId}`}
145+
value={result}
146+
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
147+
onClick={() => {
148+
console.log("clicked", url);
149+
void router.push(url);
150+
onClose();
151+
setQuery("");
152+
}}
153+
>
154+
<div className="flex items-start gap-3">
155+
<div className="mt-0.5 flex-shrink-0">
156+
{result.type === "board" ? (
157+
<HiFolder className="h-4 w-4 text-light-600 dark:text-dark-600" />
158+
) : (
159+
<HiDocumentText className="h-4 w-4 text-light-600 dark:text-dark-600" />
160+
)}
161+
</div>
162+
<div className="min-w-0 flex-1 text-left">
163+
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
164+
{result.title}
165+
</div>
166+
{result.type === "card" && (
167+
<div className="truncate text-xs text-light-700 dark:text-dark-700">
168+
{`${t`in`} ${result.boardName}${result.listName}`}
169+
</div>
170+
)}
171+
</div>
172+
</div>
173+
</ComboboxOption>
174+
);
175+
})}
176+
</ComboboxOptions>
177+
)}
178+
179+
{hasSearched &&
180+
!isLoading &&
181+
searchResults !== undefined &&
182+
results.length === 0 && (
183+
<div className="p-4 text-sm text-light-950 dark:text-dark-950">
184+
{t`No results found for "${debouncedQuery}".`}
185+
</div>
186+
)}
187+
</Combobox>
188+
</DialogPanel>
189+
</div>
190+
</div>
191+
</Dialog>
192+
);
193+
}

0 commit comments

Comments
 (0)