diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba92016..3f73c19d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ # 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) - Moved provider capability loading from `App.tsx` into `frontend/src/hooks/useProviderCapabilities.ts`. diff --git a/backend/tests/test_sprint39_frontend_orchestration_hooks.py b/backend/tests/test_sprint39_frontend_orchestration_hooks.py index 204cc63f..3eca2a55 100644 --- a/backend/tests/test_sprint39_frontend_orchestration_hooks.py +++ b/backend/tests/test_sprint39_frontend_orchestration_hooks.py @@ -9,16 +9,33 @@ ROOT = Path(__file__).resolve().parents[2] def test_app_uses_shared_orchestration_hooks() -> None: app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + assert "useProjectWorkspace" in app assert "useProviderCapabilities" in app assert "useChangeDetectionWorkflow" in app assert "useMapWorkspaceState" in app + assert "from './hooks/useProjectWorkspace'" in app assert "from './hooks/useProviderCapabilities'" in app assert "from './hooks/useChangeDetectionWorkflow'" 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 "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: 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 "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 diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 6a48731c..f36a6ce2 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index 6147a32e..92828d64 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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] Export preview component decomposition and HTML report download-only UX hardening. - [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 diff --git a/frontend/README.md b/frontend/README.md index 50dddbe2..9166ce8c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -213,6 +213,8 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow. - Provider capability loading lives in `src/hooks/useProviderCapabilities.ts`. - Change detection orchestration lives in `src/hooks/useChangeDetectionWorkflow.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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4cba3ab3..ebc39fd0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,5 @@ import { FormEvent, useEffect, useMemo, useState } from 'react' 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 { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel' import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel' @@ -15,15 +12,7 @@ import { AreaPanel } from './components/project/AreaPanel' import { ProjectPanel } from './components/project/ProjectPanel' import { QualityResultsPanel } from './components/quality/QualityResultsPanel' import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip' -import type { - DatasetCreateResponse, - DatasetListResponse, - ProjectRead, - ProjectCreate, - AreaCreate, - AreaListResponse, - AreaRead, -} from './types' +import type { DatasetCreateResponse } from './types' import { ProviderPanel } from './components/providers/ProviderPanel' import { SegmentationLab } from './components/segmentation/SegmentationLab' import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow' @@ -32,6 +21,7 @@ import { useDatasetWorkflow } from './hooks/useDatasetWorkflow' import { useExportWorkflow } from './hooks/useExportWorkflow' import { useMapWorkspaceState } from './hooks/useMapWorkspaceState' import { useProviderCapabilities } from './hooks/useProviderCapabilities' +import { useProjectWorkspace } from './hooks/useProjectWorkspace' import { useQualityWorkflow } from './hooks/useQualityWorkflow' import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow' import { formatError } from './lib/formatError' @@ -41,32 +31,31 @@ function isVectorDatasetType(datasetType: string): boolean { } function App(): JSX.Element { - const [projects, setProjects] = useState([]) - const [selectedProjectId, setSelectedProjectId] = useState(null) - const [areas, setAreas] = useState([]) - const [datasets, setDatasets] = useState([]) - 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 [errorMessage, setErrorMessage] = useState(null) - const [projectForm, setProjectForm] = useState({ - 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 { + projects, + selectedProject, + selectedProjectId, + areas, + datasets, + loadingProjects, + loadingAreas, + loadingDatasets, + errorMessage, + projectForm, + areaForm, + loadProjects, + loadProjectData, + createProject, + createArea, + resetProjectData, + setSelectedProjectId, + setErrorMessage, + setProjectForm, + setAreaForm, + } = useProjectWorkspace() const { selectedDatasetId, selectedDataset, @@ -128,6 +117,7 @@ function App(): JSX.Element { setDatasetForm, } = useDatasetWorkflow({ selectedProjectId, + areas, datasets, loadProjectData, setErrorMessage, @@ -334,52 +324,6 @@ function App(): JSX.Element { datasetContent, 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(() => { loadProjects().catch(() => null) @@ -390,10 +334,8 @@ function App(): JSX.Element { useEffect(() => { if (!selectedProjectId) { - setAreas([]) - setDatasets([]) + resetProjectData() resetDatasetForProject() - setSelectedMapAreaId('') resetDetectionForProject() resetSegmentationForProject() resetExportsForProject() @@ -414,25 +356,6 @@ function App(): JSX.Element { loadSegmentationResults().catch(() => null) }, [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 () => { setLoadingDemoWorkflow(true) 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 (
diff --git a/frontend/src/hooks/useDatasetWorkflow.ts b/frontend/src/hooks/useDatasetWorkflow.ts index eb46631e..364fd7bc 100644 --- a/frontend/src/hooks/useDatasetWorkflow.ts +++ b/frontend/src/hooks/useDatasetWorkflow.ts @@ -1,6 +1,7 @@ -import { FormEvent, useMemo, useState } from 'react' +import { FormEvent, useEffect, useMemo, useState } from 'react' import { datasetsApi, jobsApi } from '../services/api' import type { + AreaRead, DatasetCreateResponse, JobRead, RasterMetadataResponse, @@ -12,6 +13,7 @@ import { formatError } from '../lib/formatError' interface DatasetWorkflowOptions { selectedProjectId: string | null + areas: AreaRead[] datasets: DatasetCreateResponse[] loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null> setErrorMessage: (message: string | null) => void @@ -27,6 +29,7 @@ function toRasterMetadata(metadata: Record | null | undefined): export function useDatasetWorkflow({ selectedProjectId, + areas, datasets, loadProjectData, setErrorMessage, @@ -72,6 +75,14 @@ export function useDatasetWorkflow({ [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 response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 }) setJobs(response.items) diff --git a/frontend/src/hooks/useMapWorkspaceState.ts b/frontend/src/hooks/useMapWorkspaceState.ts index df7bd8d0..e90283dd 100644 --- a/frontend/src/hooks/useMapWorkspaceState.ts +++ b/frontend/src/hooks/useMapWorkspaceState.ts @@ -25,6 +25,14 @@ export function useMapWorkspaceState({ const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18) const [selectedMapFeature, setSelectedMapFeature] = useState(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( () => areas.find((area) => area.id === selectedMapAreaId) ?? null, [areas, selectedMapAreaId], diff --git a/frontend/src/hooks/useProjectWorkspace.ts b/frontend/src/hooks/useProjectWorkspace.ts new file mode 100644 index 00000000..b0d57cc0 --- /dev/null +++ b/frontend/src/hooks/useProjectWorkspace.ts @@ -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([]) + const [selectedProjectId, setSelectedProjectId] = useState(null) + const [areas, setAreas] = useState([]) + const [datasets, setDatasets] = useState([]) + const [loadingProjects, setLoadingProjects] = useState(false) + const [loadingAreas, setLoadingAreas] = useState(false) + const [loadingDatasets, setLoadingDatasets] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + const [projectForm, setProjectForm] = useState({ + 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, + } +}