diff --git a/CHANGELOG.md b/CHANGELOG.md index 29bfe7ad..457dc1e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ # Changelog +## Sprint 28 dataset workflow hook hardening (2026-06-17) + +- Moved dataset selection, upload, detail loading, dataset jobs and raster/vector operation orchestration from `App.tsx` into `useDatasetWorkflow`. +- Kept project dataset listing in `App.tsx` so project/area loading remains the shared workbench boundary. +- Added regression coverage to verify `App.tsx` still wires dataset, raster and vector UI callbacks while operation API ownership stays inside the focused hook. +- No API contracts, backend behavior, migrations, product features, provider fetching, AI behavior or UI redesign were introduced. + ## Sprint 27 export and QA workflow hook hardening (2026-06-17) - Moved Export Center orchestration state and API calls from `App.tsx` into `useExportWorkflow`. diff --git a/backend/tests/test_sprint28_dataset_workflow_hook.py b/backend/tests/test_sprint28_dataset_workflow_hook.py new file mode 100644 index 00000000..c857c928 --- /dev/null +++ b/backend/tests/test_sprint28_dataset_workflow_hook.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_app_uses_dataset_workflow_hook() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert "useDatasetWorkflow" in app + assert "from './hooks/useDatasetWorkflow'" in app + assert "datasetsApi.upload" not in app + assert "jobsApi" not in app + assert "datasetsApi.vectorClip" not in app + assert "datasetsApi.rasterTile" not in app + + +def test_dataset_workflow_hook_owns_dataset_api_calls() -> None: + hook = (ROOT / "frontend" / "src" / "hooks" / "useDatasetWorkflow.ts").read_text(encoding="utf-8") + + assert "datasetsApi.upload" in hook + assert "datasetsApi.getContent" in hook + assert "datasetsApi.vectorSummary" in hook + assert "datasetsApi.vectorClip" in hook + assert "datasetsApi.vectorBuffer" in hook + assert "datasetsApi.vectorIntersect" in hook + assert "datasetsApi.inspectRaster" in hook + assert "datasetsApi.rasterPreview" in hook + assert "datasetsApi.rasterStats" in hook + assert "datasetsApi.rasterReproject" in hook + assert "datasetsApi.rasterClip" in hook + assert "datasetsApi.rasterTile" in hook + assert "datasetsApi.rasterNdvi" in hook + assert "datasetsApi.rasterNdwi" in hook + assert "datasetsApi.rasterNdbi" in hook + assert "datasetsApi.refreshMetadata" in hook + assert "jobsApi.list" in hook + + +def test_app_still_wires_dataset_ui_callbacks() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert "
loadDatasetDetails(selectedProjectId ?? '', dataset)}" in app + assert "onClick={() => refreshMetadata(dataset.id)}" in app + assert "onClick={runRasterInspect}" in app + assert "onClick={runRasterPreview}" in app + assert "onClick={runRasterStats}" in app + assert "onClick={runRasterReproject}" in app + assert "onClick={runRasterClip}" in app + assert "onClick={runRasterTile}" in app + assert "onClick={runRasterNdvi}" in app + assert "onClick={runRasterNdwi}" in app + assert "onClick={runRasterNdbi}" in app + assert "onClick={runVectorClip}" in app + assert "onClick={runVectorBuffer}" in app + assert "onClick={() => runVectorIntersect(availableVectorTargets)}" in app diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index c347a363..ae29ca2a 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,26 @@ +## Sprint 28 dataset workflow hook hardening (2026-06-17) + +Changed: +- Moved dataset selection, upload form state, dataset detail loading, dataset jobs and raster/vector operation orchestration from `frontend/src/App.tsx` into `frontend/src/hooks/useDatasetWorkflow.ts`. +- Kept project dataset listing in `App.tsx` so project/area loading remains the shared workbench boundary. +- Added regression tests to verify App uses the dataset workflow hook and still wires dataset, raster and vector callbacks. +- Updated frontend README, TODO and changelog docs. + +Validation: +- `python -m pytest backend/tests/test_sprint28_dataset_workflow_hook.py backend/tests/test_sprint27_frontend_workflow_hooks.py backend/tests/test_sprint26_frontend_workflow_hooks.py` passed: 11 tests. +- `python -m compileall backend/app` passed. +- `cd backend && python -m pytest` passed: 167 tests. +- `cd frontend && npm run typecheck` passed. +- `cd frontend && npm run build` passed. +- `bash scripts/run_readiness_check.sh` passed: 167 backend tests, frontend typecheck/build, Alembic head check and script syntax checks. +- `cd backend && python -m alembic heads` passed: single head `202606120900`. +- `cd backend && python -m alembic upgrade head --sql` passed. +- `bash -n scripts/live_migration_smoke.sh` passed. + +Notes: +- No API contracts, backend behavior, migrations, product features, provider fetching, AI behavior or UI redesign changed. +- Next maintainability pass should split dataset detail, raster controls and vector controls into presentational components fed by the hook state. + ## Sprint 27 export and QA workflow hook hardening (2026-06-17) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index a0226707..d2b952eb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -38,7 +38,8 @@ This file now starts with the current implementation status. Older preparation/b - [x] Real YOLO compatibility smoke with optional AI extras and local model file. - [x] Detection and segmentation workflow hook extraction beyond Sprint 10. - [x] Export and QA/QC workflow hook extraction beyond Sprint 10. -- [ ] Further frontend state/module decomposition for datasets and raster/vector operations. +- [x] Dataset, raster and vector workflow hook extraction beyond Sprint 10. +- [ ] Further frontend component decomposition for dataset detail, raster controls and vector controls. ## Sprint 8 status diff --git a/frontend/README.md b/frontend/README.md index 25dcfa3b..74e4eb09 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -184,6 +184,12 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow. - Detection and segmentation QA continue to share the same persisted QA/QC refresh path through `loadQualityChecks`. - Export Center and QA/QC UI behavior is unchanged; `App.tsx` still coordinates selected project, dataset and analysis-run state. +## Sprint 28 maintainability updates + +- Dataset selection, upload form state, detail loading, dataset jobs and raster/vector operation orchestration moved from `src/App.tsx` into `src/hooks/useDatasetWorkflow.ts`. +- Project-scoped dataset listing remains in `App.tsx` because it is still part of the shared project/area load boundary. +- Dataset details, raster controls, vector controls and job list behavior are unchanged; the UI still receives the same callbacks and state. + ## Release hardening updates - Production builds split application code, React vendor code and MapLibre vendor code into separate chunks. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bef64d0c..abcc5645 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import GeoMap from './components/GeoMap' import { areasApi } from './services/api/areas' import { datasetsApi } from './services/api/datasets' import { projectsApi } from './services/api/projects' -import { analysisApi, demoApi, jobsApi, externalApi } from './services/api' +import { analysisApi, demoApi, externalApi } from './services/api' import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel' import { DetectionLab } from './components/detection/DetectionLab' import { ExportCenter } from './components/exports/ExportCenter' @@ -16,12 +16,6 @@ import type { ChangeDetectionSummary, DatasetCreateResponse, DatasetListResponse, - JobRead, - RasterMetadataResponse, - RasterInspectResponse, - RasterStatsResponse, - RasterPreviewResponse, - VectorSummary, ProjectRead, ProjectCreate, AreaCreate, @@ -32,6 +26,7 @@ import type { import { ProviderPanel } from './components/providers/ProviderPanel' import { SegmentationLab } from './components/segmentation/SegmentationLab' import { useDetectionWorkflow } from './hooks/useDetectionWorkflow' +import { useDatasetWorkflow } from './hooks/useDatasetWorkflow' import { useExportWorkflow } from './hooks/useExportWorkflow' import { useQualityWorkflow } from './hooks/useQualityWorkflow' import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow' @@ -71,19 +66,12 @@ function App(): JSX.Element { const [selectedProjectId, setSelectedProjectId] = useState(null) const [areas, setAreas] = useState([]) const [datasets, setDatasets] = useState([]) - const [selectedDatasetId, setSelectedDatasetId] = useState(null) - const [selectedDataset, setSelectedDataset] = useState(null) - const [selectedDatasetSummary, setSelectedDatasetSummary] = useState(null) - const [selectedRasterMetadata, setSelectedRasterMetadata] = useState(null) - const [selectedRasterStats, setSelectedRasterStats] = useState(null) - const [datasetContent, setDatasetContent] = useState(null) const [mapLayerVisible, setMapLayerVisible] = useState(true) const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4) const [selectedMapAreaId, setSelectedMapAreaId] = useState('') const [areaLayerVisible, setAreaLayerVisible] = useState(true) const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18) const [selectedMapFeature, setSelectedMapFeature] = useState(null) - const [jobs, setJobs] = useState([]) const [providerCapabilities, setProviderCapabilities] = useState([]) const [loadingCapabilities, setLoadingCapabilities] = useState(false) const [capabilitiesError, setCapabilitiesError] = useState(null) @@ -94,29 +82,12 @@ function App(): JSX.Element { const [runningChangeDetection, setRunningChangeDetection] = useState(false) const [changeDetectionResult, setChangeDetectionResult] = useState(null) const [changeDetectionError, setChangeDetectionError] = useState(null) - const [rasterPreview, setRasterPreview] = useState(null) - const [selectedIntersectTargetId, setSelectedIntersectTargetId] = useState('') - const [selectedClipAreaId, setSelectedClipAreaId] = useState('') - const [rasterTileSize, setRasterTileSize] = useState(512) - const [rasterTileOverlap, setRasterTileOverlap] = useState(64) - const [rasterTileOutputName, setRasterTileOutputName] = useState('') - const [rasterReprojectCrs, setRasterReprojectCrs] = useState('EPSG:31370') - const [rasterReprojectResampling, setRasterReprojectResampling] = useState('nearest') - const [ndviNirBand, setNdviNirBand] = useState(4) - const [ndviRedBand, setNdviRedBand] = useState(3) - const [ndwiGreenBand, setNdwiGreenBand] = useState(2) - const [ndwiNirBand, setNdwiNirBand] = useState(4) - const [ndbiSwirBand, setNdbiSwirBand] = useState(5) - const [ndbiNirBand, setNdbiNirBand] = useState(4) - const [loadingProjects, setLoadingProjects] = useState(false) const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false) const [demoWorkflowMessage, setDemoWorkflowMessage] = useState(null) const [loadingAreas, setLoadingAreas] = useState(false) const [loadingDatasets, setLoadingDatasets] = useState(false) - const [loadingDatasetDetails, setLoadingDatasetDetails] = useState(false) const [errorMessage, setErrorMessage] = useState(null) - const [datasetDetailError, setDatasetDetailError] = useState(null) const [projectForm, setProjectForm] = useState({ name: '', @@ -129,22 +100,76 @@ function App(): JSX.Element { '{"type":"MultiPolygon","coordinates":[[[[4.35,51.28],[4.55,51.28],[4.55,51.46],[4.35,51.46],[4.35,51.28]]]]}', crs: 'EPSG:4326', }) - const [datasetForm, setDatasetForm] = useState({ - datasetType: 'vector', - source: 'user_upload', - datasetRole: 'source', - sourceName: '', - referenceLayerName: '', - sourceMetadataJson: '', - provenanceMetadataJson: '', - areaId: '', - file: null as File | null, - }) - const selectedProject = useMemo( () => projects.find((project) => project.id === selectedProjectId) ?? null, [projects, selectedProjectId], ) + const { + selectedDatasetId, + selectedDataset, + selectedDatasetSummary, + selectedRasterMetadata, + selectedRasterStats, + datasetContent, + jobs, + rasterPreview, + selectedIntersectTargetId, + selectedClipAreaId, + rasterTileSize, + rasterTileOverlap, + rasterTileOutputName, + rasterReprojectCrs, + rasterReprojectResampling, + ndviNirBand, + ndviRedBand, + ndwiGreenBand, + ndwiNirBand, + ndbiSwirBand, + ndbiNirBand, + loadingDatasetDetails, + datasetDetailError, + datasetForm, + isRasterTileInputValid, + loadDatasetJobs, + loadDatasetDetails, + uploadDataset, + runVectorClip, + runVectorBuffer, + runVectorIntersect, + runRasterInspect, + runRasterPreview, + runRasterStats, + runRasterReproject, + runRasterClip, + runRasterTile, + runRasterNdvi, + runRasterNdwi, + runRasterNdbi, + pickDerivedDataset, + refreshMetadata, + resetDatasetForProject, + setSelectedDatasetId, + setSelectedIntersectTargetId, + setSelectedClipAreaId, + setRasterTileSize, + setRasterTileOverlap, + setRasterTileOutputName, + setRasterReprojectCrs, + setRasterReprojectResampling, + setNdviNirBand, + setNdviRedBand, + setNdwiGreenBand, + setNdwiNirBand, + setNdbiSwirBand, + setNdbiNirBand, + setDatasetForm, + } = useDatasetWorkflow({ + selectedProjectId, + datasets, + loadProjectData, + setErrorMessage, + isVectorDatasetType, + }) const rasterUnavailableMessage = useMemo(() => { if (!selectedDataset || selectedDataset.dataset_type !== 'raster' || !selectedDataset.metadata_json) { return null @@ -343,18 +368,6 @@ function App(): JSX.Element { }, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset]) const mapFeatureCount = mapFeatureCollection?.features.length ?? 0 const areaFeatureCount = areaFeatureCollection?.features.length ?? 0 - const isRasterTileInputValid = useMemo( - () => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize, - [rasterTileSize, rasterTileOverlap], - ) - - const toRasterMetadata = (metadata: Record | null | undefined): RasterMetadataResponse | null => { - if (!metadata) { - return null - } - return metadata as unknown as RasterMetadataResponse - } - const formatRasterBounds = (bounds: number[] | undefined | null): string => { if (!bounds || bounds.length < 4) { return 'n/a' @@ -423,47 +436,6 @@ function App(): JSX.Element { } } - const loadDatasetJobs = async (projectId: string, datasetId: string) => { - const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 }) - setJobs(response.items) - } - - const loadDatasetDetails = async (projectId: string, dataset: DatasetCreateResponse) => { - setLoadingDatasetDetails(true) - setDatasetDetailError(null) - setSelectedDataset(dataset) - setSelectedDatasetSummary(null) - setSelectedRasterMetadata(null) - setSelectedRasterStats(null) - setDatasetContent(null) - setRasterPreview(null) - setSelectedDatasetId(dataset.id) - setJobs([]) - try { - if (isVectorDatasetType(dataset.dataset_type)) { - const [content, summary] = await Promise.all([ - datasetsApi.getContent(projectId, dataset.id), - datasetsApi.vectorSummary(projectId, dataset.id), - ]) - setDatasetContent(content) - setSelectedDatasetSummary(summary) - } else if (dataset.dataset_type === 'raster') { - try { - const rasterInspection = await datasetsApi.rasterInspect(projectId, dataset.id) - setSelectedRasterMetadata(toRasterMetadata(rasterInspection.metadata)) - } catch (error) { - setSelectedRasterMetadata(null) - setDatasetDetailError(formatError(error, 'Raster metadata unavailable')) - } - } - await loadDatasetJobs(projectId, dataset.id) - } catch (error) { - setDatasetDetailError(formatError(error, 'Unable to load dataset detail')) - } finally { - setLoadingDatasetDetails(false) - } - } - useEffect(() => { loadProjects().catch(() => null) loadCapabilities().catch(() => null) @@ -479,13 +451,8 @@ function App(): JSX.Element { if (!selectedProjectId) { setAreas([]) setDatasets([]) - setSelectedDatasetId(null) - setSelectedDataset(null) + resetDatasetForProject() setSelectedMapAreaId('') - setSelectedDatasetSummary(null) - setSelectedRasterMetadata(null) - setSelectedRasterStats(null) - setJobs([]) resetDetectionForProject() resetSegmentationForProject() resetExportsForProject() @@ -586,305 +553,6 @@ function App(): JSX.Element { } } - const uploadDataset = async (event: FormEvent) => { - event.preventDefault() - if (!selectedProjectId || !datasetForm.file) { - setErrorMessage('Select project and upload a file') - return - } - if (!datasetForm.datasetRole) { - setErrorMessage('Select dataset role') - return - } - if (datasetForm.datasetRole === 'reference' && datasetForm.datasetType !== 'vector' && datasetForm.datasetType !== 'geojson') { - setErrorMessage('Reference role requires vector dataset upload') - return - } - if (datasetForm.sourceMetadataJson) { - try { - const parsedSourceMetadata = JSON.parse(datasetForm.sourceMetadataJson) - if (parsedSourceMetadata === null || typeof parsedSourceMetadata !== 'object') { - setErrorMessage('Source metadata must be a JSON object') - return - } - } catch { - setErrorMessage('Source metadata must be valid JSON') - return - } - } - if (datasetForm.provenanceMetadataJson) { - try { - const parsedProvenanceMetadata = JSON.parse(datasetForm.provenanceMetadataJson) - if (parsedProvenanceMetadata === null || typeof parsedProvenanceMetadata !== 'object') { - setErrorMessage('Provenance metadata must be a JSON object') - return - } - } catch { - setErrorMessage('Provenance metadata must be valid JSON') - return - } - } - try { - await datasetsApi.upload(selectedProjectId, { - file: datasetForm.file, - datasetType: datasetForm.datasetType, - source: datasetForm.source, - datasetRole: datasetForm.datasetRole, - sourceName: datasetForm.sourceName || undefined, - referenceLayerName: datasetForm.referenceLayerName || undefined, - sourceMetadataJson: datasetForm.sourceMetadataJson || undefined, - provenanceMetadataJson: datasetForm.provenanceMetadataJson || undefined, - areaId: datasetForm.areaId || undefined, - }) - setDatasetForm((previous) => ({ ...previous, file: null })) - await loadProjectData(selectedProjectId) - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : 'Failed to upload dataset') - } - } - - const runVectorClip = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - if (!selectedClipAreaId) { - setDatasetDetailError('Select an area for clipping') - return - } - setDatasetDetailError(null) - try { - await datasetsApi.vectorClip(selectedProjectId, selectedDatasetId, { - area_id: selectedClipAreaId, - output_name: `${selectedDataset?.name.replace(/\.geojson$/, '')}-clipped`, - }) - await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) - const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) - if (refreshed && selectedProjectId) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) - } - } catch (error) { - setDatasetDetailError(formatError(error, 'Vector clip failed')) - } - } - - const runVectorBuffer = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - setDatasetDetailError(null) - try { - await datasetsApi.vectorBuffer(selectedProjectId, selectedDatasetId, { - distance_m: 25, - dissolve: false, - }) - await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) - const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) - if (refreshed && selectedProjectId) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) - } - } catch (error) { - setDatasetDetailError(formatError(error, 'Vector buffer failed')) - } - } - - const runVectorIntersect = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - const targetId = selectedIntersectTargetId || availableVectorTargets[0]?.id - if (!targetId) { - setDatasetDetailError('Select an intersect target dataset') - return - } - setDatasetDetailError(null) - try { - await datasetsApi.vectorIntersect(selectedProjectId, selectedDatasetId, { - other_dataset_id: targetId, - }) - await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) - const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) - if (refreshed && selectedProjectId) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) - } - } catch (error) { - setDatasetDetailError(formatError(error, 'Vector intersect failed')) - } - } - - const runRasterInspect = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - try { - const response = await datasetsApi.inspectRaster(selectedProjectId, selectedDatasetId) - setSelectedRasterMetadata(toRasterMetadata(response.metadata)) - setDatasetDetailError(null) - } catch (error) { - setDatasetDetailError(formatError(error, 'Raster inspect unavailable')) - } - } - - const runRasterPreview = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - try { - const response = await datasetsApi.rasterPreview(selectedProjectId, selectedDatasetId) - setRasterPreview(response) - if (response.metadata) { - setSelectedRasterMetadata(toRasterMetadata(response.metadata)) - } - setDatasetDetailError(null) - } catch (error) { - setDatasetDetailError(formatError(error, 'Raster preview unavailable')) - setRasterPreview(null) - } - } - - const runRasterStats = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - try { - const response = await datasetsApi.rasterStats(selectedProjectId, selectedDatasetId) - setSelectedRasterStats(response) - setDatasetDetailError(null) - } catch (error) { - setDatasetDetailError(formatError(error, 'Raster stats unavailable')) - setSelectedRasterStats(null) - } - } - - const runRasterReproject = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - const targetCrs = rasterReprojectCrs.trim() - if (!targetCrs) { - setDatasetDetailError('Target CRS is required for raster reproject') - return - } - try { - await datasetsApi.rasterReproject(selectedProjectId, selectedDatasetId, { - target_crs: targetCrs, - resampling: rasterReprojectResampling, - }) - await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) - const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) - if (refreshed && selectedProjectId) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) - } - setDatasetDetailError(null) - } catch (error) { - setDatasetDetailError(formatError(error, 'Raster reproject failed')) - } - } - - const runRasterClip = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - if (!selectedClipAreaId) { - setDatasetDetailError('Select an area for raster clip') - return - } - try { - await datasetsApi.rasterClip(selectedProjectId, selectedDatasetId, { - area_id: selectedClipAreaId, - output_name: `raster-clipped-${selectedDatasetId}`, - }) - await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) - const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) - if (refreshed && selectedProjectId) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) - } - setDatasetDetailError(null) - } catch (error) { - setDatasetDetailError(formatError(error, 'Raster clip failed')) - } - } - - const runRasterTile = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - try { - await datasetsApi.rasterTile(selectedProjectId, selectedDatasetId, { - tile_size: rasterTileSize, - overlap: rasterTileOverlap, - output_name: rasterTileOutputName || undefined, - }) - await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) - const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) - if (refreshed && selectedProjectId) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) - } - setDatasetDetailError(null) - } catch (error) { - setDatasetDetailError(formatError(error, 'Raster tile failed')) - } - } - - const runRasterNdvi = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - try { - await datasetsApi.rasterNdvi(selectedProjectId, selectedDatasetId, { - nir_band: ndviNirBand, - red_band: ndviRedBand, - }) - await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) - const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) - if (refreshed && selectedProjectId) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) - } - setDatasetDetailError(null) - } catch (error) { - setDatasetDetailError(formatError(error, 'Raster NDVI failed')) - } - } - - const runRasterNdwi = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - try { - await datasetsApi.rasterNdwi(selectedProjectId, selectedDatasetId, { - green_band: ndwiGreenBand, - nir_band: ndwiNirBand, - }) - await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) - const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) - if (refreshed && selectedProjectId) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) - } - setDatasetDetailError(null) - } catch (error) { - setDatasetDetailError(formatError(error, 'Raster NDWI failed')) - } - } - - const runRasterNdbi = async () => { - if (!selectedProjectId || !selectedDatasetId) { - return - } - try { - await datasetsApi.rasterNdbi(selectedProjectId, selectedDatasetId, { - swir_band: ndbiSwirBand, - nir_band: ndbiNirBand, - }) - await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) - const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) - if (refreshed && selectedProjectId) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) - } - setDatasetDetailError(null) - } catch (error) { - setDatasetDetailError(formatError(error, 'Raster NDBI failed')) - } - } - const runChangeDetection = async () => { const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id const targetDatasetId = @@ -930,30 +598,6 @@ function App(): JSX.Element { } } - const pickDerivedDataset = async (datasetId: string) => { - if (!selectedProjectId) { - return - } - const target = datasets.find((item) => item.id === datasetId) - if (target) { - await loadDatasetDetails(selectedProjectId, target) - } - } - - const refreshMetadata = async (datasetId: string) => { - if (!selectedProjectId) { - return - } - const refreshed = await datasetsApi.refreshMetadata(selectedProjectId, datasetId) - await loadProjectData(selectedProjectId) - if (selectedDataset?.id === datasetId) { - setSelectedDataset(refreshed) - if (isVectorDatasetType(refreshed.dataset_type)) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) - } - } - } - return (
@@ -1460,7 +1104,7 @@ function App(): JSX.Element { ))} -
diff --git a/frontend/src/hooks/useDatasetWorkflow.ts b/frontend/src/hooks/useDatasetWorkflow.ts new file mode 100644 index 00000000..eb46631e --- /dev/null +++ b/frontend/src/hooks/useDatasetWorkflow.ts @@ -0,0 +1,486 @@ +import { FormEvent, useMemo, useState } from 'react' +import { datasetsApi, jobsApi } from '../services/api' +import type { + DatasetCreateResponse, + JobRead, + RasterMetadataResponse, + RasterPreviewResponse, + RasterStatsResponse, + VectorSummary, +} from '../types' +import { formatError } from '../lib/formatError' + +interface DatasetWorkflowOptions { + selectedProjectId: string | null + datasets: DatasetCreateResponse[] + loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null> + setErrorMessage: (message: string | null) => void + isVectorDatasetType: (datasetType: string) => boolean +} + +function toRasterMetadata(metadata: Record | null | undefined): RasterMetadataResponse | null { + if (!metadata) { + return null + } + return metadata as unknown as RasterMetadataResponse +} + +export function useDatasetWorkflow({ + selectedProjectId, + datasets, + loadProjectData, + setErrorMessage, + isVectorDatasetType, +}: DatasetWorkflowOptions) { + const [selectedDatasetId, setSelectedDatasetId] = useState(null) + const [selectedDataset, setSelectedDataset] = useState(null) + const [selectedDatasetSummary, setSelectedDatasetSummary] = useState(null) + const [selectedRasterMetadata, setSelectedRasterMetadata] = useState(null) + const [selectedRasterStats, setSelectedRasterStats] = useState(null) + const [datasetContent, setDatasetContent] = useState(null) + const [jobs, setJobs] = useState([]) + const [rasterPreview, setRasterPreview] = useState(null) + const [selectedIntersectTargetId, setSelectedIntersectTargetId] = useState('') + const [selectedClipAreaId, setSelectedClipAreaId] = useState('') + const [rasterTileSize, setRasterTileSize] = useState(512) + const [rasterTileOverlap, setRasterTileOverlap] = useState(64) + const [rasterTileOutputName, setRasterTileOutputName] = useState('') + const [rasterReprojectCrs, setRasterReprojectCrs] = useState('EPSG:31370') + const [rasterReprojectResampling, setRasterReprojectResampling] = useState('nearest') + const [ndviNirBand, setNdviNirBand] = useState(4) + const [ndviRedBand, setNdviRedBand] = useState(3) + const [ndwiGreenBand, setNdwiGreenBand] = useState(2) + const [ndwiNirBand, setNdwiNirBand] = useState(4) + const [ndbiSwirBand, setNdbiSwirBand] = useState(5) + const [ndbiNirBand, setNdbiNirBand] = useState(4) + const [loadingDatasetDetails, setLoadingDatasetDetails] = useState(false) + const [datasetDetailError, setDatasetDetailError] = useState(null) + const [datasetForm, setDatasetForm] = useState({ + datasetType: 'vector', + source: 'user_upload', + datasetRole: 'source', + sourceName: '', + referenceLayerName: '', + sourceMetadataJson: '', + provenanceMetadataJson: '', + areaId: '', + file: null as File | null, + }) + + const isRasterTileInputValid = useMemo( + () => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize, + [rasterTileSize, rasterTileOverlap], + ) + + const loadDatasetJobs = async (projectId: string, datasetId: string) => { + const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 }) + setJobs(response.items) + } + + const loadDatasetDetails = async (projectId: string, dataset: DatasetCreateResponse) => { + setLoadingDatasetDetails(true) + setDatasetDetailError(null) + setSelectedDataset(dataset) + setSelectedDatasetSummary(null) + setSelectedRasterMetadata(null) + setSelectedRasterStats(null) + setDatasetContent(null) + setRasterPreview(null) + setSelectedDatasetId(dataset.id) + setJobs([]) + try { + if (isVectorDatasetType(dataset.dataset_type)) { + const [content, summary] = await Promise.all([ + datasetsApi.getContent(projectId, dataset.id), + datasetsApi.vectorSummary(projectId, dataset.id), + ]) + setDatasetContent(content) + setSelectedDatasetSummary(summary) + } else if (dataset.dataset_type === 'raster') { + try { + const rasterInspection = await datasetsApi.rasterInspect(projectId, dataset.id) + setSelectedRasterMetadata(toRasterMetadata(rasterInspection.metadata)) + } catch (error) { + setSelectedRasterMetadata(null) + setDatasetDetailError(formatError(error, 'Raster metadata unavailable')) + } + } + await loadDatasetJobs(projectId, dataset.id) + } catch (error) { + setDatasetDetailError(formatError(error, 'Unable to load dataset detail')) + } finally { + setLoadingDatasetDetails(false) + } + } + + const uploadDataset = async (event: FormEvent) => { + event.preventDefault() + if (!selectedProjectId || !datasetForm.file) { + setErrorMessage('Select project and upload a file') + return + } + if (!datasetForm.datasetRole) { + setErrorMessage('Select dataset role') + return + } + if (datasetForm.datasetRole === 'reference' && datasetForm.datasetType !== 'vector' && datasetForm.datasetType !== 'geojson') { + setErrorMessage('Reference role requires vector dataset upload') + return + } + if (datasetForm.sourceMetadataJson) { + try { + const parsedSourceMetadata = JSON.parse(datasetForm.sourceMetadataJson) + if (parsedSourceMetadata === null || typeof parsedSourceMetadata !== 'object') { + setErrorMessage('Source metadata must be a JSON object') + return + } + } catch { + setErrorMessage('Source metadata must be valid JSON') + return + } + } + if (datasetForm.provenanceMetadataJson) { + try { + const parsedProvenanceMetadata = JSON.parse(datasetForm.provenanceMetadataJson) + if (parsedProvenanceMetadata === null || typeof parsedProvenanceMetadata !== 'object') { + setErrorMessage('Provenance metadata must be a JSON object') + return + } + } catch { + setErrorMessage('Provenance metadata must be valid JSON') + return + } + } + try { + await datasetsApi.upload(selectedProjectId, { + file: datasetForm.file, + datasetType: datasetForm.datasetType, + source: datasetForm.source, + datasetRole: datasetForm.datasetRole, + sourceName: datasetForm.sourceName || undefined, + referenceLayerName: datasetForm.referenceLayerName || undefined, + sourceMetadataJson: datasetForm.sourceMetadataJson || undefined, + provenanceMetadataJson: datasetForm.provenanceMetadataJson || undefined, + areaId: datasetForm.areaId || undefined, + }) + setDatasetForm((previous) => ({ ...previous, file: null })) + await loadProjectData(selectedProjectId) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Failed to upload dataset') + } + } + + const refreshSelectedDatasetAfterJob = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) + const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) + if (refreshed) { + loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) + } + } + + const runVectorClip = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + if (!selectedClipAreaId) { + setDatasetDetailError('Select an area for clipping') + return + } + setDatasetDetailError(null) + try { + await datasetsApi.vectorClip(selectedProjectId, selectedDatasetId, { + area_id: selectedClipAreaId, + output_name: `${selectedDataset?.name.replace(/\.geojson$/, '')}-clipped`, + }) + await refreshSelectedDatasetAfterJob() + } catch (error) { + setDatasetDetailError(formatError(error, 'Vector clip failed')) + } + } + + const runVectorBuffer = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + setDatasetDetailError(null) + try { + await datasetsApi.vectorBuffer(selectedProjectId, selectedDatasetId, { + distance_m: 25, + dissolve: false, + }) + await refreshSelectedDatasetAfterJob() + } catch (error) { + setDatasetDetailError(formatError(error, 'Vector buffer failed')) + } + } + + const runVectorIntersect = async (availableVectorTargets: DatasetCreateResponse[]) => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + const targetId = selectedIntersectTargetId || availableVectorTargets[0]?.id + if (!targetId) { + setDatasetDetailError('Select an intersect target dataset') + return + } + setDatasetDetailError(null) + try { + await datasetsApi.vectorIntersect(selectedProjectId, selectedDatasetId, { + other_dataset_id: targetId, + }) + await refreshSelectedDatasetAfterJob() + } catch (error) { + setDatasetDetailError(formatError(error, 'Vector intersect failed')) + } + } + + const runRasterInspect = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + try { + const response = await datasetsApi.inspectRaster(selectedProjectId, selectedDatasetId) + setSelectedRasterMetadata(toRasterMetadata(response.metadata)) + setDatasetDetailError(null) + } catch (error) { + setDatasetDetailError(formatError(error, 'Raster inspect unavailable')) + } + } + + const runRasterPreview = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + try { + const response = await datasetsApi.rasterPreview(selectedProjectId, selectedDatasetId) + setRasterPreview(response) + if (response.metadata) { + setSelectedRasterMetadata(toRasterMetadata(response.metadata)) + } + setDatasetDetailError(null) + } catch (error) { + setDatasetDetailError(formatError(error, 'Raster preview unavailable')) + setRasterPreview(null) + } + } + + const runRasterStats = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + try { + const response = await datasetsApi.rasterStats(selectedProjectId, selectedDatasetId) + setSelectedRasterStats(response) + setDatasetDetailError(null) + } catch (error) { + setDatasetDetailError(formatError(error, 'Raster stats unavailable')) + setSelectedRasterStats(null) + } + } + + const runRasterReproject = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + const targetCrs = rasterReprojectCrs.trim() + if (!targetCrs) { + setDatasetDetailError('Target CRS is required for raster reproject') + return + } + try { + await datasetsApi.rasterReproject(selectedProjectId, selectedDatasetId, { + target_crs: targetCrs, + resampling: rasterReprojectResampling, + }) + await refreshSelectedDatasetAfterJob() + setDatasetDetailError(null) + } catch (error) { + setDatasetDetailError(formatError(error, 'Raster reproject failed')) + } + } + + const runRasterClip = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + if (!selectedClipAreaId) { + setDatasetDetailError('Select an area for raster clip') + return + } + try { + await datasetsApi.rasterClip(selectedProjectId, selectedDatasetId, { + area_id: selectedClipAreaId, + output_name: `raster-clipped-${selectedDatasetId}`, + }) + await refreshSelectedDatasetAfterJob() + setDatasetDetailError(null) + } catch (error) { + setDatasetDetailError(formatError(error, 'Raster clip failed')) + } + } + + const runRasterTile = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + try { + await datasetsApi.rasterTile(selectedProjectId, selectedDatasetId, { + tile_size: rasterTileSize, + overlap: rasterTileOverlap, + output_name: rasterTileOutputName || undefined, + }) + await refreshSelectedDatasetAfterJob() + setDatasetDetailError(null) + } catch (error) { + setDatasetDetailError(formatError(error, 'Raster tile failed')) + } + } + + const runRasterNdvi = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + try { + await datasetsApi.rasterNdvi(selectedProjectId, selectedDatasetId, { + nir_band: ndviNirBand, + red_band: ndviRedBand, + }) + await refreshSelectedDatasetAfterJob() + setDatasetDetailError(null) + } catch (error) { + setDatasetDetailError(formatError(error, 'Raster NDVI failed')) + } + } + + const runRasterNdwi = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + try { + await datasetsApi.rasterNdwi(selectedProjectId, selectedDatasetId, { + green_band: ndwiGreenBand, + nir_band: ndwiNirBand, + }) + await refreshSelectedDatasetAfterJob() + setDatasetDetailError(null) + } catch (error) { + setDatasetDetailError(formatError(error, 'Raster NDWI failed')) + } + } + + const runRasterNdbi = async () => { + if (!selectedProjectId || !selectedDatasetId) { + return + } + try { + await datasetsApi.rasterNdbi(selectedProjectId, selectedDatasetId, { + swir_band: ndbiSwirBand, + nir_band: ndbiNirBand, + }) + await refreshSelectedDatasetAfterJob() + setDatasetDetailError(null) + } catch (error) { + setDatasetDetailError(formatError(error, 'Raster NDBI failed')) + } + } + + const pickDerivedDataset = async (datasetId: string) => { + if (!selectedProjectId) { + return + } + const target = datasets.find((item) => item.id === datasetId) + if (target) { + await loadDatasetDetails(selectedProjectId, target) + } + } + + const refreshMetadata = async (datasetId: string) => { + if (!selectedProjectId) { + return + } + const refreshed = await datasetsApi.refreshMetadata(selectedProjectId, datasetId) + await loadProjectData(selectedProjectId) + if (selectedDataset?.id === datasetId) { + setSelectedDataset(refreshed) + if (isVectorDatasetType(refreshed.dataset_type)) { + loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) + } + } + } + + const resetDatasetForProject = () => { + setSelectedDatasetId(null) + setSelectedDataset(null) + setSelectedDatasetSummary(null) + setSelectedRasterMetadata(null) + setSelectedRasterStats(null) + setDatasetContent(null) + setRasterPreview(null) + setJobs([]) + } + + return { + selectedDatasetId, + selectedDataset, + selectedDatasetSummary, + selectedRasterMetadata, + selectedRasterStats, + datasetContent, + jobs, + rasterPreview, + selectedIntersectTargetId, + selectedClipAreaId, + rasterTileSize, + rasterTileOverlap, + rasterTileOutputName, + rasterReprojectCrs, + rasterReprojectResampling, + ndviNirBand, + ndviRedBand, + ndwiGreenBand, + ndwiNirBand, + ndbiSwirBand, + ndbiNirBand, + loadingDatasetDetails, + datasetDetailError, + datasetForm, + isRasterTileInputValid, + loadDatasetJobs, + loadDatasetDetails, + uploadDataset, + runVectorClip, + runVectorBuffer, + runVectorIntersect, + runRasterInspect, + runRasterPreview, + runRasterStats, + runRasterReproject, + runRasterClip, + runRasterTile, + runRasterNdvi, + runRasterNdwi, + runRasterNdbi, + pickDerivedDataset, + refreshMetadata, + resetDatasetForProject, + setSelectedDatasetId, + setSelectedDataset, + setSelectedIntersectTargetId, + setSelectedClipAreaId, + setRasterTileSize, + setRasterTileOverlap, + setRasterTileOutputName, + setRasterReprojectCrs, + setRasterReprojectResampling, + setNdviNirBand, + setNdviRedBand, + setNdwiGreenBand, + setNdwiNirBand, + setNdbiSwirBand, + setNdbiNirBand, + setDatasetForm, + } +}