Skip to content

Commit

Permalink
facet filtering ux
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity committed Nov 15, 2024
1 parent d48b42d commit 910053a
Show file tree
Hide file tree
Showing 12 changed files with 802 additions and 68 deletions.
2 changes: 2 additions & 0 deletions packages/ui/fern-docs-search-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"@fern-ui/fern-docs-search-server": "workspace:*",
"@fern-ui/fern-http-method-tag": "workspace:*",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"ai": "^3.4.33",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { FACET_DISPLAY_NAME_MAP, getFacets, toFilterOptions } from "@/utils/facet";
import { useFacets } from "@/hooks/useFacets";
import { FACET_DISPLAY_NAME_MAP, FacetName, getFacets, toFilterOptions } from "@/utils/facet";
import { getFacetDisplay } from "@/utils/facet-display";
import { toFiltersString } from "@/utils/to-filter-string";
import { AlgoliaRecord } from "@fern-ui/fern-docs-search-server/types";
import { HttpMethodTag } from "@fern-ui/fern-http-method-tag";
import { TooltipPortal, TooltipTrigger } from "@radix-ui/react-tooltip";
import { Command } from "cmdk";
import { ArrowLeft, FileText, History, ListFilter, MessageCircle } from "lucide-react";
import { Dispatch, ReactElement, ReactNode, SetStateAction, useRef } from "react";
import { Dispatch, ReactElement, SetStateAction, useRef } from "react";
import { useHits, useSearchBox } from "react-instantsearch";
import { preload } from "swr";
import useSWRImmutable from "swr/immutable";
import { MarkRequired } from "ts-essentials";
import { RemoteIcon } from "../icons/RemoteIcon";
import { HitContent } from "../shared/HitContent";
Expand All @@ -16,19 +17,10 @@ import { AlgoliaRecordHit } from "../types";
import { Button } from "../ui/button";
import { Kbd } from "../ui/kbd";
import { Tooltip, TooltipContent, TooltipProvider } from "../ui/tooltip";
import { FilterDropdownMenu } from "./FilterDropdownMenu";

const ICON_CLASS = "size-4 text-[#969696] dark:text-white/50 shrink-0 my-1";

const FACET_DISPLAY_MAP: Record<string, Record<string, ReactNode>> = {
method: {
GET: <HttpMethodTag method="GET" />,
POST: <HttpMethodTag method="POST" />,
PUT: <HttpMethodTag method="PUT" />,
PATCH: <HttpMethodTag method="PATCH" />,
DELETE: <HttpMethodTag method="DELETE" />,
},
} as const;

