Skip to content

Commit

Permalink
Merge pull request #39 from danskernesdigitalebibliotek/DDFBRA-180-im…
Browse files Browse the repository at this point in the history
…plementer-xstate-som-state-manager-i-sogning

Search in Xstate
  • Loading branch information
spaceo authored Nov 14, 2024
2 parents 47ad089 + dae93ae commit fd4388a
Show file tree
Hide file tree
Showing 15 changed files with 656 additions and 172 deletions.
2 changes: 1 addition & 1 deletion app/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const Page = async ({ searchParams: { q } }: { searchParams: { q: string } }) =>
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<p>Loading...</p>}>
<SearchPageLayout searchQuery={q} />
<SearchPageLayout />
</Suspense>
</HydrationBoundary>
)
Expand Down
168 changes: 45 additions & 123 deletions components/pages/searchPageLayout/SearchPageLayout.tsx
Original file line number Diff line number Diff line change
@@ -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<number>("search.item.limit")

export type FilterItemTerm = Omit<FacetValue, "__typename">

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<SearchFiltersInput>({})
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<number>("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 (
<div className="content-container my-grid-gap-2 space-y-grid-gap-2">
<h1 className="text-typo-heading-3 lg:text-typo-heading-2">
{`Viser resultater for "${q}" ${hitcount ? "(" + hitcount + ")" : ""}`}
</h1>
{q ? (
{searchQuery && (
<h1 className="text-typo-heading-3 lg:text-typo-heading-2">
{`Viser resultater for ${searchQueryText} ${hitCountText}`}
</h1>
)}
{searchQuery ? (
<>
{!isLoadingFacets && facetData && facetData?.length > 0 ? (
{!isLoadingFacets && data.facets && data.facets.length > 0 ? (
<div className="relative">
<div className="xl:hidden">
<SearchFiltersMobile facets={dataFacets.search.facets} />
<SearchFiltersMobile facets={data.facets} />
</div>
<div className="hidden xl:block">
<SearchFiltersDesktop facets={dataFacets.search.facets} />
<SearchFiltersDesktop facets={data.facets} />
</div>
</div>
) : (
Expand All @@ -144,28 +60,34 @@ const SearchPageLayout = ({ searchQuery }: { searchQuery?: string }) => {
)}
<hr className="-mx-grid-edge w-screen border-foreground opacity-10 md:mx-auto md:w-full" />
<div className="mb-space-y flex flex-col gap-y-[calc(var(--grid-gap-x)*2)]">
{data?.pages.map(
(page, i) =>
page.search.works && (
<motion.div
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
exit={{ opacity: 0 }}>
<SearchResults works={page.search.works} />
</motion.div>
)
)}
{isLoading && <SearchResultsGhost />}
{isNoSearchResult && <p>Ingen søgeresultat</p>}
{data.search &&
data.search.pages.map(
(works, i) =>
works && (
<motion.div
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
exit={{ opacity: 0 }}>
<SearchResults works={works} />
</motion.div>
)
)}
{isLoadingResults && <SearchResultsGhost />}
</div>
<div ref={loadMoreRef} className="h-0 opacity-0"></div>
</>
) : (
<div className="text-typo-body-1">
<p className="text-foreground opacity-80">Ingen søgeord fundet</p>
</div>
<>
{machineIsReady && (
<div className="text-typo-body-1">
<p className="text-foreground opacity-80">Ingen søgeord fundet</p>
</div>
)}
</>
)}
<div ref={loadMoreRef} className="h-0 opacity-0"></div>
</div>
)
}
Expand Down
4 changes: 2 additions & 2 deletions components/pages/searchPageLayout/SearchResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
34 changes: 32 additions & 2 deletions components/pages/searchPageLayout/helper.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -42,7 +45,7 @@ export const getFacetsForSearchRequest = (searchParams: ReadonlyURLSearchParams)
}
return acc
},
{} as { [key: string]: keyof SearchFiltersInput[] }
{} as { [key: string]: keyof TFilters[] }
)
}

Expand All @@ -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,
}
}
29 changes: 19 additions & 10 deletions components/shared/searchFilters/SearchFiltersColumn.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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<HTMLDivElement | null>(null)
const [hasOverflow, setHasOverflow] = useState(false)
const { selectedFilters } = useSearchDataAndLoadingStates()

useEffect(() => {
const el = elementRef.current
Expand All @@ -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 (
<>
Expand All @@ -70,8 +73,14 @@ const SearchFiltersColumn = ({
{facet.values.map((value, index) => (
<BadgeButton
key={index}
onClick={() => 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}
</BadgeButton>
))}
Expand Down
5 changes: 3 additions & 2 deletions components/shared/searchFilters/SearchFiltersMobile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -79,7 +80,7 @@ const SearchFiltersMobile = ({ facets }: SearchFiltersMobileProps) => {
<div className="-mx-grid-edge">
<Accordion type="multiple" defaultValue={facets.map(facet => facet.name)}>
{facets.map(facet => {
const facetName = facet.name as keyof SearchFiltersInput
const facetName = facet.name as keyof TFilters
return (
<AccordionItem key={facetName} value={facetName}>
<AccordionTrigger>{getFacetTranslation(facetName)}</AccordionTrigger>
Expand Down
Loading

0 comments on commit fd4388a

Please sign in to comment.