Extract project workspace hook
This commit is contained in:
@@ -7,6 +7,13 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Sprint 40 Project workspace hook decomposition (2026-06-17)
|
||||||
|
|
||||||
|
- Moved project listing/creation, area creation and project-scoped area/dataset loading from `App.tsx` into `frontend/src/hooks/useProjectWorkspace.ts`.
|
||||||
|
- Moved default clip-area selection into `useDatasetWorkflow.ts` and default map-area selection into `useMapWorkspaceState.ts`, keeping selection state with the owning workflow.
|
||||||
|
- Extended frontend orchestration regression tests to keep project, provider, change-detection and map orchestration out of `App.tsx`.
|
||||||
|
- No API contracts, migrations, product features, provider fetching or AI behavior were introduced.
|
||||||
|
|
||||||
## Sprint 39 Frontend orchestration decomposition (2026-06-17)
|
## Sprint 39 Frontend orchestration decomposition (2026-06-17)
|
||||||
|
|
||||||
- Moved provider capability loading from `App.tsx` into `frontend/src/hooks/useProviderCapabilities.ts`.
|
- Moved provider capability loading from `App.tsx` into `frontend/src/hooks/useProviderCapabilities.ts`.
|
||||||
|
|||||||
@@ -9,16 +9,33 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||||||
def test_app_uses_shared_orchestration_hooks() -> None:
|
def test_app_uses_shared_orchestration_hooks() -> None:
|
||||||
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "useProjectWorkspace" in app
|
||||||
assert "useProviderCapabilities" in app
|
assert "useProviderCapabilities" in app
|
||||||
assert "useChangeDetectionWorkflow" in app
|
assert "useChangeDetectionWorkflow" in app
|
||||||
assert "useMapWorkspaceState" in app
|
assert "useMapWorkspaceState" in app
|
||||||
|
assert "from './hooks/useProjectWorkspace'" in app
|
||||||
assert "from './hooks/useProviderCapabilities'" in app
|
assert "from './hooks/useProviderCapabilities'" in app
|
||||||
assert "from './hooks/useChangeDetectionWorkflow'" in app
|
assert "from './hooks/useChangeDetectionWorkflow'" in app
|
||||||
assert "from './hooks/useMapWorkspaceState'" in app
|
assert "from './hooks/useMapWorkspaceState'" in app
|
||||||
|
assert "projectsApi" not in app
|
||||||
|
assert "areasApi" not in app
|
||||||
|
assert "datasetsApi" not in app
|
||||||
assert "analysisApi" not in app
|
assert "analysisApi" not in app
|
||||||
assert "externalApi" not in app
|
assert "externalApi" not in app
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_workspace_hook_owns_project_area_dataset_loading() -> None:
|
||||||
|
hook = (ROOT / "frontend" / "src" / "hooks" / "useProjectWorkspace.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "projectsApi.list" in hook
|
||||||
|
assert "projectsApi.create" in hook
|
||||||
|
assert "areasApi.list" in hook
|
||||||
|
assert "areasApi.create" in hook
|
||||||
|
assert "datasetsApi.list" in hook
|
||||||
|
assert "loadProjectData" in hook
|
||||||
|
assert "resetProjectData" in hook
|
||||||
|
|
||||||
|
|
||||||
def test_provider_capabilities_hook_owns_provider_api_calls() -> None:
|
def test_provider_capabilities_hook_owns_provider_api_calls() -> None:
|
||||||
hook = (ROOT / "frontend" / "src" / "hooks" / "useProviderCapabilities.ts").read_text(encoding="utf-8")
|
hook = (ROOT / "frontend" / "src" / "hooks" / "useProviderCapabilities.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
@@ -45,3 +62,10 @@ def test_map_workspace_state_hook_owns_derived_map_state() -> None:
|
|||||||
assert "mapLayerLabel" in hook
|
assert "mapLayerLabel" in hook
|
||||||
assert "setSelectedMapFeature(null)" in hook
|
assert "setSelectedMapFeature(null)" in hook
|
||||||
|
|
||||||
|
|
||||||
|
def test_area_selection_fallbacks_live_with_owning_hooks() -> None:
|
||||||
|
dataset_hook = (ROOT / "frontend" / "src" / "hooks" / "useDatasetWorkflow.ts").read_text(encoding="utf-8")
|
||||||
|
map_hook = (ROOT / "frontend" / "src" / "hooks" / "useMapWorkspaceState.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "setSelectedClipAreaId(areas[0].id)" in dataset_hook
|
||||||
|
assert "setSelectedMapAreaId(areas[0].id)" in map_hook
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
## Sprint 40 Project workspace hook decomposition (2026-06-17)
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
- Moved project list/create state, area create state and project-scoped area/dataset loading into `frontend/src/hooks/useProjectWorkspace.ts`.
|
||||||
|
- Kept demo workflow orchestration in `App.tsx` because it coordinates project, dataset, QA/QC, detection, segmentation and export selections across multiple hooks.
|
||||||
|
- Moved default clip-area fallback selection into `frontend/src/hooks/useDatasetWorkflow.ts`.
|
||||||
|
- Moved default map-area fallback selection into `frontend/src/hooks/useMapWorkspaceState.ts`.
|
||||||
|
- Extended static frontend orchestration tests to lock these ownership boundaries.
|
||||||
|
- Updated frontend README, changelog and TODO status.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cd backend && python -m pytest tests/test_sprint39_frontend_orchestration_hooks.py -q` passed: 6 tests.
|
||||||
|
- `cd frontend && npm run typecheck` passed.
|
||||||
|
- `cd frontend && npm run build` passed.
|
||||||
|
|
||||||
|
Open:
|
||||||
|
- Full readiness and Tower deploy smoke should run before considering this pass deployed.
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
- No UX behavior, API contracts, migrations, provider fetching or AI behavior changed.
|
||||||
|
- Demo workflow orchestration remains in `App.tsx` until it can be safely split without obscuring cross-module state updates.
|
||||||
|
|
||||||
|
Next recommended pass:
|
||||||
|
- Run full readiness and Tower deploy verification, then consider extracting the demo workflow coordinator into a dedicated hook.
|
||||||
|
|
||||||
## Sprint 39 Frontend orchestration decomposition (2026-06-17)
|
## Sprint 39 Frontend orchestration decomposition (2026-06-17)
|
||||||
|
|
||||||
Changed:
|
Changed:
|
||||||
|
|||||||
+2
-1
@@ -52,7 +52,8 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
- [x] Single-container `geointel` Unraid compose/template runtime.
|
- [x] Single-container `geointel` Unraid compose/template runtime.
|
||||||
- [x] Export preview component decomposition and HTML report download-only UX hardening.
|
- [x] Export preview component decomposition and HTML report download-only UX hardening.
|
||||||
- [x] Provider, change-detection and map-workspace orchestration hook decomposition.
|
- [x] Provider, change-detection and map-workspace orchestration hook decomposition.
|
||||||
- [ ] Further project/area/dataset cross-load orchestration decomposition.
|
- [x] Project/area/dataset cross-load orchestration hook decomposition.
|
||||||
|
- [ ] Further demo workflow orchestration decomposition.
|
||||||
|
|
||||||
## Sprint 8 status
|
## Sprint 8 status
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow.
|
|||||||
- Provider capability loading lives in `src/hooks/useProviderCapabilities.ts`.
|
- Provider capability loading lives in `src/hooks/useProviderCapabilities.ts`.
|
||||||
- Change detection orchestration lives in `src/hooks/useChangeDetectionWorkflow.ts`.
|
- Change detection orchestration lives in `src/hooks/useChangeDetectionWorkflow.ts`.
|
||||||
- Derived MapLibre workbench state lives in `src/hooks/useMapWorkspaceState.ts`.
|
- Derived MapLibre workbench state lives in `src/hooks/useMapWorkspaceState.ts`.
|
||||||
|
- Project, area and dataset-list loading lives in `src/hooks/useProjectWorkspace.ts`.
|
||||||
|
- Area fallback selection now lives with the owning workflow hooks: clip-area selection in `useDatasetWorkflow.ts` and map-area selection in `useMapWorkspaceState.ts`.
|
||||||
|
|
||||||
## Raster dependency visibility
|
## Raster dependency visibility
|
||||||
|
|
||||||
|
|||||||
+26
-130
@@ -1,8 +1,5 @@
|
|||||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import './styles/app.css'
|
import './styles/app.css'
|
||||||
import { areasApi } from './services/api/areas'
|
|
||||||
import { datasetsApi } from './services/api/datasets'
|
|
||||||
import { projectsApi } from './services/api/projects'
|
|
||||||
import { demoApi } from './services/api'
|
import { demoApi } from './services/api'
|
||||||
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
|
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
|
||||||
import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel'
|
import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel'
|
||||||
@@ -15,15 +12,7 @@ import { AreaPanel } from './components/project/AreaPanel'
|
|||||||
import { ProjectPanel } from './components/project/ProjectPanel'
|
import { ProjectPanel } from './components/project/ProjectPanel'
|
||||||
import { QualityResultsPanel } from './components/quality/QualityResultsPanel'
|
import { QualityResultsPanel } from './components/quality/QualityResultsPanel'
|
||||||
import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip'
|
import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip'
|
||||||
import type {
|
import type { DatasetCreateResponse } from './types'
|
||||||
DatasetCreateResponse,
|
|
||||||
DatasetListResponse,
|
|
||||||
ProjectRead,
|
|
||||||
ProjectCreate,
|
|
||||||
AreaCreate,
|
|
||||||
AreaListResponse,
|
|
||||||
AreaRead,
|
|
||||||
} from './types'
|
|
||||||
import { ProviderPanel } from './components/providers/ProviderPanel'
|
import { ProviderPanel } from './components/providers/ProviderPanel'
|
||||||
import { SegmentationLab } from './components/segmentation/SegmentationLab'
|
import { SegmentationLab } from './components/segmentation/SegmentationLab'
|
||||||
import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow'
|
import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow'
|
||||||
@@ -32,6 +21,7 @@ import { useDatasetWorkflow } from './hooks/useDatasetWorkflow'
|
|||||||
import { useExportWorkflow } from './hooks/useExportWorkflow'
|
import { useExportWorkflow } from './hooks/useExportWorkflow'
|
||||||
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
|
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
|
||||||
import { useProviderCapabilities } from './hooks/useProviderCapabilities'
|
import { useProviderCapabilities } from './hooks/useProviderCapabilities'
|
||||||
|
import { useProjectWorkspace } from './hooks/useProjectWorkspace'
|
||||||
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
|
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
|
||||||
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
|
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
|
||||||
import { formatError } from './lib/formatError'
|
import { formatError } from './lib/formatError'
|
||||||
@@ -41,32 +31,31 @@ function isVectorDatasetType(datasetType: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App(): JSX.Element {
|
function App(): JSX.Element {
|
||||||
const [projects, setProjects] = useState<ProjectRead[]>([])
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
|
|
||||||
const [areas, setAreas] = useState<AreaRead[]>([])
|
|
||||||
const [datasets, setDatasets] = useState<DatasetCreateResponse[]>([])
|
|
||||||
const [loadingProjects, setLoadingProjects] = useState(false)
|
|
||||||
const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false)
|
const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false)
|
||||||
const [demoWorkflowMessage, setDemoWorkflowMessage] = useState<string | null>(null)
|
const [demoWorkflowMessage, setDemoWorkflowMessage] = useState<string | null>(null)
|
||||||
const [loadingAreas, setLoadingAreas] = useState(false)
|
|
||||||
const [loadingDatasets, setLoadingDatasets] = useState(false)
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const [projectForm, setProjectForm] = useState<ProjectCreate>({
|
const {
|
||||||
name: '',
|
projects,
|
||||||
description: '',
|
selectedProject,
|
||||||
region: 'Kempen',
|
selectedProjectId,
|
||||||
})
|
areas,
|
||||||
const [areaForm, setAreaForm] = useState({
|
datasets,
|
||||||
name: 'Demo AOI',
|
loadingProjects,
|
||||||
geometry:
|
loadingAreas,
|
||||||
'{"type":"MultiPolygon","coordinates":[[[[4.35,51.28],[4.55,51.28],[4.55,51.46],[4.35,51.46],[4.35,51.28]]]]}',
|
loadingDatasets,
|
||||||
crs: 'EPSG:4326',
|
errorMessage,
|
||||||
})
|
projectForm,
|
||||||
const selectedProject = useMemo(
|
areaForm,
|
||||||
() => projects.find((project) => project.id === selectedProjectId) ?? null,
|
loadProjects,
|
||||||
[projects, selectedProjectId],
|
loadProjectData,
|
||||||
)
|
createProject,
|
||||||
|
createArea,
|
||||||
|
resetProjectData,
|
||||||
|
setSelectedProjectId,
|
||||||
|
setErrorMessage,
|
||||||
|
setProjectForm,
|
||||||
|
setAreaForm,
|
||||||
|
} = useProjectWorkspace()
|
||||||
const {
|
const {
|
||||||
selectedDatasetId,
|
selectedDatasetId,
|
||||||
selectedDataset,
|
selectedDataset,
|
||||||
@@ -128,6 +117,7 @@ function App(): JSX.Element {
|
|||||||
setDatasetForm,
|
setDatasetForm,
|
||||||
} = useDatasetWorkflow({
|
} = useDatasetWorkflow({
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
|
areas,
|
||||||
datasets,
|
datasets,
|
||||||
loadProjectData,
|
loadProjectData,
|
||||||
setErrorMessage,
|
setErrorMessage,
|
||||||
@@ -334,52 +324,6 @@ function App(): JSX.Element {
|
|||||||
datasetContent,
|
datasetContent,
|
||||||
selectedDataset,
|
selectedDataset,
|
||||||
})
|
})
|
||||||
const loadProjects = async () => {
|
|
||||||
setLoadingProjects(true)
|
|
||||||
setErrorMessage(null)
|
|
||||||
try {
|
|
||||||
const response = await projectsApi.list()
|
|
||||||
setProjects(response.items)
|
|
||||||
if (!selectedProjectId && response.items.length > 0) {
|
|
||||||
setSelectedProjectId(response.items[0].id)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to load projects')
|
|
||||||
} finally {
|
|
||||||
setLoadingProjects(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProjectData(projectId: string) {
|
|
||||||
setLoadingAreas(true)
|
|
||||||
setLoadingDatasets(true)
|
|
||||||
setErrorMessage(null)
|
|
||||||
try {
|
|
||||||
const [areaResponse, datasetResponse]: [AreaListResponse, DatasetListResponse] = await Promise.all([
|
|
||||||
areasApi.list(projectId),
|
|
||||||
datasetsApi.list(projectId),
|
|
||||||
])
|
|
||||||
setAreas(areaResponse.items)
|
|
||||||
setDatasets(datasetResponse.items)
|
|
||||||
if (areaResponse.items.length === 0) {
|
|
||||||
setSelectedClipAreaId('')
|
|
||||||
} else if (!selectedClipAreaId || !areaResponse.items.some((area) => area.id === selectedClipAreaId)) {
|
|
||||||
setSelectedClipAreaId(areaResponse.items[0].id)
|
|
||||||
}
|
|
||||||
if (areaResponse.items.length === 0) {
|
|
||||||
setSelectedMapAreaId('')
|
|
||||||
} else if (!selectedMapAreaId || !areaResponse.items.some((area) => area.id === selectedMapAreaId)) {
|
|
||||||
setSelectedMapAreaId(areaResponse.items[0].id)
|
|
||||||
}
|
|
||||||
return { areas: areaResponse.items, datasets: datasetResponse.items }
|
|
||||||
} catch (error) {
|
|
||||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to load project data')
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
setLoadingAreas(false)
|
|
||||||
setLoadingDatasets(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProjects().catch(() => null)
|
loadProjects().catch(() => null)
|
||||||
@@ -390,10 +334,8 @@ function App(): JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId) {
|
if (!selectedProjectId) {
|
||||||
setAreas([])
|
resetProjectData()
|
||||||
setDatasets([])
|
|
||||||
resetDatasetForProject()
|
resetDatasetForProject()
|
||||||
setSelectedMapAreaId('')
|
|
||||||
resetDetectionForProject()
|
resetDetectionForProject()
|
||||||
resetSegmentationForProject()
|
resetSegmentationForProject()
|
||||||
resetExportsForProject()
|
resetExportsForProject()
|
||||||
@@ -414,25 +356,6 @@ function App(): JSX.Element {
|
|||||||
loadSegmentationResults().catch(() => null)
|
loadSegmentationResults().catch(() => null)
|
||||||
}, [selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
|
}, [selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
|
||||||
|
|
||||||
const createProject = async (event: FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!projectForm.name.trim()) {
|
|
||||||
setErrorMessage('Project name is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await projectsApi.create({
|
|
||||||
name: projectForm.name.trim(),
|
|
||||||
description: projectForm.description?.trim() || undefined,
|
|
||||||
region: projectForm.region?.trim() || 'Kempen',
|
|
||||||
})
|
|
||||||
setProjectForm((previous) => ({ ...previous, name: '', description: '' }))
|
|
||||||
await loadProjects()
|
|
||||||
} catch (error) {
|
|
||||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to create project')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadDemoWorkflow = async () => {
|
const loadDemoWorkflow = async () => {
|
||||||
setLoadingDemoWorkflow(true)
|
setLoadingDemoWorkflow(true)
|
||||||
setDemoWorkflowMessage(null)
|
setDemoWorkflowMessage(null)
|
||||||
@@ -467,33 +390,6 @@ function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createArea = async (event: FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!selectedProjectId) {
|
|
||||||
setErrorMessage('Select a project first')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let geometry: AreaCreate['geometry']
|
|
||||||
try {
|
|
||||||
geometry = JSON.parse(areaForm.geometry) as AreaCreate['geometry']
|
|
||||||
} catch {
|
|
||||||
setErrorMessage('Invalid GeoJSON geometry JSON')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const createdArea = await areasApi.create(selectedProjectId, {
|
|
||||||
name: areaForm.name,
|
|
||||||
crs: areaForm.crs,
|
|
||||||
geometry,
|
|
||||||
})
|
|
||||||
setSelectedMapAreaId(createdArea.id)
|
|
||||||
await loadProjectData(selectedProjectId)
|
|
||||||
setAreaForm((previous) => ({ ...previous, name: '' }))
|
|
||||||
} catch (error) {
|
|
||||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to create area')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FormEvent, useMemo, useState } from 'react'
|
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { datasetsApi, jobsApi } from '../services/api'
|
import { datasetsApi, jobsApi } from '../services/api'
|
||||||
import type {
|
import type {
|
||||||
|
AreaRead,
|
||||||
DatasetCreateResponse,
|
DatasetCreateResponse,
|
||||||
JobRead,
|
JobRead,
|
||||||
RasterMetadataResponse,
|
RasterMetadataResponse,
|
||||||
@@ -12,6 +13,7 @@ import { formatError } from '../lib/formatError'
|
|||||||
|
|
||||||
interface DatasetWorkflowOptions {
|
interface DatasetWorkflowOptions {
|
||||||
selectedProjectId: string | null
|
selectedProjectId: string | null
|
||||||
|
areas: AreaRead[]
|
||||||
datasets: DatasetCreateResponse[]
|
datasets: DatasetCreateResponse[]
|
||||||
loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null>
|
loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null>
|
||||||
setErrorMessage: (message: string | null) => void
|
setErrorMessage: (message: string | null) => void
|
||||||
@@ -27,6 +29,7 @@ function toRasterMetadata(metadata: Record<string, unknown> | null | undefined):
|
|||||||
|
|
||||||
export function useDatasetWorkflow({
|
export function useDatasetWorkflow({
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
|
areas,
|
||||||
datasets,
|
datasets,
|
||||||
loadProjectData,
|
loadProjectData,
|
||||||
setErrorMessage,
|
setErrorMessage,
|
||||||
@@ -72,6 +75,14 @@ export function useDatasetWorkflow({
|
|||||||
[rasterTileSize, rasterTileOverlap],
|
[rasterTileSize, rasterTileOverlap],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (areas.length === 0) {
|
||||||
|
setSelectedClipAreaId('')
|
||||||
|
} else if (!selectedClipAreaId || !areas.some((area) => area.id === selectedClipAreaId)) {
|
||||||
|
setSelectedClipAreaId(areas[0].id)
|
||||||
|
}
|
||||||
|
}, [areas, selectedClipAreaId])
|
||||||
|
|
||||||
const loadDatasetJobs = async (projectId: string, datasetId: string) => {
|
const loadDatasetJobs = async (projectId: string, datasetId: string) => {
|
||||||
const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 })
|
const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 })
|
||||||
setJobs(response.items)
|
setJobs(response.items)
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export function useMapWorkspaceState({
|
|||||||
const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18)
|
const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18)
|
||||||
const [selectedMapFeature, setSelectedMapFeature] = useState<GeoJSON.Feature | null>(null)
|
const [selectedMapFeature, setSelectedMapFeature] = useState<GeoJSON.Feature | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (areas.length === 0) {
|
||||||
|
setSelectedMapAreaId('')
|
||||||
|
} else if (!selectedMapAreaId || !areas.some((area) => area.id === selectedMapAreaId)) {
|
||||||
|
setSelectedMapAreaId(areas[0].id)
|
||||||
|
}
|
||||||
|
}, [areas, selectedMapAreaId])
|
||||||
|
|
||||||
const selectedMapArea = useMemo(
|
const selectedMapArea = useMemo(
|
||||||
() => areas.find((area) => area.id === selectedMapAreaId) ?? null,
|
() => areas.find((area) => area.id === selectedMapAreaId) ?? null,
|
||||||
[areas, selectedMapAreaId],
|
[areas, selectedMapAreaId],
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { FormEvent, useMemo, useState } from 'react'
|
||||||
|
import { areasApi } from '../services/api/areas'
|
||||||
|
import { datasetsApi } from '../services/api/datasets'
|
||||||
|
import { projectsApi } from '../services/api/projects'
|
||||||
|
import type {
|
||||||
|
AreaCreate,
|
||||||
|
AreaListResponse,
|
||||||
|
AreaRead,
|
||||||
|
DatasetCreateResponse,
|
||||||
|
DatasetListResponse,
|
||||||
|
ProjectCreate,
|
||||||
|
ProjectRead,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export function useProjectWorkspace() {
|
||||||
|
const [projects, setProjects] = useState<ProjectRead[]>([])
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
|
||||||
|
const [areas, setAreas] = useState<AreaRead[]>([])
|
||||||
|
const [datasets, setDatasets] = useState<DatasetCreateResponse[]>([])
|
||||||
|
const [loadingProjects, setLoadingProjects] = useState(false)
|
||||||
|
const [loadingAreas, setLoadingAreas] = useState(false)
|
||||||
|
const [loadingDatasets, setLoadingDatasets] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [projectForm, setProjectForm] = useState<ProjectCreate>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
region: 'Kempen',
|
||||||
|
})
|
||||||
|
const [areaForm, setAreaForm] = useState({
|
||||||
|
name: 'Demo AOI',
|
||||||
|
geometry:
|
||||||
|
'{"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 selectedProject = useMemo(
|
||||||
|
() => projects.find((project) => project.id === selectedProjectId) ?? null,
|
||||||
|
[projects, selectedProjectId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadProjects = async () => {
|
||||||
|
setLoadingProjects(true)
|
||||||
|
setErrorMessage(null)
|
||||||
|
try {
|
||||||
|
const response = await projectsApi.list()
|
||||||
|
setProjects(response.items)
|
||||||
|
if (!selectedProjectId && response.items.length > 0) {
|
||||||
|
setSelectedProjectId(response.items[0].id)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : 'Failed to load projects')
|
||||||
|
} finally {
|
||||||
|
setLoadingProjects(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProjectData = async (projectId: string) => {
|
||||||
|
setLoadingAreas(true)
|
||||||
|
setLoadingDatasets(true)
|
||||||
|
setErrorMessage(null)
|
||||||
|
try {
|
||||||
|
const [areaResponse, datasetResponse]: [AreaListResponse, DatasetListResponse] = await Promise.all([
|
||||||
|
areasApi.list(projectId),
|
||||||
|
datasetsApi.list(projectId),
|
||||||
|
])
|
||||||
|
setAreas(areaResponse.items)
|
||||||
|
setDatasets(datasetResponse.items)
|
||||||
|
return { areas: areaResponse.items, datasets: datasetResponse.items }
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : 'Failed to load project data')
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
setLoadingAreas(false)
|
||||||
|
setLoadingDatasets(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProject = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!projectForm.name.trim()) {
|
||||||
|
setErrorMessage('Project name is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await projectsApi.create({
|
||||||
|
name: projectForm.name.trim(),
|
||||||
|
description: projectForm.description?.trim() || undefined,
|
||||||
|
region: projectForm.region?.trim() || 'Kempen',
|
||||||
|
})
|
||||||
|
setProjectForm((previous) => ({ ...previous, name: '', description: '' }))
|
||||||
|
await loadProjects()
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : 'Failed to create project')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createArea = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!selectedProjectId) {
|
||||||
|
setErrorMessage('Select a project first')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let geometry: AreaCreate['geometry']
|
||||||
|
try {
|
||||||
|
geometry = JSON.parse(areaForm.geometry) as AreaCreate['geometry']
|
||||||
|
} catch {
|
||||||
|
setErrorMessage('Invalid GeoJSON geometry JSON')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await areasApi.create(selectedProjectId, {
|
||||||
|
name: areaForm.name,
|
||||||
|
crs: areaForm.crs,
|
||||||
|
geometry,
|
||||||
|
})
|
||||||
|
await loadProjectData(selectedProjectId)
|
||||||
|
setAreaForm((previous) => ({ ...previous, name: '' }))
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : 'Failed to create area')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetProjectData = () => {
|
||||||
|
setAreas([])
|
||||||
|
setDatasets([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
selectedProject,
|
||||||
|
selectedProjectId,
|
||||||
|
areas,
|
||||||
|
datasets,
|
||||||
|
loadingProjects,
|
||||||
|
loadingAreas,
|
||||||
|
loadingDatasets,
|
||||||
|
errorMessage,
|
||||||
|
projectForm,
|
||||||
|
areaForm,
|
||||||
|
loadProjects,
|
||||||
|
loadProjectData,
|
||||||
|
createProject,
|
||||||
|
createArea,
|
||||||
|
resetProjectData,
|
||||||
|
setSelectedProjectId,
|
||||||
|
setErrorMessage,
|
||||||
|
setProjectForm,
|
||||||
|
setAreaForm,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user