Extract project workspace hook
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 07:31:28 +02:00
parent ea70a77c6f
commit c0216823f2
9 changed files with 257 additions and 132 deletions
+7
View File
@@ -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
+25
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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>
+12 -1
View File
@@ -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],
+151
View File
@@ -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,
}
}