function toPlaceholder(filters: { facet: string; value: string }[]): string {
if (filters.length === 0) {
return "Search";
Expand All @@ -37,13 +29,6 @@ function toPlaceholder(filters: { facet: string; value: string }[]): string {
return `Search ${filters.map((filter) => FACET_DISPLAY_NAME_MAP[filter.facet]?.[filter.value] ?? filter.value).join(", ")}`;
}

function toFiltersString(filters: { facet: string; value: string }[]): string {
return filters
.map((filter) => `${filter.facet}:"${filter.value}"`)
.sort()
.join(" AND ");
}

export function DesktopCommand({
domain,
appId,
Expand All @@ -59,10 +44,10 @@ export function DesktopCommand({
onSubmit: (path: string) => void;
onAskAI?: ({ initialInput }: { initialInput?: string }) => void;
filters: {
facet: string;
facet: FacetName;
value: string;
}[];
setFilters?: Dispatch<SetStateAction<{ facet: string; value: string }[]>>;
setFilters?: Dispatch<SetStateAction<{ facet: FacetName; value: string }[]>>;
}): ReactElement {
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
Expand All @@ -71,34 +56,46 @@ export function DesktopCommand({

const { items } = useHits<AlgoliaRecord>();

const { data: facets } = useSWRImmutable([domain, toFiltersString(filters)], ([_domain, filters]) =>
getFacets({ appId, apiKey, filters }),
);
const { data: facets } = useFacets({ appId, apiKey, domain, filters });

const groups = generateHits(items);

const filterOptions = toFilterOptions(facets, query);

function bounce() {
if (ref.current) {
ref.current.style.transform = "scale(0.96)";
setTimeout(() => {
if (ref.current) {
ref.current.style.transform = "";
}
}, 100);

refine("");
inputRef.current?.focus();
}
}

return (
<Command
ref={ref}
className="flex flex-col border border-[#DBDBDB] dark:border-white/10 rounded-lg overflow-hidden bg-[#F2F2F2]/30 dark:bg-[#1A1919]/30 backdrop-blur-xl transition-transform duration-100 h-full"
className="flex flex-col border border-[#DBDBDB] dark:border-white/10 rounded-lg overflow-hidden bg-[#F2F2F2]/30 dark:bg-[#1A1919]/30 backdrop-blur-xl h-full"
shouldFilter={false}
>
{filters.length > 0 && (
<div className="flex items-center gap-2 p-4 pb-0 -mb-1">
{filters.map((filter) => (
<FilterDropdownMenu
key={`${filter.facet}:${filter.value}`}
appId={appId}
apiKey={apiKey}
domain={domain}
filter={filter}
filters={filters}
removeFilter={() => {
setFilters?.((prev) => prev.filter((f) => f.facet !== filter.facet));
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}}
updateFilter={(value) => {
setFilters?.((prev) =>
prev.map((f) => (f.facet === filter.facet ? { ...f, value } : f)),
);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}}
/>
))}
</div>
)}
<div
className="p-4 border-b last:border-b-0 border-[#DBDBDB] dark:border-white/10 flex items-center gap-2 cursor-text"
onClickCapture={() => {
Expand All @@ -119,6 +116,7 @@ export function DesktopCommand({
} else {
setFilters?.((lastFilters) => lastFilters.slice(0, -1));
}
inputRef.current?.focus();
}}
>
<ArrowLeft />
Expand Down Expand Up @@ -152,25 +150,15 @@ export function DesktopCommand({
} else {
setFilters?.((lastFilters) => lastFilters.slice(0, -1));
}
bounce();
inputRef.current?.focus();
}
}}
/>

<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="xs"
variant="outline"
// onClick={(e) => {
// if (e.metaKey) {
// setFilters?.([]);
// } else {
// setFilters?.((lastFilters) => lastFilters.slice(0, -1));
// }
// }}
>
<Button size="xs" variant="outline">
<kbd>esc</kbd>
</Button>
</TooltipTrigger>
Expand Down Expand Up @@ -214,7 +202,7 @@ export function DesktopCommand({
className="flex gap-2 cursor-default items-center"
onSelect={() => {
setFilters?.([...filters, { facet: filter.facet, value: filter.value }]);
bounce();
inputRef.current?.focus();
}}
onMouseOver={() => {
const filterString = toFiltersString([
Expand All @@ -227,12 +215,7 @@ export function DesktopCommand({
}}
>
<ListFilter className={ICON_CLASS} />
<span className="flex-1">
Search{" "}
{FACET_DISPLAY_MAP[filter.facet]?.[filter.value] ??
FACET_DISPLAY_NAME_MAP[filter.facet]?.[filter.value] ??
filter.value}
</span>
<span className="flex-1">Search {getFacetDisplay(filter.facet, filter.value)}</span>
<span className="text-xs text-[#969696] dark:text-white/50">{filter.count}</span>
</Command.Item>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FacetName } from "@/utils/facet";
import { liteClient as algoliasearch } from "algoliasearch/lite";
import "instantsearch.css/themes/reset.css";
import { useEffect, useRef, useState, type ReactElement } from "react";
Expand Down Expand Up @@ -26,8 +27,8 @@ export function DesktopInstantSearch({
filters: initialFilters,
}: DesktopInstantSearchProps): ReactElement {
const ref = useRef(algoliasearch(appId, apiKey));
const [filters, setFilters] = useState<{ facet: string; value: string }[]>(() => {
const toRet: { facet: string; value: string }[] = [];
const [filters, setFilters] = useState<{ facet: FacetName; value: string }[]>(() => {
const toRet: { facet: FacetName; value: string }[] = [];
if (initialFilters?.["product.title"]) {
toRet.push({ facet: "product.title", value: initialFilters["product.title"] });
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useFacets } from "@/hooks/useFacets";
import { FacetName, toFilterLabel } from "@/utils/facet";
import { getFacetDisplay } from "@/utils/facet-display";
import { FilterX } from "lucide-react";
import { ReactElement } from "react";
import { Badge } from "../ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "../ui/dropdown";

export function FilterDropdownMenu({
appId,
apiKey,
domain,
filter,
filters,
removeFilter,
updateFilter,
}: {
appId: string;
apiKey: string;
domain: string;
filter: {
facet: FacetName;
value: string;
};
removeFilter?: () => void;
updateFilter?: (value: string) => void;
filters: { facet: FacetName; value: string }[];
}): ReactElement {
const otherFilters = filters.filter((f) => f.facet !== filter.facet);

const { data: facets } = useFacets({ appId, apiKey, domain, filters: otherFilters });

const options = facets?.[filter.facet] ?? [];

return (
<DropdownMenu key={`${filter.facet}:${filter.value}`}>
<DropdownMenuTrigger asChild>
<Badge variant="outline" asChild>
<button>{getFacetDisplay(filter.facet, filter.value, { small: true, titleCase: true })}</button>
</Badge>
</DropdownMenuTrigger>
<DropdownMenuContent
onKeyDownCapture={(e) => {
if (e.key === "Backspace") {
removeFilter?.();
}
}}
>
<DropdownMenuLabel>{toFilterLabel(filter.facet)}</DropdownMenuLabel>
<DropdownMenuSeparator />
{options.length > 0 && (
<>
<DropdownMenuRadioGroup
value={filter.value}
onValueChange={(value) => {
updateFilter?.(value);
}}
>
{options.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{getFacetDisplay(filter.facet, option.value, { small: true, titleCase: true })}
<DropdownMenuShortcut>{option.count}</DropdownMenuShortcut>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup>
<DropdownMenuItem
onSelect={() => {
removeFilter?.();
}}
>
<FilterX className="size-4" />
Remove filter
<DropdownMenuShortcut>del</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
36 changes: 36 additions & 0 deletions packages/ui/fern-docs-search-ui/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";

import { Slot } from "@radix-ui/react-slot";
import { cn } from "./cn";

const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);

export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {
asChild?: boolean;
}

const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(({ className, variant, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return <Comp ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />;
});

Badge.displayName = "Badge";

export { Badge, badgeVariants };
Loading

0 comments on commit 910053a

Please sign in to comment.