feat(dict): URL search params для filter state — share-friendly persistence

Раньше search/scope/AOI filters жили только в локальном useState —
F5 сбрасывал, ссылку с применёнными фильтрами не расшарить.

Теперь:
- ?q=text — search query
- ?scopes=PUBLIC,INTERNAL — comma-separated subset (empty = все)
- ?bbox=west,south,east,north — bbox AOI (server-side spatial filter)
  Polygon AOI остаётся в local state — слишком длинно (50+ точек) для
  URL, рендерили бы /dictionaries/foo?polygon={...} с сотнями символов.

validateSearch на route + Route.useSearch() читает; navigate replace:true
обновляет URL без новой history entry на каждый keystroke search'а.

Bootstrap: при mount если ?bbox= — рендерим bbox-AOI initial state;
картинку с прямоугольником сразу видно в picker'е если открыть.

Use cases:
- Bookmark filtered view словаря
- Поделиться ссылкой '/dictionaries/spacecraft?q=Канопус&scopes=PUBLIC'
- F5 не теряет filters
This commit is contained in:
Zimin A.N.
2026-05-06 20:30:40 +03:00
parent dd42626fe2
commit d5a28d251d
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import {
@@ -32,8 +32,41 @@ import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialo
import { nowIsoLocal } from '@/lib/dates'
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
/**
* URL search params для filter state — share-friendly, persist между F5.
* - q: search query (free text)
* - scopes: csv subset of PUBLIC/INTERNAL/RESTRICTED. Empty/missing = все.
* - bbox: AOI как CSV "west,south,east,north". Polygon AOI в URL не
* сериализуем — слишком длинно (50+ точек), оставляем в local state.
* Если bbox задан — initial AOI установится в bbox-режим при mount'е.
*/
type DictSearch = {
q?: string
scopes?: string
bbox?: string
}
const SCOPE_VALUES: ReadonlySet<DataScope> = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
const validateSearch = (raw: Record<string, unknown>): DictSearch => {
const out: DictSearch = {}
if (typeof raw.q === 'string' && raw.q.length > 0) out.q = raw.q
if (typeof raw.scopes === 'string') {
const valid = raw.scopes
.split(',')
.map((s) => s.trim().toUpperCase())
.filter((s): s is DataScope => SCOPE_VALUES.has(s as DataScope))
if (valid.length > 0) out.scopes = valid.join(',')
}
if (typeof raw.bbox === 'string' && /^-?\d+(\.\d+)?(,-?\d+(\.\d+)?){3}$/.test(raw.bbox)) {
out.bbox = raw.bbox
}
return out
}
export const Route = createFileRoute('/dictionaries/$name')({
component: DictionaryDetail,
validateSearch,
})
type EditState =
@@ -44,9 +77,16 @@ type EditState =
function DictionaryDetail() {
const { name } = Route.useParams()
const urlSearch = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const { t } = useTranslation()
const detailQuery = useDictionaryDetail(name)
const [aoi, setAoi] = useState<AoiResult | null>(null)
// AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
// только в local state (не сериализуем в URL — слишком длинно).
// setAoi записывает в URL bbox только для bbox-mode (полигон не жмём в URL).
const [aoi, setAoi] = useState<AoiResult | null>(() => bboxToAoi(urlSearch.bbox))
const filter: RecordsFilter | undefined = aoi
? aoi.kind === 'bbox'
? { bbox: aoi.bboxCsv }
@@ -62,10 +102,60 @@ function DictionaryDetail() {
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
const [aoiOpen, setAoiOpen] = useState(false)
const [search, setSearch] = useState('')
// Empty Set = "все scope активны". Click toggle добавляет/убирает
// конкретный scope. Это проще объяснить чем "все selected" vs "none".
const [scopeFilter, setScopeFilter] = useState<Set<DataScope>>(new Set())
// Search & scope filter — URL-derived. Empty/missing = все scope активны.
const search = urlSearch.q ?? ''
const scopeFilter = useMemo<Set<DataScope>>(
() => new Set(urlSearch.scopes ? (urlSearch.scopes.split(',') as DataScope[]) : []),
[urlSearch.scopes],
)
const setSearch = (next: string) => {
void navigate({
search: (prev) => ({ ...prev, q: next.length > 0 ? next : undefined }),
replace: true,
})
}
const toggleScope = (scope: DataScope) => {
const next = new Set(scopeFilter)
if (next.has(scope)) next.delete(scope)
else next.add(scope)
void navigate({
search: (prev) => ({
...prev,
scopes: next.size > 0 ? Array.from(next).join(',') : undefined,
}),
replace: true,
})
}
const handleAoiApply = (result: AoiResult) => {
setAoi(result)
// bbox в URL для share; polygon оставляем в memory.
void navigate({
search: (prev) => ({
...prev,
bbox: result.kind === 'bbox' ? result.bboxCsv : undefined,
}),
replace: true,
})
}
const handleAoiClear = () => {
setAoi(null)
void navigate({
search: (prev) => ({ ...prev, bbox: undefined }),
replace: true,
})
}
const handleClearFilters = () => {
void navigate({
search: (prev) => ({ ...prev, q: undefined, scopes: undefined }),
replace: true,
})
}
const filteredRecords = useMemo(() => {
const raw = recordsResult.data ?? []
@@ -84,14 +174,6 @@ function DictionaryDetail() {
})
}, [recordsResult.data, search, scopeFilter])
const toggleScope = (scope: DataScope) => {
setScopeFilter((prev) => {
const next = new Set(prev)
if (next.has(scope)) next.delete(scope)
else next.add(scope)
return next
})
}
const filtersActive = search.length > 0 || scopeFilter.size > 0
const filteredCount = filteredRecords.length
@@ -205,7 +287,7 @@ function DictionaryDetail() {
<button
type="button"
className="text-xs text-ultramarain hover:underline"
onClick={() => setAoi(null)}
onClick={handleAoiClear}
>
{t('aoi.clear')}
</button>
@@ -267,10 +349,7 @@ function DictionaryDetail() {
</span>
<button
type="button"
onClick={() => {
setSearch('')
setScopeFilter(new Set())
}}
onClick={handleClearFilters}
className="text-ultramarain hover:underline"
>
{t('dict.filter.clear')}
@@ -471,13 +550,41 @@ function DictionaryDetail() {
<AoiPickerDialog
isOpen={aoiOpen}
onClose={() => setAoiOpen(false)}
onApply={(result) => setAoi(result)}
onApply={handleAoiApply}
initial={aoi?.geojson ?? null}
/>
</div>
)
}
/**
* Bootstrap initial AOI из URL bbox param. Возвращает bbox-AOI с
* прямоугольным GeoJSON для preview в picker'е. Невалидные значения
* уже отфильтрованы validateSearch — но dup'аем guard на парсинге.
*/
const bboxToAoi = (bboxCsv: string | undefined): AoiResult | null => {
if (!bboxCsv) return null
const parts = bboxCsv.split(',').map(Number)
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return null
const [w, s, e, n] = parts
return {
kind: 'bbox',
bboxCsv,
geojson: {
type: 'Polygon',
coordinates: [
[
[w, s],
[e, s],
[e, n],
[w, n],
[w, s],
],
],
},
}
}
const serverErrorMessage = (err: unknown): string | null => {
if (!err) return null
if (axios.isAxiosError(err)) {