Files
supabase/packages/ui-patterns/ComplexTabs/withQueryParams.tsx
Charis 2f4f0adbd1 rework tabs query state to work better across both routers (#23253)
Refactor tabs so that:

- Behavior add-ons, such as query params, are HOCs in ui-patterns, to keep the basic Tabs minimal
- No dependency on next/router (this is to smooth out a separate App Router migration PR)
- No dependency on useSearchParams either (this is to preserve static generation of as much HTML content as 
  possible, for SEO purposes -- see comment about client-rendering bailout when using useSearchParams)
- Syncing behavior better localized in a hook

Minor enhancement to save tab preferences to local storage so they are remembered between sessions
2024-05-02 14:50:07 -04:00

119 lines
3.8 KiB
TypeScript

import { useSearchParamsShallow } from 'common'
import { xor } from 'lodash'
import { Children, useEffect, useRef, type PropsWithChildren } from 'react'
import { type Tabs, type TabsProps } from 'ui'
const isString = (maybeStr: unknown): maybeStr is string => typeof maybeStr === 'string'
const LOCAL_STORAGE_KEY = 'supabase.ui-patterns.ComplexTabs.withQueryParams.v0'
/**
* Wraps the basic `Tabs` component from the `ui` library so it stores
* selection state in query params.
*/
const withQueryParams =
(Component: typeof Tabs) =>
({
children: childrenUnvalidated,
queryGroup: queryGroupTemp,
onClick,
...props
}: PropsWithChildren<TabsProps & { queryGroup?: string }>) => {
const children = Children.toArray(childrenUnvalidated)
const tabIdsTemp = children
.map((child) => !!child && typeof child === 'object' && 'props' in child && child.props.id)
.filter(isString)
// Store in ref to avoid stale data in later timeout
const tabIdsRef = useRef(tabIdsTemp)
tabIdsRef.current = tabIdsTemp
// Store in ref to avoid stale data in later timeout
const queryGroupRef = useRef(queryGroupTemp)
queryGroupRef.current = queryGroupTemp
const searchParams = useSearchParamsShallow()
const queryTabMaybe = queryGroupRef.current && searchParams.get(queryGroupRef.current)
const queryTab =
queryTabMaybe && tabIdsRef.current.includes(queryTabMaybe) ? queryTabMaybe : undefined
const checkedLocalStorage = useRef(false)
useEffect(() => {
if (!checkedLocalStorage.current) {
// Timeout to avoid something (I think the router) overwriting it
setTimeout(() => {
if (
queryGroupRef.current &&
!new URLSearchParams(window.location.search).has(queryGroupRef.current)
) {
try {
const storedValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '')
if (storedValues === null || typeof storedValues !== 'object') return
let storedValue: any = null
let maxDiff = tabIdsRef.current.length
Object.entries(storedValues).forEach(([key, value]) => {
const arr = key.split(',')
const diff = xor(arr, tabIdsRef.current)
if (diff.length < maxDiff) {
maxDiff = diff.length
storedValue = value
}
})
if (storedValue && tabIdsRef.current.includes(storedValue)) {
switchTab(storedValue)
}
} catch {
// ignore
}
}
}, 300)
checkedLocalStorage.current = true
}
if (queryGroupRef.current && queryTab) {
let updatedValues: Record<string, unknown> = {}
try {
const oldValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '')
if (oldValues && typeof oldValues === 'object') {
updatedValues = oldValues
}
} catch {
// ignore
}
updatedValues[tabIdsRef.current.sort().join(',')] = queryTab
try {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updatedValues))
} catch {
// ignore
}
}
}, [queryTab])
const switchTab = (id: string) => {
if (queryGroupRef.current) {
if (!searchParams.getAll('queryGroups').includes(queryGroupRef.current)) {
searchParams.append('queryGroups', queryGroupRef.current)
}
searchParams.set(queryGroupRef.current, id)
}
}
const onTabClick = (id: string) => {
switchTab(id)
onClick?.(id)
}
return (
<Component {...props} activeId={queryTab} onClick={onTabClick}>
{/* Tabs does its own validation */}
{childrenUnvalidated}
</Component>
)
}
export { withQueryParams }