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 { 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 { useTranslation } from 'react-i18next'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import {
|
import {
|
||||||
@@ -32,8 +32,41 @@ import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialo
|
|||||||
import { nowIsoLocal } from '@/lib/dates'
|
import { nowIsoLocal } from '@/lib/dates'
|
||||||
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
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')({
|
export const Route = createFileRoute('/dictionaries/$name')({
|
||||||
component: DictionaryDetail,
|
component: DictionaryDetail,
|
||||||
|
validateSearch,
|
||||||
})
|
})
|
||||||
|
|
||||||
type EditState =
|
type EditState =
|
||||||
@@ -44,9 +77,16 @@ type EditState =
|
|||||||
|
|
||||||
function DictionaryDetail() {
|
function DictionaryDetail() {
|
||||||
const { name } = Route.useParams()
|
const { name } = Route.useParams()
|
||||||
|
const urlSearch = Route.useSearch()
|
||||||
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const detailQuery = useDictionaryDetail(name)
|
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
|
const filter: RecordsFilter | undefined = aoi
|
||||||
? aoi.kind === 'bbox'
|
? aoi.kind === 'bbox'
|
||||||
? { bbox: aoi.bboxCsv }
|
? { bbox: aoi.bboxCsv }
|
||||||
@@ -62,10 +102,60 @@ function DictionaryDetail() {
|
|||||||
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
||||||
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
||||||
const [aoiOpen, setAoiOpen] = useState(false)
|
const [aoiOpen, setAoiOpen] = useState(false)
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
// Empty Set = "все scope активны". Click toggle добавляет/убирает
|
// Search & scope filter — URL-derived. Empty/missing = все scope активны.
|
||||||
// конкретный scope. Это проще объяснить чем "все selected" vs "none".
|
const search = urlSearch.q ?? ''
|
||||||
const [scopeFilter, setScopeFilter] = useState<Set<DataScope>>(new Set())
|
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 filteredRecords = useMemo(() => {
|
||||||
const raw = recordsResult.data ?? []
|
const raw = recordsResult.data ?? []
|
||||||
@@ -84,14 +174,6 @@ function DictionaryDetail() {
|
|||||||
})
|
})
|
||||||
}, [recordsResult.data, search, scopeFilter])
|
}, [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 filtersActive = search.length > 0 || scopeFilter.size > 0
|
||||||
const filteredCount = filteredRecords.length
|
const filteredCount = filteredRecords.length
|
||||||
|
|
||||||
@@ -205,7 +287,7 @@ function DictionaryDetail() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-xs text-ultramarain hover:underline"
|
className="text-xs text-ultramarain hover:underline"
|
||||||
onClick={() => setAoi(null)}
|
onClick={handleAoiClear}
|
||||||
>
|
>
|
||||||
{t('aoi.clear')}
|
{t('aoi.clear')}
|
||||||
</button>
|
</button>
|
||||||
@@ -267,10 +349,7 @@ function DictionaryDetail() {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={handleClearFilters}
|
||||||
setSearch('')
|
|
||||||
setScopeFilter(new Set())
|
|
||||||
}}
|
|
||||||
className="text-ultramarain hover:underline"
|
className="text-ultramarain hover:underline"
|
||||||
>
|
>
|
||||||
{t('dict.filter.clear')}
|
{t('dict.filter.clear')}
|
||||||
@@ -471,13 +550,41 @@ function DictionaryDetail() {
|
|||||||
<AoiPickerDialog
|
<AoiPickerDialog
|
||||||
isOpen={aoiOpen}
|
isOpen={aoiOpen}
|
||||||
onClose={() => setAoiOpen(false)}
|
onClose={() => setAoiOpen(false)}
|
||||||
onApply={(result) => setAoi(result)}
|
onApply={handleAoiApply}
|
||||||
initial={aoi?.geojson ?? null}
|
initial={aoi?.geojson ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 => {
|
const serverErrorMessage = (err: unknown): string | null => {
|
||||||
if (!err) return null
|
if (!err) return null
|
||||||
if (axios.isAxiosError(err)) {
|
if (axios.isAxiosError(err)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user