Some experimentation with facetted search
This commit is contained in:
parent
da916f898a
commit
ccc81a122c
|
@ -91,5 +91,24 @@ export const meiliSearch = async <I extends MeiliDocumentsType["index"]>(
|
|||
})) as unknown as CustomSearchResponse<Extract<MeiliDocumentsType, { index: I }>["documents"]>;
|
||||
};
|
||||
|
||||
export type MeiliFacetResult = { name: string; count: number }[];
|
||||
|
||||
export const meiliFacet = async <I extends MeiliDocumentsType["index"]>(
|
||||
indexName: I,
|
||||
facet: string
|
||||
): Promise<MeiliFacetResult> => {
|
||||
const index = meili.index(indexName);
|
||||
const searchResult = await index.search<Extract<MeiliDocumentsType, { index: I }>["documents"]>(
|
||||
"",
|
||||
{
|
||||
hitsPerPage: 0,
|
||||
facets: [facet],
|
||||
}
|
||||
);
|
||||
return [...Object.entries(searchResult.facetDistribution?.[facet] ?? {})].map(
|
||||
([name, count]) => ({ name, count })
|
||||
);
|
||||
};
|
||||
|
||||
export const containsHighlight = (text: string | null | undefined): boolean =>
|
||||
isDefined(text) && text.includes("</mark>");
|
||||
|
|
|
@ -2,6 +2,7 @@ import { GetStaticProps } from "next";
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useBoolean } from "usehooks-ts";
|
||||
import { z } from "zod";
|
||||
import Collapsible from "react-collapsible";
|
||||
import { AppLayout, AppLayoutRequired } from "components/AppLayout";
|
||||
import { Select } from "components/Inputs/Select";
|
||||
import { Switch } from "components/Inputs/Switch";
|
||||
|
@ -20,6 +21,8 @@ import {
|
|||
containsHighlight,
|
||||
CustomSearchResponse,
|
||||
filterHitsWithHighlight,
|
||||
meiliFacet,
|
||||
MeiliFacetResult,
|
||||
meiliSearch,
|
||||
} from "helpers/search";
|
||||
import { MeiliContent, MeiliIndices } from "shared/meilisearch-graphql-typings/meiliTypes";
|
||||
|
@ -31,6 +34,7 @@ import { useFormat } from "hooks/useFormat";
|
|||
import { getFormat } from "helpers/i18n";
|
||||
import { useAtomGetter, useAtomSetter } from "helpers/atoms";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { Ico } from "components/Ico";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
|
@ -75,17 +79,6 @@ const Contents = (props: Props): JSX.Element => {
|
|||
[format]
|
||||
);
|
||||
|
||||
const languageOptions = useMemo(() => {
|
||||
const memo =
|
||||
router.locales?.map((language) => ({
|
||||
meiliAttribute: language,
|
||||
displayedName: formatLanguage(language),
|
||||
})) ?? [];
|
||||
|
||||
memo.unshift({ meiliAttribute: "", displayedName: format("all") });
|
||||
return memo;
|
||||
}, [router.locales, formatLanguage, format]);
|
||||
|
||||
const [sortingMethod, setSortingMethod] = useState<number>(
|
||||
router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod
|
||||
);
|
||||
|
@ -103,14 +96,15 @@ const Contents = (props: Props): JSX.Element => {
|
|||
router.query.lang ?? DEFAULT_FILTERS_STATE.lang
|
||||
);
|
||||
|
||||
const [selectedLocales, setSelectedLocales] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPosts = async () => {
|
||||
const currentSortingMethod = sortingMethods[sortingMethod]?.meiliAttribute;
|
||||
const currentLanguageOption = languageOptions[languageOption]?.meiliAttribute;
|
||||
|
||||
const filter: string[] = [];
|
||||
if (languageOption !== 0) {
|
||||
filter.push(`filterable_languages = ${currentLanguageOption}`);
|
||||
if (selectedLocales.length !== 0) {
|
||||
filter.push(`filterable_languages IN [${selectedLocales.join()}]`);
|
||||
}
|
||||
|
||||
const searchResult = await meiliSearch(MeiliIndices.CONTENT, query, {
|
||||
|
@ -123,14 +117,10 @@ const Contents = (props: Props): JSX.Element => {
|
|||
sort: isDefined(currentSortingMethod) ? [currentSortingMethod] : undefined,
|
||||
});
|
||||
|
||||
setContents(
|
||||
languageOption === 0
|
||||
? filterHitsWithHighlight<MeiliContent>(searchResult, "translations")
|
||||
: searchResult
|
||||
);
|
||||
setContents(filterHitsWithHighlight<MeiliContent>(searchResult, "translations"));
|
||||
};
|
||||
fetchPosts();
|
||||
}, [query, page, sortingMethod, sortingMethods, languageOption, languageOptions]);
|
||||
}, [query, page, sortingMethod, sortingMethods, selectedLocales]);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.isReady)
|
||||
|
@ -153,6 +143,14 @@ const Contents = (props: Props): JSX.Element => {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.isReady]);
|
||||
|
||||
const [countForLanguages, setCountForLanguages] = useState<MeiliFacetResult>([]);
|
||||
const [countForCategories, setCountForCategories] = useState<MeiliFacetResult>([]);
|
||||
|
||||
useEffect(() => {
|
||||
meiliFacet(MeiliIndices.CONTENT, "filterable_languages").then(setCountForLanguages);
|
||||
meiliFacet(MeiliIndices.CONTENT, "filterable_categories").then(setCountForCategories);
|
||||
}, []);
|
||||
|
||||
const searchInput = (
|
||||
<TextInput
|
||||
placeholder={format("search_placeholder")}
|
||||
|
@ -189,7 +187,22 @@ const Contents = (props: Props): JSX.Element => {
|
|||
<HorizontalLine />
|
||||
|
||||
{!is1ColumnLayout && <div className="mb-6">{searchInput}</div>}
|
||||
|
||||
|
||||
<CollapsibleFilters
|
||||
title={format("language", { count: countForLanguages.length })}
|
||||
facetResult={countForLanguages}
|
||||
format={formatLanguage}
|
||||
onValueChanged={setSelectedLocales}
|
||||
selectedValues={selectedLocales}
|
||||
/>
|
||||
|
||||
<CollapsibleFilters
|
||||
title={format("category", { count: countForCategories.length })}
|
||||
facetResult={countForCategories}
|
||||
format={formatCategory}
|
||||
onValueChanged={setSelectedLocales}
|
||||
selectedValues={selectedLocales}
|
||||
/>
|
||||
|
||||
<WithLabel label={format("order_by")}>
|
||||
<Select
|
||||
|
@ -209,24 +222,6 @@ const Contents = (props: Props): JSX.Element => {
|
|||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel label={format("language", { count: Infinity })}>
|
||||
<Select
|
||||
className="w-full"
|
||||
options={languageOptions.map((item) => item.displayedName)}
|
||||
value={languageOption}
|
||||
onChange={(newLanguageOption) => {
|
||||
setPage(1);
|
||||
setLanguageOption(newLanguageOption);
|
||||
sendAnalytics(
|
||||
"Contents/All",
|
||||
`Change language filter (${
|
||||
languageOptions.map((item) => item.meiliAttribute)[newLanguageOption]
|
||||
})`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
{hoverable && (
|
||||
<WithLabel label={format("always_show_info")}>
|
||||
<Switch
|
||||
|
@ -278,8 +273,9 @@ const Contents = (props: Props): JSX.Element => {
|
|||
}))
|
||||
.filter(
|
||||
({ language }) =>
|
||||
languageOption === 0 ||
|
||||
language === languageOptions[languageOption]?.meiliAttribute
|
||||
selectedLocales.length === 0 ||
|
||||
query !== "" ||
|
||||
selectedLocales.includes(language)
|
||||
)}
|
||||
fallback={{ title: prettySlug(item.slug) }}
|
||||
thumbnail={item.thumbnail?.data?.attributes}
|
||||
|
@ -322,3 +318,63 @@ export const getStaticProps: GetStaticProps = (context) => {
|
|||
props: props,
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface CollapsibleFiltersProps {
|
||||
title: string;
|
||||
facetResult: MeiliFacetResult;
|
||||
selectedValues: string[];
|
||||
onValueChanged: (setStateFn: (current: string[]) => string[]) => void;
|
||||
format: (name: string) => string;
|
||||
}
|
||||
|
||||
const CollapsibleFilters = ({
|
||||
title,
|
||||
facetResult,
|
||||
selectedValues,
|
||||
onValueChanged,
|
||||
format,
|
||||
}: CollapsibleFiltersProps): JSX.Element => {
|
||||
const [isOpened, setOpened] = useState(false);
|
||||
|
||||
if (facetResult.length === 0) return <></>;
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpened}
|
||||
onTriggerClosing={() => setOpened(false)}
|
||||
onOpening={() => setOpened(true)}
|
||||
trigger={
|
||||
<div className="flex gap-2">
|
||||
<p className="leading-5">{title}</p>
|
||||
<Ico icon={isOpened ? "expand_less" : "expand_more"} />
|
||||
</div>
|
||||
}
|
||||
easing="ease-in-out"
|
||||
transitionTime={400}
|
||||
contentInnerClassName="flex flex-wrap gap-1 py-3"
|
||||
overflowWhenOpen="visible">
|
||||
{facetResult
|
||||
.filter(({ count }) => count > 0)
|
||||
.map(({ name, count }) => (
|
||||
<Button
|
||||
key={name}
|
||||
text={`${format(name)} (${count})`}
|
||||
size="small"
|
||||
onClick={() =>
|
||||
onValueChanged((current) => {
|
||||
if (current.includes(name)) {
|
||||
return current.filter((currentLocale) => currentLocale !== name);
|
||||
}
|
||||
return [...current, name];
|
||||
})
|
||||
}
|
||||
active={selectedValues.includes(name)}
|
||||
/>
|
||||
))}
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -34,6 +34,7 @@ export interface MeiliContent
|
|||
})[];
|
||||
sortable_updated_date: number;
|
||||
filterable_languages: string[];
|
||||
filterable_categories: string[];
|
||||
}
|
||||
|
||||
export interface MeiliVideo extends VideoAttributesFragment {
|
||||
|
|
Loading…
Reference in New Issue