Skip to content

Commit 7363bad

Browse files
tefkah3mcd
andauthored
feat: search on all pub page (#1290)
* feat: search on all pub page * fix: some layout + accessibility adjustments to list * feat: properly track server update status for highlighting * fix: spacing in pubcard * feat: add keyboard shortcut handler * fix: make pagination work properly when querying * fix: fix lint and type errors * feat: add clear search input button * fix: properly suspend a bunch of queries on the pub page * fix: fix layout * fix: don't flash Ctrl -> Cmd on mac * fix: remove keyboard shortcut for sidebar --------- Co-authored-by: Eric McDaniel <[email protected]>
1 parent 2d71267 commit 7363bad

File tree

24 files changed

+887
-177
lines changed

24 files changed

+887
-177
lines changed

core/app/c/[communitySlug]/ContentLayout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,19 @@ export const ContentLayout = ({
3030
left,
3131
right,
3232
children,
33+
className,
3334
}: {
3435
title: ReactNode;
3536
left?: ReactNode;
3637
right?: ReactNode;
3738
children: ReactNode;
39+
className?: string;
3840
}) => {
3941
return (
4042
<div className="absolute inset-0 w-full">
4143
<div className="flex h-full flex-col">
4244
<Heading title={title} left={left} right={right} />
43-
<div className="h-full flex-1 overflow-auto">{children}</div>
45+
<div className={`h-full flex-1 overflow-auto ${className || ""}`}>{children}</div>
4446
</div>
4547
</div>
4648
);
Lines changed: 113 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { Suspense } from "react";
22

3+
import type { ProcessedPub } from "contracts";
34
import type { CommunitiesId, UsersId } from "db/public";
45
import { Skeleton } from "ui/skeleton";
56
import { cn } from "utils";
67

78
import { searchParamsCache } from "~/app/components/DataTable/PubsDataTable/validations";
89
import { FooterPagination } from "~/app/components/Pagination";
910
import { PubCard } from "~/app/components/PubCard";
10-
import { getStageActions } from "~/lib/db/queries";
1111
import { getPubsCount, getPubsWithRelatedValues } from "~/lib/server";
1212
import { getCommunitySlug } from "~/lib/server/cache/getCommunitySlug";
1313
import { getStages } from "~/lib/server/stages";
14-
import { PubSelector } from "./PubSelector";
14+
import { PubSearch } from "./PubSearchInput";
1515
import { PubsSelectedProvider } from "./PubsSelectedContext";
1616
import { PubsSelectedCounter } from "./PubsSelectedCounter";
1717

@@ -27,56 +27,46 @@ type PaginatedPubListProps = {
2727
userId: UsersId;
2828
};
2929

30-
const PaginatedPubListInner = async (props: PaginatedPubListProps) => {
31-
const search = searchParamsCache.parse(props.searchParams);
32-
const [count, pubs, stages, actions] = await Promise.all([
33-
getPubsCount({ communityId: props.communityId }),
34-
getPubsWithRelatedValues(
30+
type PubListProcessedPub = ProcessedPub<{
31+
withPubType: true;
32+
withRelatedPubs: false;
33+
withStage: true;
34+
withRelatedCounts: true;
35+
}>;
36+
37+
const PaginatedPubListInner = async (
38+
props: PaginatedPubListProps & {
39+
communitySlug: string;
40+
pubsPromise: Promise<PubListProcessedPub[]>;
41+
}
42+
) => {
43+
const [pubs, stages] = await Promise.all([
44+
props.pubsPromise,
45+
getStages(
3546
{ communityId: props.communityId, userId: props.userId },
36-
{
37-
limit: search.perPage,
38-
offset: (search.page - 1) * search.perPage,
39-
orderBy: "updatedAt",
40-
withPubType: true,
41-
withRelatedPubs: false,
42-
withStage: true,
43-
withValues: false,
44-
withRelatedCounts: true,
45-
}
46-
),
47-
getStages({ communityId: props.communityId, userId: props.userId }).execute(),
48-
getStageActions({ communityId: props.communityId }).execute(),
47+
{ withActionInstances: "full" }
48+
).execute(),
4949
]);
5050

51-
const totalPages = Math.ceil(count / search.perPage);
52-
53-
const communitySlug = await getCommunitySlug();
54-
const basePath = props.basePath ?? `/c/${communitySlug}/pubs`;
55-
5651
return (
57-
<div className={cn("flex flex-col gap-8")}>
58-
<PubsSelectedProvider pubIds={[]}>
52+
<PubsSelectedProvider pubIds={[]}>
53+
<div className="mr-auto flex flex-col gap-3 md:max-w-screen-lg">
5954
{pubs.map((pub) => {
55+
const stageForPub = stages.find((stage) => stage.id === pub.stage?.id);
56+
6057
return (
6158
<PubCard
6259
key={pub.id}
6360
pub={pub}
64-
communitySlug={communitySlug}
65-
stages={stages}
66-
actionInstances={actions}
61+
communitySlug={props.communitySlug}
62+
moveFrom={stageForPub?.moveConstraintSources}
63+
moveTo={stageForPub?.moveConstraints}
64+
actionInstances={stageForPub?.actionInstances}
6765
/>
6866
);
6967
})}
70-
<FooterPagination
71-
basePath={basePath}
72-
searchParams={props.searchParams}
73-
page={search.page}
74-
totalPages={totalPages}
75-
>
76-
<PubsSelectedCounter pageSize={search.perPage} />
77-
</FooterPagination>
78-
</PubsSelectedProvider>
79-
</div>
68+
</div>
69+
</PubsSelectedProvider>
8070
);
8171
};
8272

@@ -87,7 +77,7 @@ export const PubListSkeleton = ({
8777
amount?: number;
8878
className?: string;
8979
}) => (
90-
<div className={cn(["flex flex-col gap-8", className])}>
80+
<div className={cn(["flex flex-col gap-3", className])}>
9181
{Array.from({ length: amount }).map((_, index) => (
9282
<Skeleton key={index} className="flex h-[90px] w-full flex-col gap-2 px-4 py-3">
9383
<Skeleton className="mt-3 h-6 w-24 space-y-1.5" />
@@ -97,10 +87,89 @@ export const PubListSkeleton = ({
9787
</div>
9888
);
9989

90+
const PubListFooterPagination = async (props: {
91+
basePath: string;
92+
searchParams: Record<string, unknown>;
93+
page: number;
94+
communityId: CommunitiesId;
95+
children: React.ReactNode;
96+
pubsPromise: Promise<ProcessedPub[]>;
97+
}) => {
98+
const perPage = searchParamsCache.get("perPage");
99+
const isQuery = !!searchParamsCache.get("query");
100+
101+
const count = await (isQuery
102+
? props.pubsPromise.then((pubs) => pubs.length)
103+
: getPubsCount({ communityId: props.communityId }));
104+
105+
const paginationProps = isQuery
106+
? {
107+
mode: "cursor" as const,
108+
hasNextPage: count > perPage,
109+
}
110+
: {
111+
mode: "total" as const,
112+
totalPages: Math.ceil((count ?? 0) / perPage),
113+
};
114+
115+
return (
116+
<FooterPagination {...props} {...paginationProps} className="z-20">
117+
{props.children}
118+
</FooterPagination>
119+
);
120+
};
121+
100122
export const PaginatedPubList: React.FC<PaginatedPubListProps> = async (props) => {
123+
const search = searchParamsCache.parse(props.searchParams);
124+
125+
const communitySlug = await getCommunitySlug();
126+
127+
const basePath = props.basePath ?? `/c/${communitySlug}/pubs`;
128+
129+
// we do one more than the total amount of pubs to know if there is a next page
130+
const limit = search.query ? search.perPage + 1 : search.perPage;
131+
132+
const pubsPromise = getPubsWithRelatedValues(
133+
{ communityId: props.communityId, userId: props.userId },
134+
{
135+
limit,
136+
offset: (search.page - 1) * search.perPage,
137+
orderBy: "updatedAt",
138+
withPubType: true,
139+
withRelatedPubs: false,
140+
withStage: true,
141+
withValues: false,
142+
withRelatedCounts: true,
143+
search: search.query,
144+
}
145+
);
146+
101147
return (
102-
<Suspense fallback={<PubListSkeleton />}>
103-
<PaginatedPubListInner {...props} />
104-
</Suspense>
148+
<div className="relative flex h-full flex-col">
149+
<div
150+
className={cn("mb-4 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4 pb-16")}
151+
>
152+
<PubSearch>
153+
<Suspense fallback={<PubListSkeleton />}>
154+
<PaginatedPubListInner
155+
{...props}
156+
communitySlug={communitySlug}
157+
pubsPromise={pubsPromise}
158+
/>
159+
</Suspense>
160+
</PubSearch>
161+
</div>
162+
<Suspense fallback={null}>
163+
<PubListFooterPagination
164+
basePath={basePath}
165+
searchParams={props.searchParams}
166+
page={search.page}
167+
communityId={props.communityId}
168+
pubsPromise={pubsPromise}
169+
>
170+
<PubsSelectedCounter pageSize={search.perPage} />
171+
</PubListFooterPagination>
172+
</Suspense>
173+
</div>
105174
);
106175
};
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"use client";
2+
3+
import React, { useDeferredValue, useEffect, useRef, useState } from "react";
4+
import { Search, X } from "lucide-react";
5+
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
6+
import { useDebouncedCallback } from "use-debounce";
7+
8+
import { KeyboardShortcutPriority, useKeyboardShortcut, usePlatformModifierKey } from "ui/hooks";
9+
import { Input } from "ui/input";
10+
import { cn } from "utils";
11+
12+
type PubSearchProps = React.PropsWithChildren<{}>;
13+
14+
const DEBOUNCE_TIME = 300;
15+
16+
export const PubSearch = (props: PubSearchProps) => {
17+
const [query, setQuery] = useQueryStates(
18+
{
19+
query: parseAsString.withDefault(""),
20+
page: parseAsInteger.withDefault(1),
21+
},
22+
{
23+
shallow: false,
24+
}
25+
);
26+
27+
// local input state for immediate UI responsiveness + sync with URL
28+
// otherwise, when navigating back/forward or refreshing, the input will be empty
29+
const [inputValue, setInputValue] = useState(query.query);
30+
31+
// deferred query to keep track of server updates
32+
// without this, we can't only check if inputValue !== query.query,
33+
// which only tells us that the debounce has happened
34+
const deferredQuery = useDeferredValue(query.query);
35+
36+
const inputRef = useRef<HTMLInputElement>(null);
37+
38+
useKeyboardShortcut(
39+
"Mod+k",
40+
() => {
41+
inputRef.current?.focus();
42+
inputRef.current?.select();
43+
},
44+
{
45+
priority: KeyboardShortcutPriority.MEDIUM,
46+
}
47+
);
48+
49+
// sync input with URL when navigating back/forward
50+
useEffect(() => {
51+
if (query.query === inputValue) {
52+
return;
53+
}
54+
setInputValue(query.query);
55+
}, [query.query]);
56+
57+
const { symbol, platform } = usePlatformModifierKey();
58+
59+
const debouncedSetQuery = useDebouncedCallback((value: string) => {
60+
if (value.length >= 2 || value.length === 0) {
61+
setQuery({ query: value, page: 1 }); // reset to page 1 on new search
62+
}
63+
}, DEBOUNCE_TIME);
64+
65+
const handleClearInput = () => {
66+
setInputValue("");
67+
setQuery({ query: "", page: 1 });
68+
};
69+
70+
// determine if content is stale, in order to provide a visual feedback to the user
71+
const isStale = inputValue !== deferredQuery;
72+
73+
return (
74+
<div className="flex flex-col gap-4">
75+
<div className="sticky top-0 z-20 flex max-w-md items-center gap-x-2">
76+
<Search
77+
className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-500"
78+
size={16}
79+
/>
80+
<Input
81+
ref={inputRef}
82+
value={inputValue}
83+
onChange={(e) => {
84+
setInputValue(e.target.value);
85+
debouncedSetQuery(e.target.value);
86+
}}
87+
placeholder="Search updates as you type..."
88+
className={cn("bg-white pl-8 tracking-wide shadow-none", inputValue && "pr-8")}
89+
/>
90+
<span className="absolute right-2 top-1/2 hidden -translate-y-1/2 items-center gap-x-2 font-mono text-xs text-gray-500 opacity-50 md:flex">
91+
{inputValue && (
92+
<button
93+
onClick={handleClearInput}
94+
className="rounded-full p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700 md:right-16"
95+
type="button"
96+
aria-label="Clear search"
97+
>
98+
<X size={14} />
99+
</button>
100+
)}
101+
<span
102+
className={cn(
103+
"flex w-10 items-center justify-center gap-x-1 transition-opacity duration-200",
104+
{
105+
// hide until hydrated, otherwise you see flash of `Ctrl` -> `Cmd` on mac
106+
"opacity-0": platform === "unknown",
107+
}
108+
)}
109+
>
110+
<span className={cn({ "mt-0.5 text-lg": platform === "mac" })}>
111+
{symbol}
112+
</span>{" "}
113+
K
114+
</span>
115+
</span>
116+
</div>
117+
<div className={cn(isStale && "opacity-50 transition-opacity duration-200")}>
118+
{props.children}
119+
</div>
120+
</div>
121+
);
122+
};

core/app/c/[communitySlug]/pubs/PubSelector.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const PubSelector = ({ pubId, className }: { pubId: PubsId; className?: s
1010

1111
return (
1212
<Checkbox
13+
aria-label="Select pub"
1314
checked={isSelected(pubId)}
1415
onCheckedChange={() => {
1516
toggle(pubId);

0 commit comments

Comments
 (0)