Skip to content

Commit 13241c2

Browse files
tefkah3mcd
andauthored
fix: properly handle empty states and invalid characters in fts (#1401)
* feat: add proper empty state for pubs * fix: don't stale if query is only 1 char, bc we will not search that * fix: escape the search query a bit more to avoid crashes when eg searching for https://google.com --------- Co-authored-by: Eric McDaniel <[email protected]>
1 parent c1d3801 commit 13241c2

File tree

8 files changed

+194
-21
lines changed

8 files changed

+194
-21
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"use client";
2+
3+
import { X } from "lucide-react";
4+
5+
import { Button } from "ui/button";
6+
7+
import { usePubSearch } from "./PubSearchProvider";
8+
9+
export const PubClearSearchButton = ({ className }: { className?: string }) => {
10+
const { setQuery, setFilters } = usePubSearch();
11+
return (
12+
<Button
13+
variant="ghost"
14+
size="sm"
15+
className={className}
16+
onClick={() => {
17+
setQuery("");
18+
setFilters((old) => ({
19+
...old,
20+
pubTypes: [],
21+
stages: [],
22+
filters: [],
23+
}));
24+
}}
25+
>
26+
<X size={16} />
27+
Clear filters
28+
</Button>
29+
);
30+
};

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
11
import { Suspense } from "react";
2+
import { BookOpen, Icon, Search } from "lucide-react";
23

34
import type { ProcessedPub } from "contracts";
45
import type { CommunitiesId, UsersId } from "db/public";
6+
import type { Button } from "ui/button";
7+
import {
8+
Empty,
9+
EmptyContent,
10+
EmptyDescription,
11+
EmptyHeader,
12+
EmptyMedia,
13+
EmptyTitle,
14+
} from "ui/empty";
515
import { PubFieldProvider } from "ui/pubFields";
616
import { Skeleton } from "ui/skeleton";
717
import { stagesDAO, StagesProvider } from "ui/stages";
818
import { cn } from "utils";
919

1020
import type { AutoReturnType } from "~/lib/types";
21+
import { CreatePubButton } from "~/app/components/pubs/CreatePubButton";
1122
import { PubCard } from "~/app/components/pubs/PubCard/PubCard";
23+
import { SkeletonButton } from "~/app/components/skeletons/SkeletonButton";
1224
import {
25+
getCreatablePubTypes,
1326
userCanArchiveAllPubs,
27+
userCanCreatePub,
1428
userCanEditAllPubs,
1529
userCanMoveAllPubs,
1630
userCanRunActionsAllPubs,
@@ -20,6 +34,7 @@ import { getPubsCount, getPubsWithRelatedValues, getPubTypesForCommunity } from
2034
import { getCommunitySlug } from "~/lib/server/cache/getCommunitySlug";
2135
import { getPubFields } from "~/lib/server/pubFields";
2236
import { getStages } from "~/lib/server/stages";
37+
import { PubClearSearchButton } from "./PubClearSearchButton";
2338
import { getPubFilterParamsFromSearch, pubSearchParamsCache } from "./pubQuery";
2439
import { PubSearchFooter } from "./PubSearchFooter";
2540
import { PubSearch } from "./PubSearchInput";
@@ -71,8 +86,41 @@ const PaginatedPubListInner = async (
7186
userCanViewAllStages(),
7287
]);
7388

89+
const hasSearch =
90+
props.searchParams.query !== "" ||
91+
(props.searchParams.pubTypes?.length ?? 0) > 0 ||
92+
(props.searchParams.stages?.length ?? 0) > 0;
7493
return (
7594
<div className="mr-auto flex flex-col gap-3 md:max-w-screen-lg">
95+
{pubs.length === 0 && (
96+
<Empty className="">
97+
<EmptyHeader>
98+
<EmptyMedia variant="icon">
99+
<BookOpen size={16} />
100+
</EmptyMedia>
101+
<EmptyTitle>No Pubs Found</EmptyTitle>
102+
{hasSearch && (
103+
<EmptyDescription>
104+
Try adjusting your filters or search query.
105+
</EmptyDescription>
106+
)}
107+
</EmptyHeader>
108+
<EmptyContent>
109+
{hasSearch ? (
110+
<PubClearSearchButton />
111+
) : (
112+
<Suspense fallback={<SkeletonButton className="w-20" />}>
113+
<CreatePubButton
114+
communityId={props.communityId}
115+
className="bg-emerald-500 text-white hover:bg-emerald-600"
116+
text="Create Pub"
117+
/>
118+
</Suspense>
119+
)}
120+
</EmptyContent>
121+
</Empty>
122+
)}
123+
76124
{pubs.map((pub) => {
77125
const stageForPub = stages.find((stage) => stage.id === pub.stage?.id);
78126

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

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,7 @@
11
"use client";
22

3-
import React, {
4-
use,
5-
useCallback,
6-
useDeferredValue,
7-
useEffect,
8-
useMemo,
9-
useRef,
10-
useState,
11-
} from "react";
3+
import React, { useRef } from "react";
124
import { ArrowUpDownIcon, PlusCircle, Search, SortAsc, SortDesc, X } from "lucide-react";
13-
import {
14-
parseAsArrayOf,
15-
parseAsInteger,
16-
parseAsString,
17-
parseAsStringEnum,
18-
useQueryStates,
19-
} from "nuqs";
20-
import { useDebouncedCallback } from "use-debounce";
215

226
import type { PubTypesId, StagesId } from "db/public";
237
import { Button } from "ui/button";
@@ -32,9 +16,7 @@ import { Input } from "ui/input";
3216
import { MultiSelect } from "ui/multi-select";
3317
import { cn } from "utils";
3418

35-
import type { PubSearchParams } from "./pubQuery";
3619
import { entries } from "~/lib/mapping";
37-
import { pubSearchParsers } from "./pubQuery";
3820
import { usePubSearch } from "./PubSearchProvider";
3921

4022
export type StageFilters = {
@@ -225,7 +207,13 @@ export const PubSearch = (props: PubSearchProps) => {
225207
</DropdownMenuContent>
226208
</DropdownMenu>
227209
</div>
228-
<div className={cn(stale && "opacity-50 transition-opacity duration-200", "m-4 mt-1")}>
210+
<div
211+
className={cn(
212+
stale &&
213+
'opacity-50 transition-opacity duration-200 [&_[data-testid*="pub-card"]]:animate-pulse',
214+
"m-4 mt-1"
215+
)}
216+
>
229217
{props.children}
230218
</div>
231219
</div>

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ const PubSearchContext = createContext<PubSearchContextType>({
6666
const DEBOUNCE_TIME = 300;
6767

6868
const isStale = (query: PubSearchParams, inputValues: PubSearchParams) => {
69+
if (inputValues.query.length === 1) {
70+
return false;
71+
}
72+
6973
if (query.query !== inputValues.query) {
7074
return true;
7175
}

core/app/components/pubs/CreatePubButton.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ export const CreatePubButton = async (props: Props) => {
104104

105105
const pubTypes = await getCreatablePubTypes(user.id, community.id);
106106

107+
if (pubTypes.length === 0) {
108+
return null;
109+
}
110+
107111
const stageId = "stageId" in props ? props.stageId : undefined;
108112

109113
return (

core/lib/server/pub.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2173,7 +2173,7 @@ export const createTsQuery = (query: string, config: SearchConfig = {}) => {
21732173

21742174
const { prefixSearch = true, minLength = 2 } = options;
21752175

2176-
const cleanQuery = query.trim();
2176+
const cleanQuery = query.trim().replace(/[:@]/g, "");
21772177
if (cleanQuery.length < minLength) {
21782178
return null;
21792179
}

packages/ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"./alert": "./dist/ui-alert.js",
1313
"./badge": "./dist/ui-badge.js",
1414
"./color": "./dist/ui-color.js",
15+
"./empty": "./dist/ui-empty.js",
1516
"./field": "./dist/ui-field.js",
1617
"./input": "./dist/ui-input.js",
1718
"./label": "./dist/ui-label.js",
@@ -270,6 +271,7 @@
270271
"dialog.tsx",
271272
"dropdown-menu.tsx",
272273
"editors/index.tsx",
274+
"empty.tsx",
273275
"field.tsx",
274276
"form.tsx",
275277
"hover-card.tsx",

packages/ui/src/empty.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { VariantProps } from "class-variance-authority";
2+
3+
import * as React from "react";
4+
import { cva } from "class-variance-authority";
5+
6+
import { cn } from "utils";
7+
8+
function Empty({ className, ...props }: React.ComponentProps<"div">) {
9+
return (
10+
<div
11+
data-slot="empty"
12+
className={cn(
13+
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12",
14+
className
15+
)}
16+
{...props}
17+
/>
18+
);
19+
}
20+
21+
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
22+
return (
23+
<div
24+
data-slot="empty-header"
25+
className={cn("flex max-w-sm flex-col items-center gap-2 text-center", className)}
26+
{...props}
27+
/>
28+
);
29+
}
30+
31+
const emptyMediaVariants = cva(
32+
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
33+
{
34+
variants: {
35+
variant: {
36+
default: "bg-transparent",
37+
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
38+
},
39+
},
40+
defaultVariants: {
41+
variant: "default",
42+
},
43+
}
44+
);
45+
46+
function EmptyMedia({
47+
className,
48+
variant = "default",
49+
...props
50+
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
51+
return (
52+
<div
53+
data-slot="empty-icon"
54+
data-variant={variant}
55+
className={cn(emptyMediaVariants({ variant, className }))}
56+
{...props}
57+
/>
58+
);
59+
}
60+
61+
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
62+
return (
63+
<div
64+
data-slot="empty-title"
65+
className={cn("text-lg font-medium tracking-tight", className)}
66+
{...props}
67+
/>
68+
);
69+
}
70+
71+
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
72+
return (
73+
<div
74+
data-slot="empty-description"
75+
className={cn(
76+
"text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
77+
className
78+
)}
79+
{...props}
80+
/>
81+
);
82+
}
83+
84+
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
85+
return (
86+
<div
87+
data-slot="empty-content"
88+
className={cn(
89+
"flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm",
90+
className
91+
)}
92+
{...props}
93+
/>
94+
);
95+
}
96+
97+
export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };

0 commit comments

Comments
 (0)