Skip to content

Commit

Permalink
merge this horribly complicated hook
Browse files Browse the repository at this point in the history
  • Loading branch information
sphinxrave committed Oct 27, 2024
1 parent 8952e39 commit 80e15b9
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 250 deletions.
256 changes: 199 additions & 57 deletions packages/react/src/components/header/searchbar/hooks/useAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,226 @@
import { useQuery } from "@tanstack/react-query";
import { atom, useAtomValue } from "jotai";
import { splitAtom } from "jotai/utils";
import { useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { splitSearchClassTerms } from "../helper";
import { JSON_SCHEMA, QueryItem } from "../types";
import { useClientAutocomplete } from "./useClientAutocomplete";
import { useServerAutocomplete } from "./useServerAutocomplete";
import { useClient } from "@/hooks/useClient";
import { useOrgs } from "@/services/orgs.service";
import { CLIPPER_LANGS } from "@/lib/consts";
import { FIRST_SEARCH, splitSearchClassTerms } from "../helper";
import { JSON_SCHEMA, QueryItem, SearchableCategory } from "../types";

/**
* The current searchbar query content.
*/
export const queryAtom = atom([] as QueryItem[]);
interface ServerAutocompleteResponse {
vtuber?: Array<{ id: string; name: string; english_name?: string }>;
topic?: Array<{ id: string }>;
}

// Constants
const CATEGORY_PRIORITY: Record<SearchableCategory, number> = {
org: 0,
vtuber: 1,
topic: 2,
type: 3,
lang: 4,
from: 5,
to: 6,
search: 7,
description: 8,
has_song: 9,
};

const STATIC_SUGGESTIONS: Record<string, QueryItem[]> = {
has_song: ["none", "non-zero", "one", "many"].map((value) => ({
type: "has_song",
value,
text: "$t",
})),
type: ["clip", "stream", "placeholder"].map((value) => ({
type: "type",
value,
text: "$t",
})),
lang: CLIPPER_LANGS.map((x) => ({ ...x, type: "lang" })),
};

// Atoms
export const queryAtom = atom<QueryItem[]>([]);
export const splitQueryAtom = splitAtom(queryAtom);

// Server Autocomplete Logic
function useServerSuggestions(
searchCategory: SearchableCategory | undefined,
searchString: string,
) {
const client = useClient();

return useQuery({
queryKey: ["autocomplete", searchCategory, searchString] as const,
queryFn: async ({
queryKey: [_, searchCat, query],
}): Promise<QueryItem[]> => {
if (
!searchCategory ||
searchCategory === "topic" ||
searchCategory === "vtuber"
) {
const response = await client.get<ServerAutocompleteResponse>(
`/api/v3/search/autocomplete`,
{
params: {
q: query,
...(searchCat && { t: searchCat, n: 10 }),
},
},
);

const vtuberItems =
response.vtuber?.map<QueryItem>((x) => ({
type: "vtuber",
value: x.id,
text: x.name, // Note: Removed langPrefs handling for simplicity
_raw: x,
})) || [];

const topicItems =
response.topic?.map<QueryItem>((x) => ({
type: "topic",
value: x.id,
text: x.id,
})) || [];

return [...vtuberItems, ...topicItems];
}
return [];
},
staleTime: 30000,
enabled: !!searchString,
});
}

// Client Autocomplete Logic
function useClientSuggestions(
searchCategory: SearchableCategory | undefined,
searchString: string,
t: TFunction<"translation", undefined>,
): QueryItem[] {
const { data: orgs } = useOrgs({ enabled: !!searchString });

return useMemo(() => {
const suggestions: QueryItem[] = [];
const searchLower = searchString.toLowerCase();

// Handle organization suggestions
if (
searchCategory === "org" ||
(searchCategory === undefined && searchString)
) {
const orgSuggestions =
orgs
?.filter(
(org) =>
!searchString ||
org.name.toLowerCase().includes(searchLower) ||
org.name_jp?.toLowerCase().includes(searchLower),
)
?.slice(0, searchCategory === "org" ? 20 : 5)
?.map((org) => ({
type: "org" as const,
value: org.name,
text: org.name,
})) || [];

suggestions.push(...orgSuggestions);
}

// Handle category-specific static suggestions that only show up when a category is specified
if (searchCategory && STATIC_SUGGESTIONS[searchCategory]) {
suggestions.push(...STATIC_SUGGESTIONS[searchCategory]);
}

// Handle general search when no category is specified
if (searchCategory === undefined) {
if (searchString) {
suggestions.push({
type: "search",
value: searchString,
text: searchString,
});
}

// Add category suggestions
const categoryAutofill = FIRST_SEARCH.filter(
(x) =>
!searchString ||
t(`search.class.${x.type}`, x.type).startsWith(searchString),
);
suggestions.push(...categoryAutofill);
}

return suggestions;
}, [orgs, searchCategory, searchString, t]);
}

// Main Hook
export function useSearchboxAutocomplete() {
const { t } = useTranslation();

const query = useAtomValue(queryAtom);
// query: (what's in the search bar) <-- this is an atom.

// search: (the content typed into the input bar)
const [search, updateSearch] = useState("");

const langCategoryReversemapClass = useMemo(() => {
const out = {} as Record<string, keyof typeof JSON_SCHEMA>;
let x: keyof typeof JSON_SCHEMA;
for (x in JSON_SCHEMA) {
out[t("search.class." + x)] = x;
// Build category reverse map for translation
const langCategoryReversemap = useMemo(() => {
const map: Record<string, SearchableCategory> = {};
for (const key in JSON_SCHEMA) {
map[t(`search.class.${key}`)] = key as SearchableCategory;
}
return out;
return map;
}, [t]);

// [lang, Eng...]
// Split search into category and search string
const [searchCategory, searchString] = useMemo(
() => splitSearchClassTerms(search, langCategoryReversemapClass),
[search, langCategoryReversemapClass],
() => splitSearchClassTerms(search, langCategoryReversemap),
[search, langCategoryReversemap],
);

// server autocomplete: (async response fetched from serverside autocomplete depending on search)
const { data: serverAC, ...autocompleteQueryState } = useServerAutocomplete(
// Get suggestions from server and client
const { data: serverSuggestions, ...serverQueryState } = useServerSuggestions(
searchCategory,
searchString,
);
const clientSuggestions = useClientSuggestions(
searchCategory,
searchString,
t,
);

// Combine and sort suggestions
const autocomplete = useMemo(() => {
const allSuggestions = [...(serverSuggestions || []), ...clientSuggestions];

const clientAC = useClientAutocomplete(searchCategory, searchString, t);

// client autocomplete: (sync response from client autocomplete depending on search and inverted_lang_index)
const autocomplete: QueryItem[] = useMemo(() => {
const out = { ...serverAC, ...clientAC };

// order them by category:
const categoryOrder: Array<keyof typeof out> = [
"org",
"vtuber",
"topic",
"type",
"lang",
"from",
"to",
"search",
"description",
"has_song",
"other",
];

const autocompleteList = categoryOrder
.flatMap((x) => out[x])
.map((x) => {
if (!x) return null;
const ok = JSON_SCHEMA[x.type].suggestionOK?.(query);
if (ok === undefined || ok == "ok") return x;
else if (ok == "replace") return { ...x, replace: true };
else if (ok === false) return null;
return allSuggestions
.sort((a, b) => {
const aIndex = CATEGORY_PRIORITY[a.type];
const bIndex = CATEGORY_PRIORITY[b.type];
return aIndex - bIndex;
})
.filter((x): x is QueryItem => !!x);
.map((suggestion) => {
const validationResult =
JSON_SCHEMA[suggestion.type].suggestionOK?.(query);

return autocompleteList;
}, [clientAC, query, serverAC]);
// autocomplete_items: (merged autocomplete options, but grouped by category, also depending on query to remove selected items from dropdown)
if (validationResult === undefined || validationResult === "ok") {
return suggestion;
} else if (validationResult === "replace") {
return { ...suggestion, replace: true };
}
return null;
})
.filter((x): x is QueryItem => x !== null);
}, [serverSuggestions, clientSuggestions, query]);

return { search, updateSearch, autocompleteQueryState, autocomplete };
return {
search,
updateSearch,
autocompleteQueryState: serverQueryState,
autocomplete,
};
}
Loading

0 comments on commit 80e15b9

Please sign in to comment.