Core & UI: Support tag filters in search dialog

This commit is contained in:
fuma-nama
2024-07-08 23:40:57 +08:00
parent 444311c0f7
commit b9fa99db81
8 changed files with 203 additions and 82 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': minor
---
Support tag filters in search dialog
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Support `tag` facet field for Algolia Search Integration
+6 -46
View File
@@ -1,25 +1,11 @@
'use client';
import algo from 'algoliasearch/lite';
import { cva } from 'class-variance-authority';
import type { SharedProps } from 'fumadocs-ui/components/dialog/search';
import SearchDialog from 'fumadocs-ui/components/dialog/search-algolia';
import { useEffect, useState } from 'react';
import { modes } from '@/utils/modes';
import { cn } from '@/utils/cn';
import { useMode } from '@/app/layout.client';
const itemVariants = cva(
'rounded-md border px-2 py-0.5 text-xs font-medium text-muted-foreground transition-colors',
{
variants: {
active: {
true: 'bg-accent text-accent-foreground',
},
},
},
);
const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID;
const apiKey = process.env.NEXT_PUBLIC_ALGOLIA_API_KEY;
const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX;
@@ -34,43 +20,17 @@ export default function CustomSearchDialog(
props: SharedProps,
): React.ReactElement {
const defaultTag = useMode() ?? 'headless';
const [tag, setTag] = useState(defaultTag);
useEffect(() => {
setTag(defaultTag);
}, [defaultTag]);
return (
<SearchDialog
index={index}
{...props}
searchOptions={{
filters: `tag:${tag}`,
}}
footer={
<div className="flex flex-row items-center gap-1">
{modes.map((mode) => (
<button
key={mode.param}
className={cn(itemVariants({ active: tag === mode.param }))}
onClick={() => {
setTag(mode.param);
}}
type="button"
tabIndex={-1}
>
{mode.name}
</button>
))}
<a
href="https://algolia.com"
rel="noreferrer noopener"
className="ms-auto text-xs text-muted-foreground"
>
Search powered by Algolia
</a>
</div>
}
defaultTag={defaultTag}
tags={modes.map((mode) => ({
name: mode.name,
value: mode.param,
}))}
showAlgolia
/>
);
}
@@ -8,6 +8,8 @@ import type { BaseIndex } from './server';
export interface Options extends SearchOptions {
/**
* Use `empty` as result if query is empty
*
* @defaultValue true
*/
allowEmpty?: boolean;
}
+32 -14
View File
@@ -1,4 +1,3 @@
import { randomUUID } from 'node:crypto';
import type { SearchClient, SearchIndex } from 'algoliasearch';
import type { StructuredData } from '@/mdx-plugins/remark-structure';
@@ -15,6 +14,11 @@ interface DocumentRecord {
url: string;
structured: StructuredData;
/**
* Tag to filter results
*/
tag?: string;
/**
* Data to be added to each section index
*/
@@ -22,14 +26,28 @@ interface DocumentRecord {
}
export interface SyncOptions {
/**
* Index Name for documents
*/
document?: string;
/**
* Search indexes
*/
documents: DocumentRecord[];
}
/**
* Update index settings and replace all objects
*
* @param client - Algolia Admin Client
* @param options - Index Options
*/
export async function sync(
client: SearchClient,
{ document = 'document', documents }: SyncOptions,
options: SyncOptions,
): Promise<void> {
const { document = 'document', documents } = options;
const index = client.initIndex(document);
await setIndexSettings(index);
await updateDocuments(index, documents);
@@ -46,7 +64,14 @@ export async function setIndexSettings(index: SearchIndex): Promise<void> {
}
interface Section {
/**
* Heading content
*/
section?: string;
/**
* The anchor id
*/
section_id?: string;
content: string;
}
@@ -88,12 +113,13 @@ export async function updateDocuments(
): Promise<void> {
const objects = documents.flatMap((page) => {
return getSections(page).map(
(section) =>
(section, idx) =>
({
objectID: `${page._id}-${randomUUID()}`,
objectID: `${page._id}-${idx.toString()}`,
title: page.title,
url: page.url,
page_id: page._id,
tag: page.tag,
...section,
...page.extra_data,
}) satisfies BaseIndex,
@@ -103,22 +129,14 @@ export async function updateDocuments(
await index.replaceAllObjects(objects);
}
export interface BaseIndex {
export interface BaseIndex extends Section {
objectID: string;
title: string;
url: string;
section?: string;
/**
* The anchor id
*/
section_id?: string;
tag?: string;
/**
* The id of page, used for distinct
*/
page_id: string;
content: string;
}
@@ -5,21 +5,49 @@ import {
type Options,
useAlgoliaSearch,
} from 'fumadocs-core/search-algolia/client';
import { type ReactNode } from 'react';
import { SearchDialog, type SharedProps } from './search';
import { type ReactNode, useState } from 'react';
import {
SearchDialog,
type SharedProps,
type TagItem,
TagsList,
} from './search';
export interface AlgoliaSearchDialogProps extends SharedProps {
index: SearchIndex;
searchOptions?: Options;
footer?: ReactNode;
defaultTag?: string;
tags?: TagItem[];
/**
* Add the "Powered by Algolia" label, this is useful for free tier users
*
* @defaultValue false
*/
showAlgolia?: boolean;
}
export default function AlgoliaSearchDialog({
index,
searchOptions,
tags,
defaultTag,
showAlgolia = false,
...props
}: AlgoliaSearchDialogProps): React.ReactElement {
const { search, setSearch, query } = useAlgoliaSearch(index, searchOptions);
const [tag, setTag] = useState<string>();
let filters = searchOptions?.filters;
if (tag) {
filters = filters ? `tag:${tag} AND (${filters})` : `tag:${tag}`;
}
const { search, setSearch, query } = useAlgoliaSearch(index, {
...searchOptions,
filters,
});
return (
<SearchDialog
@@ -27,6 +55,28 @@ export default function AlgoliaSearchDialog({
onSearchChange={setSearch}
results={query.data ?? []}
{...props}
footer={
<>
{tags ? (
<TagsList tag={tag ?? defaultTag} onTagChange={setTag} items={tags}>
{showAlgolia ? <AlgoliaTitle /> : null}
</TagsList>
) : null}
{props.footer}
</>
}
/>
);
}
function AlgoliaTitle(): React.ReactNode {
return (
<a
href="https://algolia.com"
rel="noreferrer noopener"
className="ms-auto text-xs text-muted-foreground"
>
Search powered by Algolia
</a>
);
}
@@ -1,16 +1,26 @@
'use client';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { type ReactNode } from 'react';
import { type ReactNode, useState } from 'react';
import { useI18n } from '@/contexts/i18n';
import { SearchDialog, type SharedProps } from './search';
import {
SearchDialog,
type SharedProps,
type TagItem,
TagsList,
} from './search';
export interface DefaultSearchDialogProps extends SharedProps {
/**
* Search tag
*
* @deprecated Use Tags API instead
*/
tag?: string;
defaultTag?: string;
tags?: TagItem[];
/**
* Search API URL
*/
@@ -25,12 +35,14 @@ export interface DefaultSearchDialogProps extends SharedProps {
}
export default function DefaultSearchDialog({
tag,
defaultTag,
tags,
api,
delayMs,
...props
}: DefaultSearchDialogProps): React.ReactElement {
const { locale } = useI18n();
const [tag, setTag] = useState<string>();
const { search, setSearch, query } = useDocsSearch(locale, tag, api, delayMs);
return (
@@ -39,6 +51,18 @@ export default function DefaultSearchDialog({
onSearchChange={setSearch}
results={query.data ?? []}
{...props}
footer={
<>
{tags ? (
<TagsList
tag={tag ?? defaultTag}
onTagChange={setTag}
items={tags}
/>
) : null}
{props.footer}
</>
}
/>
);
}
+73 -16
View File
@@ -1,7 +1,13 @@
import { FileTextIcon, HashIcon, TextIcon } from 'lucide-react';
import { FileText, Hash, Text } from 'lucide-react';
import type { SortedResult } from 'fumadocs-core/search/shared';
import { useRouter } from 'next/navigation';
import { useMemo, type ReactNode, useCallback } from 'react';
import {
useMemo,
useCallback,
type ReactNode,
type HTMLAttributes,
} from 'react';
import { cva } from 'class-variance-authority';
import { useI18n } from '@/contexts/i18n';
import {
CommandEmpty,
@@ -29,16 +35,16 @@ export interface SharedProps {
interface SearchDialogProps
extends SharedProps,
Omit<SearchContentProps, 'defaultItems'> {
Omit<SearchContentProps, 'items'> {
results: SortedResult[] | 'empty';
footer?: ReactNode;
}
interface SearchContentProps {
search: string;
onSearchChange: (v: string) => void;
results: SortedResult[] | 'empty';
defaultItems?: SortedResult[];
items: SortedResult[];
}
export function SearchDialog({
@@ -61,31 +67,30 @@ export function SearchDialog({
return (
<CommandDialog open={open} onOpenChange={onOpenChange} footer={footer}>
<Search defaultItems={defaultItems} {...props} />
<Search
{...props}
items={props.results === 'empty' ? defaultItems : props.results}
/>
</CommandDialog>
);
}
const icons = {
text: <TextIcon />,
heading: <HashIcon />,
page: <FileTextIcon />,
text: <Text />,
heading: <Hash />,
page: <FileText />,
};
function Search({
search,
onSearchChange,
defaultItems = [],
results,
items,
}: SearchContentProps): React.ReactElement {
const { text } = useI18n();
const router = useRouter();
const { setOpenSearch } = useSearchContext();
const sidebar = useSidebar();
const items = results === 'empty' ? defaultItems : results;
const hideList = results === 'empty' && defaultItems.length === 0;
const onOpen = (url: string): void => {
router.push(url);
setOpenSearch(false);
@@ -107,7 +112,7 @@ function Search({
}, [setOpenSearch])}
placeholder={text.search}
/>
<CommandList className={cn(hideList && 'hidden')}>
<CommandList className={cn(items.length === 0 && 'hidden')}>
<CommandEmpty>{text.searchNoResult}</CommandEmpty>
<CommandGroup value="result">
@@ -129,3 +134,55 @@ function Search({
</>
);
}
const itemVariants = cva(
'rounded-md border px-2 py-0.5 text-xs font-medium text-muted-foreground transition-colors',
{
variants: {
active: {
true: 'bg-accent text-accent-foreground',
},
},
},
);
export interface TagItem {
name: string;
value: string;
}
export interface TagsListProps extends HTMLAttributes<HTMLDivElement> {
tag?: string;
onTagChange: (tag: string) => void;
items: TagItem[];
}
export function TagsList({
tag,
onTagChange,
items,
...props
}: TagsListProps): ReactNode {
return (
<div
{...props}
className={cn('flex flex-row items-center gap-1', props.className)}
>
{items.map((item) => (
<button
type="button"
key={item.value}
className={cn(itemVariants({ active: tag === item.value }))}
onClick={() => {
onTagChange(item.value);
}}
tabIndex={-1}
>
{item.name}
</button>
))}
{props.children}
</div>
);
}