1
0
mirror of synced 2025-12-19 09:57:42 -05:00

reuse multiQueryParams pattern to persist landing filter, dropdown, and page # (#58558)

This commit is contained in:
Evan Bonsignori
2025-12-03 10:36:51 -08:00
committed by GitHub
parent 03945b8c27
commit f3bdf824eb
5 changed files with 237 additions and 74 deletions

View File

@@ -4,6 +4,7 @@ import { LandingHero } from '@/landings/components/shared/LandingHero'
import { ArticleGrid } from '@/landings/components/shared/LandingArticleGridWithFilter'
import { UtmPreserver } from '@/frame/components/UtmPreserver'
import { LandingCarousel } from '@/landings/components/shared/LandingCarousel'
import { useMultiQueryParams } from '@/search/components/hooks/useMultiQueryParams'
export const BespokeLanding = () => {
const {
@@ -16,6 +17,10 @@ export const BespokeLanding = () => {
includedCategories,
landingType,
} = useLandingContext()
const { params, updateParams } = useMultiQueryParams({
useHistory: true,
excludeFromHistory: ['articles-filter'],
})
return (
<DefaultLayout>
@@ -29,6 +34,8 @@ export const BespokeLanding = () => {
tocItems={tocItems}
includedCategories={includedCategories}
landingType={landingType}
params={params}
updateParams={updateParams}
/>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { LandingHero } from '@/landings/components/shared/LandingHero'
import { ArticleGrid } from '@/landings/components/shared/LandingArticleGridWithFilter'
import { LandingCarousel } from '@/landings/components/shared/LandingCarousel'
import { UtmPreserver } from '@/frame/components/UtmPreserver'
import { useMultiQueryParams } from '@/search/components/hooks/useMultiQueryParams'
export const DiscoveryLanding = () => {
const {
@@ -16,6 +17,10 @@ export const DiscoveryLanding = () => {
includedCategories,
landingType,
} = useLandingContext()
const { params, updateParams } = useMultiQueryParams({
useHistory: true,
excludeFromHistory: ['articles-filter'],
})
return (
<DefaultLayout>
@@ -28,6 +33,8 @@ export const DiscoveryLanding = () => {
tocItems={tocItems}
includedCategories={includedCategories}
landingType={landingType}
params={params}
updateParams={updateParams}
/>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import { Link } from '@/frame/components/Link'
import { useTranslation } from '@/languages/components/useTranslation'
import { ArticleCardItems, ChildTocItem, TocItem } from '@/landings/types'
import { LandingType } from '@/landings/context/LandingContext'
import type { QueryParams } from '@/search/components/hooks/useMultiQueryParams'
import styles from './LandingArticleGridWithFilter.module.scss'
@@ -14,6 +15,8 @@ type ArticleGridProps = {
tocItems: TocItem[]
includedCategories?: string[]
landingType: LandingType
params: QueryParams
updateParams: (updates: Partial<QueryParams>, shouldPushHistory?: boolean) => void
}
const ALL_CATEGORIES = 'all_categories'
@@ -69,17 +72,24 @@ const useResponsiveArticlesPerPage = () => {
return articlesPerPage
}
export const ArticleGrid = ({ tocItems, includedCategories, landingType }: ArticleGridProps) => {
export const ArticleGrid = ({
tocItems,
includedCategories,
landingType,
params,
updateParams,
}: ArticleGridProps) => {
const { t } = useTranslation('product_landing')
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORIES)
const [selectedCategoryIndex, setSelectedCategoryIndex] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const articlesPerPage = useResponsiveArticlesPerPage()
const inputRef = useRef<HTMLInputElement>(null)
const headingRef = useRef<HTMLHeadingElement>(null)
// Read filter state directly from query params
const searchQuery = params['articles-filter'] || ''
const selectedCategory = params['articles-category'] || ALL_CATEGORIES
const currentPage = parseInt(params['articles-page'] || '1', 10)
// Recursively flatten all articles from tocItems, including both direct children and nested articles
const allArticles = useMemo(() => flattenArticles(tocItems), [tocItems])
@@ -99,25 +109,43 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic
return allArticles
}, [allArticles, includedCategories, landingType])
// Reset to first page when articlesPerPage changes (screen size changes)
useEffect(() => {
setCurrentPage(1)
}, [articlesPerPage])
// Extract unique categories for dropdown from filtered articles (so all dropdown options have matching articles)
const categories: string[] = [
ALL_CATEGORIES,
...Array.from(
new Set(filteredArticlesByLandingType.flatMap((item) => (item.category || []) as string[])),
)
.filter((category: string) => {
if (!includedCategories || includedCategories.length === 0) return true
// Case-insensitive comparison for dropdown filtering
const lowerCategory = category.toLowerCase()
return includedCategories.some((included) => included.toLowerCase() === lowerCategory)
})
.sort((a, b) => a.localeCompare(b)),
]
const categories: string[] = useMemo(
() => [
ALL_CATEGORIES,
...Array.from(
new Set(filteredArticlesByLandingType.flatMap((item) => (item.category || []) as string[])),
)
.filter((category: string) => {
if (!includedCategories || includedCategories.length === 0) return true
// Case-insensitive comparison for dropdown filtering
const lowerCategory = category.toLowerCase()
return includedCategories.some((included) => included.toLowerCase() === lowerCategory)
})
.sort((a, b) => a.localeCompare(b)),
],
[filteredArticlesByLandingType, includedCategories],
)
// Calculate the selected category index based on the current query param
const selectedCategoryIndex = useMemo(() => {
const index = categories.indexOf(selectedCategory)
return index !== -1 ? index : 0
}, [categories, selectedCategory])
// Clear invalid category from query params if it doesn't exist in available categories
useEffect(() => {
if (selectedCategory !== ALL_CATEGORIES && selectedCategoryIndex === 0) {
updateParams({ 'articles-category': '' })
}
}, [selectedCategory, selectedCategoryIndex, updateParams])
// Sync the input field value with query params
useEffect(() => {
if (inputRef.current) {
inputRef.current.value = searchQuery
}
}, [searchQuery])
const applyFilters = () => {
let results = filteredArticlesByLandingType
@@ -154,31 +182,86 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic
const paginatedResults = filteredResults.slice(startIndex, startIndex + articlesPerPage)
const handleSearch = (query: string) => {
setSearchQuery(query)
setCurrentPage(1) // Reset to first page when searching
// Update query params, clear if empty, and reset to first page
// Don't add to history for search filtering
updateParams({ 'articles-filter': query || '', 'articles-page': '' }, false)
}
const handleFilter = (option: string, index: number) => {
setSelectedCategory(option)
setSelectedCategoryIndex(index)
setCurrentPage(1) // Reset to first page when filtering
const handleFilter = (option: string) => {
// Update query params, clear if "all categories", and reset to first page
updateParams(
{
'articles-category': option === ALL_CATEGORIES ? '' : option,
'articles-page': '',
},
true,
)
}
// Track previous page to determine if we should scroll
const prevPageRef = useRef(currentPage)
const hasMountedRef = useRef(false)
const handlePageChange = (e: React.MouseEvent, pageNumber: number) => {
e.preventDefault()
if (pageNumber >= 1 && pageNumber <= totalPages) {
setCurrentPage(pageNumber)
if (headingRef.current) {
const elementPosition = headingRef.current.getBoundingClientRect().top + window.scrollY
const offsetPosition = elementPosition - 140 // 140px offset from top
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
})
}
// Update page in query params, clear if page 1
updateParams({ 'articles-page': pageNumber === 1 ? '' : String(pageNumber) }, true)
}
}
// Scroll to heading on initial mount if query params are present
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true
// Check if any VALID article grid query params are present on initial load
// Don't scroll if category is invalid (selectedCategoryIndex === 0 means invalid or "all")
const hasValidCategory = selectedCategory !== ALL_CATEGORIES && selectedCategoryIndex !== 0
const hasQueryParams = searchQuery || hasValidCategory || currentPage > 1
if (hasQueryParams && headingRef.current) {
// Use setTimeout to ensure the component is fully rendered
setTimeout(() => {
if (headingRef.current) {
const elementPosition = headingRef.current.getBoundingClientRect().top + window.scrollY
const offsetPosition = elementPosition - 140 // 140px offset from top
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
})
}
}, 100)
}
}
}, []) // Only run on mount
// Scroll to heading when page changes via pagination
useEffect(() => {
const pageChanged = currentPage !== prevPageRef.current
const isPaginationClick = pageChanged && prevPageRef.current !== 1
// Scroll if page changed via pagination (not from filter/category reset to page 1)
// This includes: going to page 2+, or going back to page 1 from a higher page
const shouldScroll = pageChanged && (currentPage > 1 || isPaginationClick)
if (shouldScroll && headingRef.current) {
// Delay scroll slightly to let router finish and restore scroll position first
setTimeout(() => {
if (headingRef.current) {
const elementPosition = headingRef.current.getBoundingClientRect().top + window.scrollY
const offsetPosition = elementPosition - 140 // 140px offset from top
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
})
}
}, 150) // Slightly longer than router debounce (100ms) + execution time
}
prevPageRef.current = currentPage
}, [currentPage])
return (
<div data-testid="article-grid-container">
{/* Filter and Search Controls */}
@@ -204,7 +287,7 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic
<ActionList.Item
key={index}
selected={index === selectedCategoryIndex}
onSelect={() => handleFilter(category, index)}
onSelect={() => handleFilter(category)}
>
{category === ALL_CATEGORIES ? t('article_grid.all_categories') : category}
</ActionList.Item>

View File

@@ -1,22 +1,37 @@
import { useRouter } from 'next/router'
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
export type QueryParams = {
'search-overlay-input': string
'search-overlay-ask-ai': string // "true" or ""
debug: string
'articles-category': string
'articles-filter': string
'articles-page': string
}
const initialKeys: (keyof QueryParams)[] = [
// Used to persist search state
'search-overlay-input',
'search-overlay-ask-ai',
// Used to debug search result
'debug',
// Used to filter category and search results of Articles on landing pages
'articles-category',
'articles-filter',
'articles-page',
]
// When we need to update 2 query params simultaneously, we can use this hook to prevent race conditions
export function useMultiQueryParams() {
export function useMultiQueryParams(options?: {
useHistory?: boolean
excludeFromHistory?: (keyof QueryParams)[]
}) {
const router = useRouter()
const pushTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const useHistory = options?.useHistory ?? false
// When using browser history, exclude these params from being updated on back/forward navigation (like search input which causes race conditions)
const excludeFromHistory = options?.excludeFromHistory ?? []
const getInitialParams = (): QueryParams => {
const searchParams =
@@ -27,6 +42,9 @@ export function useMultiQueryParams() {
'search-overlay-input': searchParams.get('search-overlay-input') || '',
'search-overlay-ask-ai': searchParams.get('search-overlay-ask-ai') || '',
debug: searchParams.get('debug') || '',
'articles-category': searchParams.get('articles-category') || '',
'articles-filter': searchParams.get('articles-filter') || '',
'articles-page': searchParams.get('articles-page') || '',
}
return params
}
@@ -38,43 +56,87 @@ export function useMultiQueryParams() {
setParams(getInitialParams())
}, [router.pathname])
const updateParams = (updates: Partial<QueryParams>) => {
const newParams = { ...params, ...updates }
const [asPathWithoutHash] = router.asPath.split('#')
const [asPathRoot, asPathQuery = ''] = asPathWithoutHash.split('?')
const searchParams = new URLSearchParams(asPathQuery)
for (const key of initialKeys) {
if (key === 'search-overlay-ask-ai') {
if (newParams[key] === 'true') {
searchParams.set(key, 'true')
} else {
searchParams.delete(key)
// Listen to browser back/forward button navigation (only if history is being used)
useEffect(() => {
if (!useHistory) return
const handleRouteChange = () => {
// When the route changes (e.g., back button), update state from URL
// But preserve excluded params from current state to avoid race conditions
setParams((currentParams) => {
const newParams = getInitialParams()
// Keep excluded params from current state instead of reading from URL
for (const key of excludeFromHistory) {
newParams[key] = currentParams[key]
}
} else {
if (newParams[key]) {
searchParams.set(key, newParams[key])
} else {
searchParams.delete(key)
}
}
}
const paramsString = searchParams.toString() ? `?${searchParams.toString()}` : ''
let newUrl = `${asPathRoot}${paramsString}`
if (asPathRoot !== '/' && router.locale) {
newUrl = `${router.locale}${asPathRoot}${paramsString}`
}
if (!newUrl.startsWith('/')) {
newUrl = `/${newUrl}`
return newParams
})
}
// Debounce the router push so we don't push a new URL for every keystroke
if (pushTimeoutRef.current) clearTimeout(pushTimeoutRef.current)
pushTimeoutRef.current = setTimeout(() => {
router.replace(newUrl, undefined, { shallow: true, locale: router.locale, scroll: false })
}, 100)
router.events.on('routeChangeComplete', handleRouteChange)
return () => {
router.events.off('routeChangeComplete', handleRouteChange)
}
}, [router.events, useHistory, excludeFromHistory])
setParams(newParams)
}
const updateParams = useCallback(
(updates: Partial<QueryParams>, shouldPushHistory = false) => {
// Use functional state update to avoid depending on params in the closure
setParams((currentParams) => {
const newParams = { ...currentParams, ...updates }
const [asPathWithoutHash] = router.asPath.split('#')
const [asPathRoot, asPathQuery = ''] = asPathWithoutHash.split('?')
const searchParams = new URLSearchParams(asPathQuery)
for (const key of initialKeys) {
if (key === 'search-overlay-ask-ai') {
if (newParams[key] === 'true') {
searchParams.set(key, 'true')
} else {
searchParams.delete(key)
}
} else {
if (newParams[key]) {
searchParams.set(key, newParams[key])
} else {
searchParams.delete(key)
}
}
}
const paramsString = searchParams.toString() ? `?${searchParams.toString()}` : ''
let newUrl = `${asPathRoot}${paramsString}`
if (asPathRoot !== '/' && router.locale) {
newUrl = `${router.locale}${asPathRoot}${paramsString}`
}
if (!newUrl.startsWith('/')) {
newUrl = `/${newUrl}`
}
// Debounce the router push so we don't push a new URL for every keystroke
if (pushTimeoutRef.current) clearTimeout(pushTimeoutRef.current)
pushTimeoutRef.current = setTimeout(async () => {
// Always preserve scroll position during router update to prevent jumps
// Component-level scroll logic (like pagination scroll) will handle intentional scrolling
const scrollY = window.scrollY
const scrollX = window.scrollX
// Use router.push for history entries (category/page changes), router.replace for others (search)
const routerMethod = shouldPushHistory ? router.push : router.replace
await routerMethod(newUrl, undefined, {
shallow: true,
locale: router.locale,
scroll: false,
})
// Restore scroll position after router update
// This prevents unintended scrolling; intentional scrolling is handled by components
window.scrollTo(scrollX, scrollY)
}, 100)
return newParams
})
},
[router],
)
return { params, updateParams }
}

View File

@@ -37,6 +37,10 @@ const RECOGNIZED_KEYS_BY_ANY = new Set([
'search-overlay-ask-ai',
// The drop-downs on "Webhook events and payloads"
'actionType',
// Landing page article grid filters
'articles-category',
'articles-filter',
'articles-page',
// Legacy domain tracking parameter (no longer processed but still recognized)
'ghdomain',
// UTM campaign tracking