From 05af8e1520f4ea9c23fed6a0f52276b22a6fecb3 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 20:01:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(geo):=20AOI=20picker=20=E2=80=94=20Leaflet?= =?UTF-8?q?=20draw=20=E2=86=92=20bbox/polygon=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin кликает 'AOI фильтр' на странице словаря → открывается dialog с картой OSM. Рисует rectangle (→ bbox CSV) или polygon → onApply применяется к recordsQuery как ?bbox= или ?polygon=GeoJSON. Server-side parsing уже есть (BoundingBox.parse, GeoJsonPolygon.parse в ordinis-rest-api) — подключили UI поверх. Активный AOI отображается inline над таблицей с кнопкой 'Сбросить'. Edit shape возможен через leaflet-draw edit/remove controls. Зависимости: leaflet 1.9.4 + react-leaflet 5 + leaflet-draw 1.0.4 + types. Bundle +231KB gzip 62KB — приемлемо для AOI-only feature (map работает без MapLibre WASM, OSM tiles — публичные). Picker self-contained: монтируется/уничтожается с диалогом, invalidateSize 100ms после open чтобы Leaflet корректно посчитал container size после modal animation. --- ordinis-admin-ui/package.json | 5 + ordinis-admin-ui/pnpm-lock.yaml | 71 ++++++ ordinis-admin-ui/src/api/queries.ts | 33 ++- .../src/components/geo/AoiPickerDialog.tsx | 236 ++++++++++++++++++ ordinis-admin-ui/src/i18n.ts | 20 ++ .../src/routes/dictionaries.$name.tsx | 48 +++- 6 files changed, 405 insertions(+), 8 deletions(-) create mode 100644 ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx diff --git a/ordinis-admin-ui/package.json b/ordinis-admin-ui/package.json index 3e01fab..996fb80 100644 --- a/ordinis-admin-ui/package.json +++ b/ordinis-admin-ui/package.json @@ -21,11 +21,14 @@ "axios": "^1.7.9", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.0.2", + "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", "oidc-client-ts": "^3.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-i18next": "^17.0.2", + "react-leaflet": "^5.0.0", "react-oidc-context": "^3.3.1" }, "devDependencies": { @@ -34,6 +37,8 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/leaflet": "^1.9.21", + "@types/leaflet-draw": "^1.0.13", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/ordinis-admin-ui/pnpm-lock.yaml b/ordinis-admin-ui/pnpm-lock.yaml index 0737b31..cfcae11 100644 --- a/ordinis-admin-ui/pnpm-lock.yaml +++ b/ordinis-admin-ui/pnpm-lock.yaml @@ -38,6 +38,12 @@ importers: i18next-browser-languagedetector: specifier: ^8.0.2 version: 8.2.1 + leaflet: + specifier: ^1.9.4 + version: 1.9.4 + leaflet-draw: + specifier: ^1.0.4 + version: 1.0.4 oidc-client-ts: specifier: ^3.5.0 version: 3.5.0 @@ -53,6 +59,9 @@ importers: react-i18next: specifier: ^17.0.2 version: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + react-leaflet: + specifier: ^5.0.0 + version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-oidc-context: specifier: ^3.3.1 version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.5) @@ -72,6 +81,12 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/leaflet': + specifier: ^1.9.21 + version: 1.9.21 + '@types/leaflet-draw': + specifier: ^1.0.13 + version: 1.0.13 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -596,6 +611,13 @@ packages: react: '>= 16.8' react-dom: '>= 16.8' + '@react-leaflet/core@3.0.0': + resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==} + peerDependencies: + leaflet: ^1.9.0 + react: ^19.0.0 + react-dom: ^19.0.0 + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -950,6 +972,15 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/leaflet-draw@1.0.13': + resolution: {integrity: sha512-YU82kilOaU+wPNbqKCCDfHH3hqepN6XilrBwG/mSeZ/z4ewumaRCOah44s3FMxSu/Aa0SVa3PPJvhIZDUA09mw==} + + '@types/leaflet@1.9.21': + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1360,6 +1391,12 @@ packages: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} + leaflet-draw@1.0.4: + resolution: {integrity: sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==} + + leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1573,6 +1610,13 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-leaflet@5.0.0: + resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==} + peerDependencies: + leaflet: ^1.9.0 + react: ^19.0.0 + react-dom: ^19.0.0 + react-oidc-context@3.3.1: resolution: {integrity: sha512-/Azvm9W4DhhOtSDBE73kFInh1b6zZRRfILKbgmk2syExMF0PCYJOn/dGdOOi2BFX8x0rCeUe45NXHU+/+xDcrQ==} engines: {node: '>=18'} @@ -2230,6 +2274,12 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + leaflet: 1.9.4 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.60.2': @@ -2529,6 +2579,16 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + + '@types/leaflet-draw@1.0.13': + dependencies: + '@types/leaflet': 1.9.21 + + '@types/leaflet@1.9.21': + dependencies: + '@types/geojson': 7946.0.16 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -2974,6 +3034,10 @@ snapshots: jwt-decode@4.0.0: {} + leaflet-draw@1.0.4: {} + + leaflet@1.9.4: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -3125,6 +3189,13 @@ snapshots: react-is@17.0.2: {} + react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + leaflet: 1.9.4 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-oidc-context@3.3.1(oidc-client-ts@3.5.0)(react@19.2.5): dependencies: oidc-client-ts: 3.5.0 diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 74d0dca..835897a 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -30,13 +30,33 @@ export const dictionaryDetailQuery = (name: string) => }, }) -export const recordsQuery = (dictionaryName: string, scopeCsv: string) => +export type RecordsFilter = { + /** CSV bbox: west,south,east,north */ + bbox?: string + /** GeoJSON Polygon as raw object — serialized as JSON в query string. */ + polygon?: GeoJSON.Polygon +} + +export const recordsQuery = ( + dictionaryName: string, + scopeCsv: string, + filter?: RecordsFilter, +) => queryOptions({ - queryKey: ['records', dictionaryName, scopeCsv] as const, + queryKey: [ + 'records', + dictionaryName, + scopeCsv, + filter?.bbox ?? null, + filter?.polygon ? JSON.stringify(filter.polygon) : null, + ] as const, queryFn: async (): Promise => { + const params: Record = { as_scope: scopeCsv } + if (filter?.bbox) params.bbox = filter.bbox + if (filter?.polygon) params.polygon = JSON.stringify(filter.polygon) const { data } = await apiClient.get( `/${dictionaryName}/records`, - { params: { as_scope: scopeCsv } }, + { params }, ) return data }, @@ -235,8 +255,11 @@ export const useWebhookDlq = (page: number, size: number) => export const useDictionaries = () => useQuery(dictionariesQuery) export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name)) -export const useRecords = (dictionaryName: string, scopeCsv: string) => - useQuery(recordsQuery(dictionaryName, scopeCsv)) +export const useRecords = ( + dictionaryName: string, + scopeCsv: string, + filter?: RecordsFilter, +) => useQuery(recordsQuery(dictionaryName, scopeCsv, filter)) export const useRecordRaw = ( dictionaryName: string, businessKey: string | undefined, diff --git a/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx b/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx new file mode 100644 index 0000000..d78a2d7 --- /dev/null +++ b/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx @@ -0,0 +1,236 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Alert, Button, FormActions, Modal } from '@nstart/ui' +import L from 'leaflet' +import 'leaflet/dist/leaflet.css' +import 'leaflet-draw' +import 'leaflet-draw/dist/leaflet.draw.css' + +/** + * Результат AOI picker'а: + * - bbox: `west,south,east,north` (CSV для ?bbox= query) + * - polygon: GeoJSON Polygon (для ?polygon= query) + * + * Match'ит API ordinis-rest-api: `BoundingBox.parse` для bbox CSV, + * `GeoJsonPolygon.parse` для polygon JSON. Server validates lon/lat + * range, anti-meridian, polygon ring closure. + */ +export type AoiResult = + | { kind: 'bbox'; bboxCsv: string; geojson: GeoJSON.Polygon } + | { kind: 'polygon'; bboxCsv: string; geojson: GeoJSON.Polygon } + +type Props = { + isOpen: boolean + onClose: () => void + onApply: (result: AoiResult) => void + /** Optional initial geometry to render and edit. */ + initial?: GeoJSON.Polygon | null +} + +/** + * AOI picker на Leaflet с draw controls (rectangle + polygon). + * + * Admin рисует bbox или произвольный полигон на OSM tile'ах → + * onApply получает CSV bbox и GeoJSON polygon одновременно. Caller + * выбирает что использовать для filter (bbox дешевле для server, + * polygon точнее). + * + *

