diff --git a/app/search/page.tsx b/app/search/page.tsx index c50852a4..61d08d3d 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -14,7 +14,7 @@ const Page = async ({ searchParams: { q } }: { searchParams: { q: string } }) => return ( Loading...

}> - +
) diff --git a/components/pages/searchPageLayout/SearchPageLayout.tsx b/components/pages/searchPageLayout/SearchPageLayout.tsx index 9e8c3629..dd4dc70d 100644 --- a/components/pages/searchPageLayout/SearchPageLayout.tsx +++ b/components/pages/searchPageLayout/SearchPageLayout.tsx @@ -1,137 +1,53 @@ "use client" -import { useInfiniteQuery } from "@tanstack/react-query" -import { useInView } from "framer-motion" -import { motion } from "framer-motion" -import { useSearchParams } from "next/navigation" -import { useEffect, useRef, useState } from "react" +import { motion, useInView } from "framer-motion" +import { useEffect, useRef } from "react" import SearchFiltersMobile from "@/components/shared/searchFilters/SearchFiltersMobile" -import { getFacetMachineNames } from "@/components/shared/searchFilters/helper" -import goConfig from "@/lib/config/config" -import { - FacetValue, - SearchFiltersInput, - useSearchFacetsQuery, - useSearchWithPaginationQuery, -} from "@/lib/graphql/generated/fbi/graphql" +import useSearchMachineActor from "@/lib/machines/search/useSearchMachineActor" import SearchFiltersDesktop, { SearchFiltersDesktopGhost, } from "../../shared/searchFilters/SearchFiltersDesktop" import SearchResults, { SearchResultsGhost } from "./SearchResults" -import { getFacetsForSearchRequest, getNextPageParamsFunc, getSearchQueryArguments } from "./helper" +import { useSearchDataAndLoadingStates } from "./helper" -const SEARCH_RESULTS_LIMIT = goConfig("search.item.limit") - -export type FilterItemTerm = Omit - -const SearchPageLayout = ({ searchQuery }: { searchQuery?: string }) => { - const searchParams = useSearchParams() - const q = searchQuery || searchParams.get("q") || "" - const [currentQueryString, setCurrentQueryString] = useState("") - const [currentPage, setCurrentPage] = useState(0) - const [facetFilters, setFacetFilters] = useState({}) +const SearchPageLayout = () => { const loadMoreRef = useRef(null) const isInView = useInView(loadMoreRef) - const facets = getFacetMachineNames() - - const facetsForSearchRequest = getFacetsForSearchRequest(searchParams) - const searchQueryArguments = getSearchQueryArguments({ - q: currentQueryString, - currentPage, - facetFilters, - }) - - const { - data, - fetchNextPage, - isLoading: isLoadingResults, - isFetchingNextPage: isFetchingMoreResults, - isFetching: isFetchingResults, - isPending: isPendingResults, - } = useInfiniteQuery({ - queryKey: useSearchWithPaginationQuery.getKey({ - ...searchQueryArguments, - offset: goConfig("search.offset.initial"), - }), - queryFn: useSearchWithPaginationQuery.fetcher(searchQueryArguments), - getNextPageParam: getNextPageParamsFunc(currentPage), - initialPageParam: goConfig("search.param.initial"), - refetchOnWindowFocus: false, - enabled: currentQueryString?.length > 0, // Disable search result & search filter queries if q doesn't exist - }) - - const { data: dataFacets, isLoading: isLoadingFacets } = useSearchFacetsQuery( - { - q: searchQueryArguments.q, - facetLimit: goConfig("search.facet.limit"), - facets, - filters: searchQueryArguments.filters, - }, - { - refetchOnWindowFocus: false, - enabled: currentQueryString?.length > 0, - } - ) - - const handleLoadMore = () => { - const totalPages = Math.ceil((data?.pages?.[0]?.search.hitcount ?? 0) / SEARCH_RESULTS_LIMIT) - - if (currentPage < totalPages) { - fetchNextPage() - setCurrentPage(currentPage + 1) - } - } + const actor = useSearchMachineActor() + const { data, isLoadingFacets, isLoadingResults, machineIsReady, searchQuery } = + useSearchDataAndLoadingStates() useEffect(() => { if (isInView) { - handleLoadMore() + actor.send({ type: "LOAD_MORE" }) } // We choose to ignore the eslint warning below // because we do not want to add the handleMore callback which changes on every render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isInView]) - // TODO: consider finding a better way to control fetching of data without using the useEffects below - useEffect(() => { - const page = data?.pages.length || 0 - setCurrentPage(page) - }, [data?.pages]) - - useEffect(() => { - setCurrentPage(0) - setCurrentQueryString(q) - }, [q]) - - useEffect(() => { - // Check if the filters in URL have changed - const isFilterMatching = JSON.stringify(facetFilters) === JSON.stringify(facetsForSearchRequest) - if (!isFilterMatching) { - setFacetFilters(facetsForSearchRequest) - setCurrentPage(0) - } - }, [facetFilters, facetsForSearchRequest]) - - const facetData = dataFacets?.search?.facets - const hitcount = data?.pages?.[0]?.search.hitcount ?? 0 - const isLoading = - isLoadingResults || isFetchingMoreResults || isFetchingResults || isPendingResults + const isNoSearchResult = !isLoadingResults && (!data.search || !data.search.pages[0].length) + const hitCountText = data.search?.hitcount ? `(${data.search.hitcount})` : "" + const searchQueryText = searchQuery ? `"${searchQuery}"` : "" return (
-

- {`Viser resultater for "${q}" ${hitcount ? "(" + hitcount + ")" : ""}`} -

- {q ? ( + {searchQuery && ( +

+ {`Viser resultater for ${searchQueryText} ${hitCountText}`} +

+ )} + {searchQuery ? ( <> - {!isLoadingFacets && facetData && facetData?.length > 0 ? ( + {!isLoadingFacets && data.facets && data.facets.length > 0 ? (
- +
- +
) : ( @@ -144,28 +60,34 @@ const SearchPageLayout = ({ searchQuery }: { searchQuery?: string }) => { )}
- {data?.pages.map( - (page, i) => - page.search.works && ( - - - - ) - )} - {isLoading && } + {isNoSearchResult &&

Ingen søgeresultat

} + {data.search && + data.search.pages.map( + (works, i) => + works && ( + + + + ) + )} + {isLoadingResults && }
-
) : ( -
-

Ingen søgeord fundet

-
+ <> + {machineIsReady && ( +
+

Ingen søgeord fundet

+
+ )} + )} +
) } diff --git a/components/pages/searchPageLayout/SearchResults.tsx b/components/pages/searchPageLayout/SearchResults.tsx index 08b23d0f..15ed1b16 100644 --- a/components/pages/searchPageLayout/SearchResults.tsx +++ b/components/pages/searchPageLayout/SearchResults.tsx @@ -3,10 +3,10 @@ import React from "react" import WorkCard, { WorkCardGhost } from "@/components/shared/workCard/WorkCard" -import { SearchWithPaginationQuery } from "@/lib/graphql/generated/fbi/graphql" +import { WorkTeaserFragment } from "@/lib/graphql/generated/fbi/graphql" type SearchResultProps = { - works: SearchWithPaginationQuery["search"]["works"] + works: WorkTeaserFragment[] } const SearchResults = ({ works }: SearchResultProps) => { diff --git a/components/pages/searchPageLayout/helper.ts b/components/pages/searchPageLayout/helper.ts index ad042ae9..ca9a4937 100644 --- a/components/pages/searchPageLayout/helper.ts +++ b/components/pages/searchPageLayout/helper.ts @@ -1,10 +1,13 @@ import { GetNextPageParamFunction } from "@tanstack/react-query" +import { useSelector } from "@xstate/react" import { ReadonlyURLSearchParams } from "next/navigation" import { getFacetMachineNames } from "@/components/shared/searchFilters/helper" import goConfig from "@/lib/config/config" import { TConfigSearchFacets } from "@/lib/config/resolvers/search" import { SearchFiltersInput, SearchWithPaginationQuery } from "@/lib/graphql/generated/fbi/graphql" +import { TFilters } from "@/lib/machines/search/types" +import useSearchMachineActor from "@/lib/machines/search/useSearchMachineActor" export const getSearchQueryArguments = ({ q, @@ -32,7 +35,7 @@ export const getFacetsForSearchRequest = (searchParams: ReadonlyURLSearchParams) const facetsMachineNames = getFacetMachineNames() return facetsMachineNames.reduce( - (acc: SearchFiltersInput, machineName) => { + (acc: TFilters, machineName) => { const values = searchParams.getAll(facets[machineName].filter) if (values.length > 0) { return { @@ -42,7 +45,7 @@ export const getFacetsForSearchRequest = (searchParams: ReadonlyURLSearchParams) } return acc }, - {} as { [key: string]: keyof SearchFiltersInput[] } + {} as { [key: string]: keyof TFilters[] } ) } @@ -57,3 +60,30 @@ export const getNextPageParamsFunc = ( return currentPage < totalPages ? nextPage : undefined // By returning undefined if there are no more pages, hasNextPage boolean will be set to false } } + +export const useSearchDataAndLoadingStates = () => { + const actor = useSearchMachineActor() + const searchQuery = useSelector(actor, snapshot => { + return snapshot.context.submittedQuery + }) + const data = useSelector(actor, snapshot => { + const { facetData: facets, searchData: search } = snapshot.context + return { facets, search } + }) + const isLoadingFacets = + !data.facets || actor.getSnapshot().matches({ filteringAndSearching: "filter" }) + const isLoadingResults = + !data.search || actor.getSnapshot().matches({ filteringAndSearching: "search" }) + const machineIsReady = !actor.getSnapshot().matches("bootstrap") + + const selectedFilters = useSelector(actor, snapshot => snapshot.context.selectedFilters) + + return { + searchQuery, + data, + selectedFilters, + isLoadingFacets, + isLoadingResults, + machineIsReady, + } +} diff --git a/components/shared/searchFilters/SearchFiltersColumn.tsx b/components/shared/searchFilters/SearchFiltersColumn.tsx index 325f5eeb..0f152174 100644 --- a/components/shared/searchFilters/SearchFiltersColumn.tsx +++ b/components/shared/searchFilters/SearchFiltersColumn.tsx @@ -1,15 +1,17 @@ -import { useRouter, useSearchParams } from "next/navigation" import React, { useEffect, useRef, useState } from "react" +import { useSearchDataAndLoadingStates } from "@/components/pages/searchPageLayout/helper" import BadgeButton from "@/components/shared/badge/BadgeButton" import Icon from "@/components/shared/icon/Icon" import { + facetTermIsSelected, getFacetTranslation, sortByActiveFacets, - toggleFilter, } from "@/components/shared/searchFilters/helper" -import { SearchFacetFragment, SearchFiltersInput } from "@/lib/graphql/generated/fbi/graphql" +import { SearchFacetFragment } from "@/lib/graphql/generated/fbi/graphql" import { cn } from "@/lib/helpers/helper.cn" +import { TFilters } from "@/lib/machines/search/types" +import useSearchMachineActor from "@/lib/machines/search/useSearchMachineActor" import { AnimateChangeInHeight } from "../animateChangeInHeight/AnimateChangeInHeight" @@ -26,12 +28,11 @@ const SearchFiltersColumn = ({ isExpanded, setIsExpanded, }: SearchFiltersColumnProps) => { - const router = useRouter() - const facetFilter = facet.name as keyof SearchFiltersInput - - const searchParams = useSearchParams() + const actor = useSearchMachineActor() + const facetFilter = facet.name as keyof TFilters const elementRef = useRef(null) const [hasOverflow, setHasOverflow] = useState(false) + const { selectedFilters } = useSearchDataAndLoadingStates() useEffect(() => { const el = elementRef.current @@ -44,7 +45,9 @@ const SearchFiltersColumn = ({ }, [elementRef]) // We show the selected values first in the list - facet.values = sortByActiveFacets(facet, searchParams) + if (selectedFilters) { + facet.values = sortByActiveFacets(facet, selectedFilters) + } return ( <> @@ -70,8 +73,14 @@ const SearchFiltersColumn = ({ {facet.values.map((value, index) => ( toggleFilter(facet.name, value.term, router)} - isActive={!!searchParams.getAll(facet.name).includes(value.term)}> + onClick={() => + actor.send({ type: "TOGGLE_FILTER", name: facet.name, value: value.term }) + } + isActive={facetTermIsSelected({ + facet: facet.name, + term: value.term, + filters: selectedFilters, + })}> {value.term} ))} diff --git a/components/shared/searchFilters/SearchFiltersMobile.tsx b/components/shared/searchFilters/SearchFiltersMobile.tsx index b82fdfb3..e6fd7487 100644 --- a/components/shared/searchFilters/SearchFiltersMobile.tsx +++ b/components/shared/searchFilters/SearchFiltersMobile.tsx @@ -22,7 +22,8 @@ import { SheetTitle, SheetTrigger, } from "@/components/shared/sheet/Sheet" -import { SearchFacetFragment, SearchFiltersInput } from "@/lib/graphql/generated/fbi/graphql" +import { SearchFacetFragment } from "@/lib/graphql/generated/fbi/graphql" +import { TFilters } from "@/lib/machines/search/types" import { Button } from "../button/Button" @@ -79,7 +80,7 @@ const SearchFiltersMobile = ({ facets }: SearchFiltersMobileProps) => {
facet.name)}> {facets.map(facet => { - const facetName = facet.name as keyof SearchFiltersInput + const facetName = facet.name as keyof TFilters return ( {getFacetTranslation(facetName)} diff --git a/components/shared/searchFilters/helper.ts b/components/shared/searchFilters/helper.ts index 86c78e8e..d84b8a9a 100644 --- a/components/shared/searchFilters/helper.ts +++ b/components/shared/searchFilters/helper.ts @@ -4,11 +4,8 @@ import { ReadonlyURLSearchParams } from "next/navigation" import goConfig from "@/lib/config/config" import { TConfigSearchFacets } from "@/lib/config/resolvers/search" -import { - FacetFieldEnum, - SearchFacetFragment, - SearchFiltersInput, -} from "@/lib/graphql/generated/fbi/graphql" +import { FacetFieldEnum, SearchFacetFragment } from "@/lib/graphql/generated/fbi/graphql" +import { TContext, TFilters } from "@/lib/machines/search/types" export const toggleFilter = (filterName: string, value: string, router: AppRouterInstance) => { const searchParams = new URLSearchParams(window.location.search) @@ -32,13 +29,14 @@ export const toggleFilter = (filterName: string, value: string, router: AppRoute router.push("/search" + searchParamsString ? `?${searchParamsString}` : "", { scroll: false }) } -export const sortByActiveFacets = ( - facet: SearchFacetFragment, - searchParams: ReadonlyURLSearchParams -) => { +export const sortByActiveFacets = (facet: SearchFacetFragment, selectedFilters: TFilters) => { return [...facet.values].sort((a, b) => { - const aIncluded = searchParams.getAll(facet.name).includes(a.term) - const bIncluded = searchParams.getAll(facet.name).includes(b.term) + const facetName = facet.name as keyof TFilters + if (!selectedFilters[facetName]) { + return 0 + } + const aIncluded = selectedFilters[facetName].includes(a.term) + const bIncluded = selectedFilters[facetName].includes(b.term) if (aIncluded && !bIncluded) return -1 if (!aIncluded && bIncluded) return 1 return 0 @@ -50,7 +48,7 @@ export const getFacetMachineNames = () => { return Object.keys(facets) as FacetFieldEnum[] } -export const getFacetTranslation = (facetFilter: keyof SearchFiltersInput) => { +export const getFacetTranslation = (facetFilter: keyof TFilters) => { const facets = goConfig("search.facets") return facets[facetFilter.toUpperCase() as keyof TConfigSearchFacets].translation || "" @@ -76,3 +74,13 @@ export const shouldShowActiveFilters = ( ) => { return flatten(getActiveFilters(facets, searchParams).map(filter => filter.values)).length > 0 } + +export const facetTermIsSelected = ({ + filters, + facet, + term, +}: { + filters: TContext["selectedFilters"] + facet: string + term: string +}) => Boolean(filters[facet as keyof TContext["selectedFilters"]]?.includes(term)) diff --git a/components/shared/searchInput/SearchInput.tsx b/components/shared/searchInput/SearchInput.tsx index cfeb5268..e010a3cc 100644 --- a/components/shared/searchInput/SearchInput.tsx +++ b/components/shared/searchInput/SearchInput.tsx @@ -1,10 +1,12 @@ "use client" -import { useRouter, useSearchParams } from "next/navigation" +import { useSelector } from "@xstate/react" +import { useRouter } from "next/navigation" import React from "react" import { useEffect, useRef } from "react" import { cn } from "@/lib/helpers/helper.cn" +import useSearchMachineActor from "@/lib/machines/search/useSearchMachineActor" import Icon from "../icon/Icon" @@ -16,36 +18,34 @@ type SearchInputProps = { const SearchInput = ({ className, placeholder }: SearchInputProps) => { const inputRef = useRef(null) const router = useRouter() - const searchParams = useSearchParams() - const [queryString, setQueryString] = React.useState("") + const actor = useSearchMachineActor() + const currentQuery = useSelector(actor, snapshot => { + return snapshot.context.currentQuery + }) useEffect(() => { - window.addEventListener("keydown", handleKeydown) + window.addEventListener("keydown", handleKeydown(currentQuery)) return () => { - window.removeEventListener("keydown", handleKeydown) + window.removeEventListener("keydown", handleKeydown(currentQuery)) } // We choose to ignore the eslint warning below // because we do not want to add the handleKeydown callback which changes on every render. // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [currentQuery]) - useEffect(() => { - setQueryString(searchParams.get("q") || "") - }, [searchParams]) - - const handleKeydown = (event: KeyboardEvent) => { - if (!inputRef.current) return + const handleKeydown = (q: string) => (event: KeyboardEvent) => { + if (!q) return const focusedElement = document.activeElement as HTMLElement if (event.key === "Enter" && focusedElement === inputRef.current) { - navigateToSearch() + navigateToSearch(q)() } } - const navigateToSearch = () => { - if (!inputRef.current) return - const inputValue = inputRef.current.value - router.push(inputValue ? `/search?q=${inputValue}` : "/search", { + const navigateToSearch = (q: string) => () => { + if (!q) return + actor.send({ type: "SEARCH" }) + router.push(currentQuery ? `/search?q=${currentQuery}` : "/search", { scroll: false, }) } @@ -61,13 +61,13 @@ const SearchInput = ({ className, placeholder }: SearchInputProps) => { disabled:opacity-50 lg:h-20`, className )} - value={queryString} - onChange={e => setQueryString(e.target.value)} + value={currentQuery} + onChange={({ target: { value } }) => actor.send({ type: "TYPING", q: value })} placeholder={placeholder} /> diff --git a/lib/machines/search/queries.ts b/lib/machines/search/queries.ts new file mode 100644 index 00000000..ab608def --- /dev/null +++ b/lib/machines/search/queries.ts @@ -0,0 +1,52 @@ +import { QueryClient } from "@tanstack/react-query" +import { fromPromise } from "xstate" + +import { getFacetMachineNames } from "@/components/shared/searchFilters/helper" +import { + SearchFacetsQuery, + SearchWithPaginationQuery, + useSearchFacetsQuery, + useSearchWithPaginationQuery, +} from "@/lib/graphql/generated/fbi/graphql" + +import { TFilters } from "./types" + +export const performSearch = fromPromise( + ({ + input: { q, filters, offset, limit, queryClient }, + }: { + input: { q: string; offset: number; limit: number; filters: TFilters; queryClient: QueryClient } + }): Promise => { + const args = { + q: { all: q }, + offset: offset, + limit, + filters, + } + + return queryClient.fetchQuery({ + queryKey: useSearchWithPaginationQuery.getKey(args), + queryFn: useSearchWithPaginationQuery.fetcher(args), + }) + } +) + +export const getFacets = fromPromise( + ({ + input: { q, queryClient, filters, facetLimit }, + }: { + input: { q: string; facetLimit: number; filters: TFilters; queryClient: QueryClient } + }): Promise => { + const args = { + q: { all: q }, + facets: getFacetMachineNames(), + facetLimit, + filters, + } + + return queryClient.fetchQuery({ + queryKey: useSearchFacetsQuery.getKey(args), + queryFn: useSearchFacetsQuery.fetcher(args), + }) + } +) diff --git a/lib/machines/search/search.machine.setup.ts b/lib/machines/search/search.machine.setup.ts new file mode 100644 index 00000000..a67cdd8a --- /dev/null +++ b/lib/machines/search/search.machine.setup.ts @@ -0,0 +1,117 @@ +import { assign, emit, setup } from "xstate" + +import { getFacets, performSearch } from "./queries" +import { TContext, TFilters, TInput } from "./types" + +export default setup({ + types: { + context: {} as TContext, + input: {} as TInput, + }, + actions: { + toggleFilterInContext: assign({ + selectedFilters: ({ event: { name, value }, context: { selectedFilters } }) => { + const filterName = name as keyof TFilters + if (!selectedFilters) { + selectedFilters = {} + } + + if (!selectedFilters.hasOwnProperty(filterName)) { + selectedFilters[filterName] = [] + } + // Remove filter. + if (selectedFilters[filterName] && selectedFilters[filterName].includes(value)) { + return { + ...selectedFilters, + [name]: selectedFilters[filterName].filter(filterValue => filterValue !== value), + } + } + // Add filter. + return { + ...selectedFilters, + [name]: [...(selectedFilters[filterName] ?? []), value], + } + }, + }), + emitFilterToggled: emit(({ event }) => ({ + type: "filterToggled", + toggled: event, + })), + emitQDeleted: emit(() => ({ + type: "qDeleted", + })), + setCurrentQueryInContext: assign({ + currentQuery: ({ event }) => event.q, + }), + setSbmittedQueryInContext: assign({ + submittedQuery: ({ context }) => (context.submittedQuery = context.currentQuery), + }), + resetFilters: assign(() => ({ + selectedFilters: {}, + })), + resetQuery: assign(() => ({ + currentQuery: undefined, + submittedQuery: undefined, + })), + resetSearchData: assign(() => ({ + searchData: undefined, + })), + setFacetDataInContext: assign({ + facetData: ({ + event: { + output: { + search: { facets }, + }, + }, + }) => facets, + }), + setSearchDataInContext: assign({ + searchData: ({ + event: { + output: { search }, + }, + context: { searchData }, + }) => { + return { + hitcount: search.hitcount, + pages: [...(searchData?.pages ?? []), [...search.works]], + } + }, + }), + setQueryClientInContext: assign({ + queryClient: ({ event }) => event.queryClient, + }), + setInitialFiltersInContext: assign({ + selectedFilters: ({ event }) => event.filters, + }), + setLoadMoreValuesInContext: assign({ + searchOffset: ({ context: { searchOffset, searchPageSize, searchData } }) => { + if (!searchData) { + return searchOffset + } + return searchOffset + searchPageSize + }, + }), + }, + actors: { + performSearch, + getFacets, + }, + guards: { + eventHasSearchString: ({ event }) => { + return Boolean(event.q && event.q.length > 0) + }, + contextHasSearchString: ({ context }) => { + return Boolean(context.currentQuery && context.currentQuery.length > 0) + }, + contextHasQueryClient: ({ context }) => { + return Boolean(context.queryClient) + }, + maxLimitReached: ({ context: { searchPageSize, searchData } }) => { + if (!searchData) { + return false + } + return searchData.pages.length >= Math.ceil(searchData.hitcount / searchPageSize) + }, + }, +}) diff --git a/lib/machines/search/search.machine.ts b/lib/machines/search/search.machine.ts new file mode 100644 index 00000000..dc153204 --- /dev/null +++ b/lib/machines/search/search.machine.ts @@ -0,0 +1,180 @@ +import { and, not } from "xstate" + +import searchMachineSetup from "./search.machine.setup" + +export default searchMachineSetup.createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5SzAQwE4GMAWA6ARgPaEAusJ6qADgMQDKAogCoD6AigKoMBKAmiwGEAMgEkGAOSYBtAAwBdRKCqFYASxKrCAO0UgAHogC0ANgCMAVlwAWAJzGATDIAcNmeYDM9zwBoQAT0QLAHZcYycnIKdTSNcndysAXwTfFAwcAmIyCmp6ZhZGAEFuAQAJfKZuEXEAcVkFJBBlNQ1tXQMEQytjS0cbKyCg0xdwpytzXwCEd2jcGxt3d1Gne273AaSUtCw8IlJySlpGViqRJhECoRYAMREhJh46Ot0m9U0dBvaTR1Cw0ysFpzmLxuCaIKxDXCmdw2cwyFamYyLKIbECpbYZPbZWgAIQA8rimHQKgUAAosAAiuPEDCeDReLXeoE+5iiuCC0Js9isK36phhoIQphkNlC4Ui0RczniphRaPSuyyBxoeIJRO4pIpVJppnqShUr1aHyMLNMbI5XJ5g35-kQZlFESiMSl4NlW3SqggABswDQmLjqtUhAxrrd7txaXrmm82mD7CL2V4FjZwt0rFYBfZ7E5cB5hXzPOYhdN3K60ngPd7ff7A8GbnceFIdc99QyYwh7EERV4+stnEEgd0BYXLO5jEEuUEwp2zMZS+iKz6mLwSVVavJm1HDUzjdzZn84-NoYWxwLO1ZIcCZHCx+ZwbPkqi3eWvYvl6vG7rGi3o0bBUEZKEfwAu4t6eAi7gClC56jOYnaDCBQRpqOc7ui+uRFKUEZfpujL6IgizuDmrgODInj2KYfJxAKiIisefQyIMTg0TKD5ys+laFMUJQfhuBq4e0awhPuNgIrBhYxMYAqxLgEQwuyLieCBLGbGWuALjQQi4gU5IsAAsri3A0uudLflueGCiJuBxrY8SZlYV6FvYkF8iKTErOyQxeHBKF4AAZqonokGA6CqFoUAFFoEB0E+oVQDQEDaGAuDkKgQXJU+uD+YFwWxRFUUxWFWH0j+24dHyAHmJVslMW4fwyOmNoILeuBuOOYyAlyDhdD5mUBUFIVhXl0VlrF6WqWxsXxYlalaAAboQADWSVsb12UDeFkXDdso0rRNYUIKF82YKlbx1EVpn8YEbjGLMdjCms4yNVy2acrBUT2OYPVZf1uWbQVUBjeie1xcF6CEOguBUJ6qW+eDAC2gPpN9OWDX9I1hYjeDAwdc2EMdDJncZkZ8W2owhIx7L9OC5HCpBfSzHC8QPV9fUoxt+XowDyMQ9zk0JVoSWHYty0ZbzqMc9tGPc6tP37UL+OnfI504W2QorNYH3ih9Q7-jJxEfSza2-RLOCjdLYsg+gYMQ1DMPw5jMts0N-2OzzrPrTjR0ndohOfsVZntGT1h1Q4j2TMeMljkM-Y9Z6hCoBAsW6eDYBbTg3BwAAroFsCY3z01C0tDtxwnScp2n2AZ7A2dkHncu4wrPtK0T2Ek7+hh8jdXT1cY-TmIe5FOFJ3wfbeLlhB9Yyx-Hidhcn6Cp0+Vc17nwM0KD4OQ9DJCw+gCMrSXs9QPPi9lsvOd11Ant497Wi+7xrbt6YWYXu9DEdh4aZh4EiZWV05F-DiH8CwSQHxaEIBAOAug2IPxKuZQwgJCIRA7KOScvJrSTA7lyM0sEiwyDCB4FwPUFT7GoLAgORgnBuBkuOdkY5e5Wm-h0NYVkEL1W5OYUOMJEisQyguchl0yr2BkoCYwM4xhjjiGEai+DcBrDiMOVw9k+iG1luzCusUBFtnZCEYEXQIhML5AzRMlF+xpgoveFS6ILbO05pjLRv5IiWA8MOUOkEGLWEZvIqc443CqKdmjSWANdr-QcaVOIhEXEWDcU9fonivD-Bjrw1SNjAmmwxitfmYAwnmWfpEM0QDog3nFBmYwIpugJOZsk6x7tjYaKlu7HJ7QnGsJ7gYgU3IRSoISbCOM+DlKPhSbU8W9Subu1dpokyKtfwRNafo-sQ4ylyKjgY-x61bFBNdrgLJTSf72RzMMRC7k3qmCHJZMwFNPrVPSIfMuC8K7nzILsjotgRRQn7gsTM-Z4hjGcmMNk-9royD5O86epc57lyXlnC+wNnmdELJCECA8P4-MMb-TqACrBAPBFcpIQA */ + id: "search", + initial: "bootstrap", + context: ({ input }) => ({ + searchOffset: input.initialOffset ?? 0, + searchPageSize: input.searchPageSize, + facetLimit: input.facetLimit, + currentQuery: input.q ?? "", + submittedQuery: undefined, + searchData: undefined, + facetData: undefined, + selectedFilters: input.filters ?? {}, + queryClient: input.queryClient ?? null, + }), + states: { + bootstrap: { + on: { + SET_QUERY_CLIENT: { + actions: ["setQueryClientInContext"], + }, + SET_SEARCH_STRING: { + actions: ["setCurrentQueryInContext", "setSbmittedQueryInContext"], + }, + SET_INITIAL_FILTERS: { + actions: ["setInitialFiltersInContext"], + }, + BOOTSTRAP_DONE: [ + { + guard: and(["contextHasQueryClient", "contextHasSearchString"]), + target: "filteringAndSearching", + }, + { + target: "idle", + }, + ], + }, + }, + idle: { + on: { + TOGGLE_FILTER: [ + { + guard: "contextHasSearchString", + actions: ["resetSearchData", "toggleFilterInContext", "emitFilterToggled"], + target: "filteringAndSearching", + }, + { + target: "idle", + }, + ], + TYPING: [ + { + guard: "eventHasSearchString", + actions: ["setCurrentQueryInContext"], + }, + { + actions: ["emitQDeleted", "resetQuery"], + }, + ], + SEARCH: [ + { + guard: "contextHasSearchString", + actions: ["resetSearchData", "resetFilters", "setSbmittedQueryInContext"], + target: "filteringAndSearching", + }, + { + actions: ["resetFilters"], + target: "idle", + }, + ], + LOAD_MORE: { + guard: and(["contextHasSearchString", not("maxLimitReached")]), + actions: ["setLoadMoreValuesInContext"], + target: "loadingMoreSearchResults", + }, + }, + }, + filteringAndSearching: { + type: "parallel", + initial: "search", + states: { + search: { + initial: "searching", + guard: "contextHasQueryClient", + states: { + searching: { + invoke: { + src: "performSearch", + input: ({ context }) => { + if (!context.queryClient) { + throw new Error("QueryClient is not set in context.") + } + return { + q: context.currentQuery, + queryClient: context.queryClient, + filters: context.selectedFilters, + offset: context.searchOffset, + limit: context.searchPageSize, + } + }, + onDone: { + actions: ["setSearchDataInContext"], + target: "done", + }, + onError: {}, + }, + }, + done: { + type: "final", + }, + }, + }, + filter: { + initial: "filtering", + states: { + filtering: { + invoke: { + src: "getFacets", + input: ({ context }) => { + if (!context.queryClient) { + throw new Error("QueryClient is not set in context.") + } + return { + q: context.currentQuery, + queryClient: context.queryClient, + filters: context.selectedFilters, + facetLimit: context.facetLimit, + } + }, + onDone: { + actions: ["setFacetDataInContext"], + target: "done", + }, + onError: {}, + }, + }, + done: { + type: "final", + }, + }, + }, + }, + onDone: { + target: "#search.idle", + }, + }, + loadingMoreSearchResults: { + initial: "searching", + guard: "contextHasQueryClient", + states: { + searching: { + invoke: { + src: "performSearch", + input: ({ context }) => { + if (!context.queryClient) { + throw new Error("QueryClient is not set in context.") + } + return { + q: context.currentQuery, + queryClient: context.queryClient, + filters: context.selectedFilters, + offset: context.searchOffset, + limit: context.searchPageSize, + } + }, + onDone: { + actions: ["setSearchDataInContext"], + target: "#search.idle", + }, + onError: {}, + }, + }, + }, + }, + }, +}) diff --git a/lib/machines/search/types.ts b/lib/machines/search/types.ts new file mode 100644 index 00000000..06d4845a --- /dev/null +++ b/lib/machines/search/types.ts @@ -0,0 +1,33 @@ +import { QueryClient } from "@tanstack/react-query" + +import { + SearchFacetsQuery, + SearchFiltersInput, + SearchWithPaginationQuery, +} from "@/lib/graphql/generated/fbi/graphql" + +export type TFilters = Omit + +export type TContext = { + facetLimit: number + searchOffset: number + searchPageSize: number + currentQuery: string + submittedQuery?: string + searchData?: { + hitcount: SearchWithPaginationQuery["search"]["hitcount"] + pages: SearchWithPaginationQuery["search"]["works"][] + } + facetData?: SearchFacetsQuery["search"]["facets"] + selectedFilters: TFilters + queryClient: QueryClient | null +} + +export type TInput = { + q?: string + filters?: TFilters + queryClient?: QueryClient + initialOffset: number + searchPageSize: number + facetLimit: number +} diff --git a/lib/machines/search/useSearchMachineActor.tsx b/lib/machines/search/useSearchMachineActor.tsx new file mode 100644 index 00000000..360d5dfc --- /dev/null +++ b/lib/machines/search/useSearchMachineActor.tsx @@ -0,0 +1,107 @@ +"use client" + +import { useQueryClient } from "@tanstack/react-query" +import _ from "lodash" +import { ReadonlyURLSearchParams } from "next/navigation" +import { useSearchParams } from "next/navigation" +import { useEffect, useRef, useState } from "react" +import { AnyEventObject, createActor } from "xstate" + +import { getFacetsForSearchRequest } from "@/components/pages/searchPageLayout/helper" +import goConfig from "@/lib/config/config" + +import searchMachine from "./search.machine" + +const searchActor = createActor(searchMachine, { + input: { + initialOffset: goConfig("search.offset.initial"), + searchPageSize: goConfig("search.item.limit"), + facetLimit: goConfig("search.facet.limit"), + }, +}).start() + +// Administer search query params when filters are toggled. +searchActor.on("filterToggled", (emittedEvent: AnyEventObject) => { + const url = new URL(window.location.href) + const { + toggled: { name: filterName, value: filterValue }, + } = emittedEvent + + if (url.searchParams.has(filterName, filterValue)) { + url.searchParams.delete(filterName, filterValue) + } else { + url.searchParams.append(filterName, filterValue) + } + + window.history.pushState({}, "", url.href) +}) + +// Make sure the search query is removed from the URL when the search is cleared. +// And the same goes for facets. +searchActor.on("qDeleted", () => { + let urlShouldBeUpdated = false + + const url = new URL(window.location.href) + if (url.searchParams.get("q")) { + url.searchParams.delete("q") + urlShouldBeUpdated = true + } + + const facets = getFacetsForSearchRequest(url.searchParams as ReadonlyURLSearchParams) + for (const filter in facets) { + url.searchParams.delete(filter) + urlShouldBeUpdated = true + } + + if (urlShouldBeUpdated) { + window.history.pushState({}, "", url.href) + } +}) + +/** + * + * This hook is referencing the searchActor from the search.machine.ts file. + * + * The reason why we are using a ref is because we want to keep the same actor instance. + * + * The bootstrap state is used to set the initial filters and search string from the URL. + * + * Furthermore the queryClient is set to the actor context + * so we are able to use it in queries inside the machine. + */ +const useSearchMachineActor = () => { + const searchParams = useSearchParams() + const queryClient = useQueryClient() + const [isBootstrapped, setIsBootstrapped] = useState(false) + const actorRef = useRef(searchActor) + const actor = actorRef.current + + useEffect(() => { + if (!actor.getSnapshot().matches("bootstrap") || isBootstrapped) { + return + } + + const q = searchParams.get("q") + const filters = getFacetsForSearchRequest(searchParams as ReadonlyURLSearchParams) + + if (!_.isEmpty(filters)) { + actor.send({ type: "SET_INITIAL_FILTERS", filters }) + } + if (q) { + actor.send({ type: "SET_SEARCH_STRING", q }) + } + + actor.send({ type: "SET_QUERY_CLIENT", queryClient }) + + actor.send({ type: "BOOTSTRAP_DONE" }) + + setIsBootstrapped(true) + // We choose to ignore the eslint warning below + // because we want to make sure it only reruns if isBootstrapped changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isBootstrapped]) + + return actor +} + +export default useSearchMachineActor diff --git a/package.json b/package.json index dfe20957..a83210c8 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@redux-devtools/extension": "^3.3.0", "@tanstack/react-query": "^5.59.0", "@types/lodash": "^4.17.12", + "@xstate/react": "^4.1.3", "@uidotdev/usehooks": "^2.4.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -46,6 +47,7 @@ "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.0", + "xstate": "^5.18.2", "zod": "^3.23.8", "zustand": "^5.0.0-rc.2" }, diff --git a/yarn.lock b/yarn.lock index a15700ad..07dce159 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4213,6 +4213,14 @@ "@whatwg-node/fetch" "^0.9.22" tslib "^2.6.3" +"@xstate/react@^4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@xstate/react/-/react-4.1.3.tgz#d3db7102ad950584d15f5a07fc17d52a127f3c68" + integrity sha512-zhE+ZfrcCR87bu71Rkh5Z5ruZBivR/7uD/dkelzJqjQdI45IZc9DqTI8lL4Cg5+VN2p5k86KxDsusqW1kW11Tg== + dependencies: + use-isomorphic-layout-effect "^1.1.2" + use-sync-external-store "^1.2.0" + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz" @@ -10920,6 +10928,11 @@ use-callback-ref@^1.3.0: dependencies: tslib "^2.0.0" +use-isomorphic-layout-effect@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" + integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== + use-sidecar@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz" @@ -10928,6 +10941,11 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" +use-sync-external-store@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" + integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" @@ -11286,6 +11304,11 @@ xmlchars@^2.2.0: resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xstate@^5.18.2: + version "5.18.2" + resolved "https://registry.yarnpkg.com/xstate/-/xstate-5.18.2.tgz#924122af5102f3c3f7e172ebf20a09455ddb2963" + integrity sha512-hab5VOe29D0agy8/7dH1lGw+7kilRQyXwpaChoMu4fe6rDP+nsHYhDYKfS2O4iXE7myA98TW6qMEudj/8NXEkA== + xtend@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz"