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"]>;
|
})) 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>");
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue