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:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user