Map монтируется только когда isOpen=true чтобы Leaflet + * корректно посчитал размеры контейнера (иначе серый экран до + * первого resize). + */ +export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) => { + const { t } = useTranslation() + const mapDivRef = useRef(null) + const mapRef = useRef(null) + const drawnRef = useRef(null) + const [shape, setShape] = useState(null) + const [error, setError] = useState(null) + + // Init map после mount + isOpen=true. cleanup на unmount. + useEffect(() => { + if (!isOpen) return + const div = mapDivRef.current + if (!div || mapRef.current) return + + // Россия по умолчанию: центр ~Урал, zoom видит всю страну. + const map = L.map(div, { zoomControl: true }).setView([60, 90], 3) + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 18, + }).addTo(map) + + const drawn = new L.FeatureGroup().addTo(map) + drawnRef.current = drawn + + // Initial geometry — отрисовать и зум. + if (initial && initial.coordinates?.[0]?.length >= 4) { + const ring = initial.coordinates[0].map(([lng, lat]) => L.latLng(lat, lng)) + const poly = L.polygon(ring, { color: '#1d4ed8', weight: 2 }) + drawn.addLayer(poly) + setShape(poly) + map.fitBounds(poly.getBounds(), { padding: [20, 20] }) + } + + // Draw controls: только rectangle + polygon. Marker / circle / + // circlemarker отключены — backend ждёт Polygon. + const drawControl = new L.Control.Draw({ + draw: { + rectangle: { shapeOptions: { color: '#1d4ed8', weight: 2 } }, + polygon: { + shapeOptions: { color: '#1d4ed8', weight: 2 }, + allowIntersection: false, + showArea: false, + }, + marker: false, + circle: false, + circlemarker: false, + polyline: false, + }, + edit: { + featureGroup: drawn, + edit: { selectedPathOptions: { color: '#dc2626' } }, + remove: true, + }, + }) + map.addControl(drawControl) + + // Created shape — заменяем предыдущую (max 1 AOI на picker). + map.on(L.Draw.Event.CREATED, (e) => { + const event = e as L.LeafletEvent & { layerType: string; layer: L.Layer } + drawn.clearLayers() + drawn.addLayer(event.layer) + setShape(event.layer) + setError(null) + }) + + map.on(L.Draw.Event.DELETED, () => { + setShape(null) + }) + + map.on(L.Draw.Event.EDITED, () => { + const layers = drawn.getLayers() + setShape(layers[0] ?? null) + }) + + mapRef.current = map + + // Forces map to recompute size: modal animations cause containerSize=0 + // первой кадр. После 100ms layout settled. + const t = setTimeout(() => map.invalidateSize(), 100) + return () => { + clearTimeout(t) + } + }, [isOpen, initial]) + + // Cleanup при close: уничтожить map чтобы re-open был чистый старт. + useEffect(() => { + if (isOpen) return + if (mapRef.current) { + mapRef.current.remove() + mapRef.current = null + drawnRef.current = null + setShape(null) + setError(null) + } + }, [isOpen]) + + const handleApply = () => { + if (!shape) { + setError(t('aoi.error.noShape')) + return + } + const result = layerToResult(shape) + if (!result) { + setError(t('aoi.error.invalid')) + return + } + onApply(result) + onClose() + } + + return ( + +

{t('aoi.dialog.help')}

+ +
+ + {error && ( + + {error} + + )} + + + + + + + ) +} + +/** + * Конвертирует Leaflet layer (rectangle / polygon) в bbox CSV + + * GeoJSON Polygon. Возвращает null если geometry invalid. + * + * Для rectangle — bbox-режим (server использует ST_MakeEnvelope, + * быстрее polygon ST_Intersects), но GeoJSON тоже даём для + * обратной совместимости / preview. + */ +const layerToResult = (layer: L.Layer): AoiResult | null => { + if (layer instanceof L.Rectangle) { + const b = layer.getBounds() + const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}` + return { kind: 'bbox', bboxCsv: bbox, geojson: rectangleToGeoJSON(b) } + } + if (layer instanceof L.Polygon) { + const latlngsRaw = layer.getLatLngs() + // Polygon.getLatLngs() returns LatLng[][] для simple polygon. + const ring = (Array.isArray(latlngsRaw[0]) ? latlngsRaw[0] : latlngsRaw) as L.LatLng[] + if (!ring || ring.length < 3) return null + const closed = [...ring, ring[0]] + const coords = closed.map((p) => [p.lng, p.lat] as [number, number]) + const b = layer.getBounds() + const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}` + return { + kind: 'polygon', + bboxCsv: bbox, + geojson: { type: 'Polygon', coordinates: [coords] }, + } + } + return null +} + +const rectangleToGeoJSON = (b: L.LatLngBounds): GeoJSON.Polygon => { + const w = b.getWest() + const s = b.getSouth() + const e = b.getEast() + const n = b.getNorth() + return { + type: 'Polygon', + coordinates: [ + [ + [w, s], + [e, s], + [e, n], + [w, n], + [w, s], + ], + ], + } +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 10247e0..e3cec77 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -235,6 +235,16 @@ i18n 'error.failed': 'Не удалось загрузить данные', 'auth.login': 'Войти', 'auth.logout': 'Выйти', + 'aoi.button': 'AOI фильтр', + 'aoi.activeBbox': 'AOI: bbox {{value}}', + 'aoi.activePolygon': 'AOI: полигон ({{points}} точек)', + 'aoi.clear': 'Сбросить AOI', + 'aoi.dialog.title': 'Выбор области интереса (AOI)', + 'aoi.dialog.help': 'Нарисуйте прямоугольник или полигон. Применяется как фильтр к записям со spatial geometry.', + 'aoi.dialog.apply': 'Применить', + 'aoi.dialog.mapAriaLabel': 'Карта для выбора области интереса', + 'aoi.error.noShape': 'Сначала нарисуйте область на карте', + 'aoi.error.invalid': 'Геометрия некорректна — попробуйте перерисовать', }, }, 'en-US': { @@ -460,6 +470,16 @@ i18n 'error.failed': 'Failed to load data', 'auth.login': 'Sign in', 'auth.logout': 'Sign out', + 'aoi.button': 'AOI filter', + 'aoi.activeBbox': 'AOI: bbox {{value}}', + 'aoi.activePolygon': 'AOI: polygon ({{points}} points)', + 'aoi.clear': 'Clear AOI', + 'aoi.dialog.title': 'Select Area of Interest (AOI)', + 'aoi.dialog.help': 'Draw a rectangle or polygon. Applied as filter to records with spatial geometry.', + 'aoi.dialog.apply': 'Apply', + 'aoi.dialog.mapAriaLabel': 'Map for selecting area of interest', + 'aoi.error.noShape': 'Draw an area on the map first', + 'aoi.error.invalid': 'Invalid geometry — please redraw', }, }, }, diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 380a62b..d93ad97 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -20,13 +20,14 @@ import { TableRow, TextInput, } from '@nstart/ui' -import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon } from '@phosphor-icons/react' -import { useDictionaryDetail, useRecordRaw, useRecords } from '@/api/queries' +import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon } from '@phosphor-icons/react' +import { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries' import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations' import type { CreateRecordRequest, FlattenedRecord } from '@/api/client' import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' +import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog' import { nowIsoLocal } from '@/lib/dates' import { SCOPE_BORDER_TOP, SCOPE_DOT } from '@/lib/scope-style' @@ -44,7 +45,13 @@ function DictionaryDetail() { const { name } = Route.useParams() const { t } = useTranslation() const detailQuery = useDictionaryDetail(name) - const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED') + const [aoi, setAoi] = useState(null) + const filter: RecordsFilter | undefined = aoi + ? aoi.kind === 'bbox' + ? { bbox: aoi.bboxCsv } + : { polygon: aoi.geojson } + : undefined + const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter) const createMut = useCreateRecord(name) const updateMut = useUpdateRecord(name) const closeMut = useCloseRecord(name) @@ -53,6 +60,7 @@ function DictionaryDetail() { const [closeReason, setCloseReason] = useState('') const [schemaEditOpen, setSchemaEditOpen] = useState(false) const [historyKey, setHistoryKey] = useState(undefined) + const [aoiOpen, setAoiOpen] = useState(false) const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined const rawRecordQuery = useRecordRaw(name, editingKey) @@ -124,6 +132,14 @@ function DictionaryDetail() { } actions={
+ +
+ )} + {recordsResult.isLoading && } {recordsResult.error && ( @@ -340,6 +375,13 @@ function DictionaryDetail() { dictionaryName={name} businessKey={historyKey} /> + + setAoiOpen(false)} + onApply={(result) => setAoi(result)} + initial={aoi?.geojson ?? null} + />
) }