diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 3e1a31c..7e00c3c 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -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 = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED']) + +const validateSearch = (raw: Record): 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(null) + + // AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт + // только в local state (не сериализуем в URL — слишком длинно). + // setAoi записывает в URL bbox только для bbox-mode (полигон не жмём в URL). + const [aoi, setAoi] = useState(() => 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(undefined) const [aoiOpen, setAoiOpen] = useState(false) - const [search, setSearch] = useState('') - // Empty Set = "все scope активны". Click toggle добавляет/убирает - // конкретный scope. Это проще объяснить чем "все selected" vs "none". - const [scopeFilter, setScopeFilter] = useState>(new Set()) + + // Search & scope filter — URL-derived. Empty/missing = все scope активны. + const search = urlSearch.q ?? '' + const scopeFilter = useMemo>( + () => 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() { @@ -267,10 +349,7 @@ function DictionaryDetail() {