Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Search in Xstate #39

Merged
merged 16 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
150 changes: 35 additions & 115 deletions components/pages/searchPageLayout/SearchPageLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,146 +1,66 @@
"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 { getFacetMachineNames } from "@/components/shared/searchFilters/helper"
import goConfig from "@/lib/config/config"
import {
FacetValue,
SearchFiltersInput,
useSearchFacetsQuery,
useSearchWithPaginationQuery,
} from "@/lib/graphql/generated/fbi/graphql"
import { WorkTeaserFragment } from "@/lib/graphql/generated/fbi/graphql"
import useSearchMachineActor from "@/lib/machines/search/useSearchMachineActor"

import SearchFilterBar from "../../shared/searchFilters/SearchFilterBar"
import SearchResults 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,
} = 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, q } = 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 isNoFilters = !!(!isLoadingFacets && !facetData?.length)
const isNoSearchResult = !!(!isLoadingResults && hitcount === 0)
const isNoSearchResult = !isLoadingResults && (!data.search || !data.search.pages[0].length)
const hitCountText = data.search?.hitcount ? `(${data.search.hitcount})` : ""

return (
<div className="content-container">
<h1 className="mt-8 text-typo-heading-3 lg:mt-[88px] lg:text-typo-heading-2">
{`Viser resultater for "${q}" ${hitcount ? "(" + hitcount + ")" : ""}`}
</h1>
{q && (
<h1 className="mt-8 text-typo-heading-3 lg:mt-[88px] lg:text-typo-heading-2">
{`Viser resultater for "${q}" ${hitCountText}`}
</h1>
)}
{/* TODO: add ghost loading and cleanup the code below */}
{isLoadingFacets && <p>isLoadingFacets...</p>}
{isNoFilters && <p>Ingen filter</p>}
{facetData && facetData?.length > 0 && <SearchFilterBar facets={dataFacets.search.facets} />}
{data.facets && <SearchFilterBar facets={data.facets} />}
{isLoadingResults && <p>isLoading...</p>}
{isNoSearchResult && <p>Ingen søgeresultat</p>}
<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>
)
)}
</div>
{data.search && (
<div className="mb-space-y flex flex-col gap-y-[calc(var(--grid-gap-x)*2)]">
{data.search.pages.map(
(works: WorkTeaserFragment[], i: number) =>
works && (
<motion.div
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
exit={{ opacity: 0 }}>
<SearchResults works={works} />
</motion.div>
)
)}
</div>
)}
<div ref={loadMoreRef} className="h-0 opacity-0"></div>
</div>
)
return null
}

export default SearchPageLayout
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 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
27 changes: 25 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(
spaceo marked this conversation as resolved.
Show resolved Hide resolved
(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,23 @@ 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 q = useSelector(actor, snapshot => {
return snapshot.context.currentQ
})
const data = useSelector(actor, snapshot => {
const { facetData: facets, searchData: search } = snapshot.context
return { facets, search }
})
const isLoadingFacets =
!data.facets || actor.getSnapshot().matches({ filteringAndSearching: "filter" })
spaceo marked this conversation as resolved.
Show resolved Hide resolved
const isLoadingResults =
!data.search || actor.getSnapshot().matches({ filteringAndSearching: "search" })
spaceo marked this conversation as resolved.
Show resolved Hide resolved
const selectedFilters = useSelector(actor, snapshot => {
return snapshot.context.selectedFilters
})

return { q, data, selectedFilters, isLoadingFacets, isLoadingResults }
}
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"

type SearchFiltersColumnProps = {
facet: SearchFacetFragment
Expand All @@ -24,12 +26,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 @@ -42,7 +43,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 @@ -62,8 +65,14 @@ const SearchFiltersColumn = ({
ref={elementRef}>
{facet.values.map((value, index) => (
<BadgeButton
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,
})}
key={index}>
{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 @@ -16,7 +16,8 @@ import {
toggleFilter,
} from "@/components/shared/searchFilters/helper"
import { Sheet, SheetContent, 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"

type SearchFiltersMobileProps = {
facets: SearchFacetFragment[]
Expand Down Expand Up @@ -62,7 +63,7 @@ const SearchFiltersMobile = ({ facets }: SearchFiltersMobileProps) => {
<SheetContent className="w-full p-grid-edge pt-20" side="bottom">
<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