Some experimentation with facetted search

This commit is contained in:
DrMint 2023-08-17 12:53:05 +02:00
parent da916f898a
commit ccc81a122c
3 changed files with 117 additions and 41 deletions

View File

@ -91,5 +91,24 @@ export const meiliSearch = async <I extends MeiliDocumentsType["index"]>(
})) as unknown as CustomSearchResponse<Extract<MeiliDocumentsType, { index: I }>["documents"]>; })) 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 => export const containsHighlight = (text: string | null | undefined): boolean =>
isDefined(text) && text.includes("</mark>"); isDefined(text) && text.includes("</mark>");

View File

@ -2,6 +2,7 @@ import { GetStaticProps } from "next";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import { z } from "zod"; import { z } from "zod";
import Collapsible from "react-collapsible";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { Select } from "components/Inputs/Select"; import { Select } from "components/Inputs/Select";
import { Switch } from "components/Inputs/Switch"; import { Switch } from "components/Inputs/Switch";
@ -20,6 +21,8 @@ import {
containsHighlight, containsHighlight,
CustomSearchResponse, CustomSearchResponse,
filterHitsWithHighlight, filterHitsWithHighlight,
meiliFacet,
MeiliFacetResult,
meiliSearch, meiliSearch,
} from "helpers/search"; } from "helpers/search";
import { MeiliContent, MeiliIndices } from "shared/meilisearch-graphql-typings/meiliTypes"; import { MeiliContent, MeiliIndices } from "shared/meilisearch-graphql-typings/meiliTypes";
@ -31,6 +34,7 @@ import { useFormat } from "hooks/useFormat";
import { getFormat } from "helpers/i18n"; import { getFormat } from "helpers/i18n";
import { useAtomGetter, useAtomSetter } from "helpers/atoms"; import { useAtomGetter, useAtomSetter } from "helpers/atoms";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { Ico } from "components/Ico";
/* /*
* *
@ -75,17 +79,6 @@ const Contents = (props: Props): JSX.Element => {
[format] [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>( const [sortingMethod, setSortingMethod] = useState<number>(
router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod
); );
@ -103,14 +96,15 @@ const Contents = (props: Props): JSX.Element => {
router.query.lang ?? DEFAULT_FILTERS_STATE.lang router.query.lang ?? DEFAULT_FILTERS_STATE.lang
); );
const [selectedLocales, setSelectedLocales] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
const fetchPosts = async () => { const fetchPosts = async () => {
const currentSortingMethod = sortingMethods[sortingMethod]?.meiliAttribute; const currentSortingMethod = sortingMethods[sortingMethod]?.meiliAttribute;
const currentLanguageOption = languageOptions[languageOption]?.meiliAttribute;
const filter: string[] = []; const filter: string[] = [];
if (languageOption !== 0) { if (selectedLocales.length !== 0) {
filter.push(`filterable_languages = ${currentLanguageOption}`); filter.push(`filterable_languages IN [${selectedLocales.join()}]`);
} }
const searchResult = await meiliSearch(MeiliIndices.CONTENT, query, { const searchResult = await meiliSearch(MeiliIndices.CONTENT, query, {
@ -123,14 +117,10 @@ const Contents = (props: Props): JSX.Element => {
sort: isDefined(currentSortingMethod) ? [currentSortingMethod] : undefined, sort: isDefined(currentSortingMethod) ? [currentSortingMethod] : undefined,
}); });
setContents( setContents(filterHitsWithHighlight<MeiliContent>(searchResult, "translations"));
languageOption === 0
? filterHitsWithHighlight<MeiliContent>(searchResult, "translations")
: searchResult
);
}; };
fetchPosts(); fetchPosts();
}, [query, page, sortingMethod, sortingMethods, languageOption, languageOptions]); }, [query, page, sortingMethod, sortingMethods, selectedLocales]);
useEffect(() => { useEffect(() => {
if (router.isReady) if (router.isReady)
@ -153,6 +143,14 @@ const Contents = (props: Props): JSX.Element => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady]); }, [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 = ( const searchInput = (
<TextInput <TextInput
placeholder={format("search_placeholder")} placeholder={format("search_placeholder")}
@ -190,6 +188,21 @@ const Contents = (props: Props): JSX.Element => {
{!is1ColumnLayout && <div className="mb-6">{searchInput}</div>} {!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")}> <WithLabel label={format("order_by")}>
<Select <Select
@ -209,24 +222,6 @@ const Contents = (props: Props): JSX.Element => {
/> />
</WithLabel> </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 && ( {hoverable && (
<WithLabel label={format("always_show_info")}> <WithLabel label={format("always_show_info")}>
<Switch <Switch
@ -278,8 +273,9 @@ const Contents = (props: Props): JSX.Element => {
})) }))
.filter( .filter(
({ language }) => ({ language }) =>
languageOption === 0 || selectedLocales.length === 0 ||
language === languageOptions[languageOption]?.meiliAttribute query !== "" ||
selectedLocales.includes(language)
)} )}
fallback={{ title: prettySlug(item.slug) }} fallback={{ title: prettySlug(item.slug) }}
thumbnail={item.thumbnail?.data?.attributes} thumbnail={item.thumbnail?.data?.attributes}
@ -322,3 +318,63 @@ export const getStaticProps: GetStaticProps = (context) => {
props: props, 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>
);
};

View File

@ -34,6 +34,7 @@ export interface MeiliContent
})[]; })[];
sortable_updated_date: number; sortable_updated_date: number;
filterable_languages: string[]; filterable_languages: string[];
filterable_categories: string[];
} }
export interface MeiliVideo extends VideoAttributesFragment { export interface MeiliVideo extends VideoAttributesFragment {