From fd0000f82806cee5ec39f17d93405d752e021bed Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 17 Jun 2026 02:28:55 +0200 Subject: [PATCH] Extract export and QA frontend workflows --- CHANGELOG.md | 7 + .../test_sprint27_frontend_workflow_hooks.py | 52 ++++ docs/CODEX_EXECUTION_LOG.md | 24 ++ docs/TODO.md | 3 +- frontend/README.md | 7 + frontend/src/App.tsx | 275 +++--------------- frontend/src/hooks/useExportWorkflow.ts | 182 ++++++++++++ frontend/src/hooks/useQualityWorkflow.ts | 112 +++++++ 8 files changed, 433 insertions(+), 229 deletions(-) create mode 100644 backend/tests/test_sprint27_frontend_workflow_hooks.py create mode 100644 frontend/src/hooks/useExportWorkflow.ts create mode 100644 frontend/src/hooks/useQualityWorkflow.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0346f8a9..29bfe7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ # Changelog +## Sprint 27 export and QA workflow hook hardening (2026-06-17) + +- Moved Export Center orchestration state and API calls from `App.tsx` into `useExportWorkflow`. +- Moved QA/QC comparison state and persisted quality-check loading from `App.tsx` into `useQualityWorkflow`. +- Added regression coverage to verify `App.tsx` wires the new hooks while export and QA API ownership stays inside focused hooks. +- No API contracts, backend behavior, migrations, product features, provider fetching, AI behavior or UI redesign were introduced. + ## Sprint 26 frontend workflow hook hardening (2026-06-17) - Moved Detection Lab orchestration state and API calls from `App.tsx` into `useDetectionWorkflow`. diff --git a/backend/tests/test_sprint27_frontend_workflow_hooks.py b/backend/tests/test_sprint27_frontend_workflow_hooks.py new file mode 100644 index 00000000..e5e5922c --- /dev/null +++ b/backend/tests/test_sprint27_frontend_workflow_hooks.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_app_uses_export_and_quality_workflow_hooks() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert "useExportWorkflow" in app + assert "useQualityWorkflow" in app + assert "from './hooks/useExportWorkflow'" in app + assert "from './hooks/useQualityWorkflow'" in app + assert "exportsApi" not in app + assert "qaApi" not in app + + +def test_export_workflow_hook_owns_export_api_calls() -> None: + hook = (ROOT / "frontend" / "src" / "hooks" / "useExportWorkflow.ts").read_text(encoding="utf-8") + + assert "exportsApi.listProjectExports" in hook + assert "exportsApi.exportGeojson" in hook + assert "exportsApi.exportProjectMetadata" in hook + assert "exportsApi.exportProjectReport" in hook + assert "exportsApi.getContent" in hook + assert "exportsApi.downloadUrl" in hook + assert "resetExportsForProject" in hook + + +def test_quality_workflow_hook_owns_quality_api_calls() -> None: + hook = (ROOT / "frontend" / "src" / "hooks" / "useQualityWorkflow.ts").read_text(encoding="utf-8") + + assert "qaApi.listQualityChecks" in hook + assert "qaApi.runQa" in hook + assert "runQaComparison" in hook + assert "loadQualityChecks" in hook + assert "setQaIouThreshold" in hook + + +def test_app_still_wires_quality_results_and_export_center() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert "Refresh QA/QC results" in app + assert "onClick={() => loadQualityChecks()}" in app + assert "(null) const [selectedIntersectTargetId, setSelectedIntersectTargetId] = useState('') const [selectedClipAreaId, setSelectedClipAreaId] = useState('') - const [qaCandidateDatasetId, setQaCandidateDatasetId] = useState('') - const [qaReferenceDatasetId, setQaReferenceDatasetId] = useState('') - const [qaAreaId, setQaAreaId] = useState('') - const [qaIouThreshold, setQaIouThreshold] = useState(0.5) - const [qaRunning, setQaRunning] = useState(false) - const [qaResult, setQaResult] = useState(null) - const [qaError, setQaError] = useState(null) - const [qualityChecks, setQualityChecks] = useState([]) - const [qualityChecksError, setQualityChecksError] = useState(null) - const [exports, setExports] = useState([]) - const [latestExport, setLatestExport] = useState(null) - const [exportError, setExportError] = useState(null) - const [loadingExports, setLoadingExports] = useState(false) - const [exporting, setExporting] = useState(false) - const [exportPreview, setExportPreview] = useState | null>(null) const [rasterTileSize, setRasterTileSize] = useState(512) const [rasterTileOverlap, setRasterTileOverlap] = useState(64) const [rasterTileOutputName, setRasterTileOutputName] = useState('') @@ -187,6 +169,26 @@ function App(): JSX.Element { const candidateDatasets = availableVectorDatasets const providers = useMemo(() => providerCapabilities, [providerCapabilities]) const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets]) + const { + qaCandidateDatasetId, + qaReferenceDatasetId, + qaAreaId, + qaIouThreshold, + qaRunning, + qaResult, + qaError, + qualityChecks, + qualityChecksError, + loadQualityChecks, + runQaComparison, + setQaCandidateDatasetId, + setQaReferenceDatasetId, + setQaAreaId, + setQaIouThreshold, + } = useQualityWorkflow({ + selectedProjectId, + loadProjectData, + }) const { detectionModels, loadingDetectionModels, @@ -272,6 +274,29 @@ function App(): JSX.Element { loadProjectData, loadQualityChecks, }) + const { + exports, + latestExport, + exportError, + loadingExports, + exporting, + exportPreview, + loadExports, + exportSelectedDatasetGeoJson, + exportSelectedDetectionRunGeoJson, + exportSelectedSegmentationRunGeoJson, + exportProjectMetadata, + exportProjectReport, + previewExportContent, + downloadExportArtifact, + resetExportsForProject, + } = useExportWorkflow({ + selectedProjectId, + selectedDataset, + selectedDetectionRunId, + selectedSegmentationRunId, + isVectorDatasetType, + }) const selectedMapArea = useMemo( () => areas.find((area) => area.id === selectedMapAreaId) ?? null, [areas, selectedMapAreaId], @@ -398,152 +423,6 @@ function App(): JSX.Element { } } - async function loadQualityChecks(projectId = selectedProjectId) { - if (!projectId) { - setQualityChecks([]) - return - } - setQualityChecksError(null) - try { - const response = await qaApi.listQualityChecks(projectId) - setQualityChecks(response.items) - return response.items - } catch (error) { - setQualityChecksError(formatError(error, 'Failed to load QA/QC results')) - } - } - - const loadExports = async (projectId = selectedProjectId) => { - if (!projectId) { - setExports([]) - return - } - setLoadingExports(true) - setExportError(null) - try { - const response = await exportsApi.listProjectExports(projectId) - setExports(response.items) - } catch (error) { - setExportError(formatError(error, 'Failed to load exports')) - } finally { - setLoadingExports(false) - } - } - - const exportSelectedDatasetGeoJson = async () => { - if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) { - setExportError('Select a vector dataset before exporting GeoJSON.') - return - } - setExporting(true) - setExportError(null) - try { - const response = await exportsApi.exportGeojson({ - dataset_id: selectedDataset.id, - export_kind: 'dataset', - name: selectedDataset.name.replace(/\.(geo)?json$/i, ''), - }) - setLatestExport(response) - await loadExports(selectedDataset.project_id) - } catch (error) { - setExportError(formatError(error, 'Failed to export selected dataset')) - } finally { - setExporting(false) - } - } - - const exportSelectedDetectionRunGeoJson = async () => { - if (!selectedDetectionRunId) { - setExportError('Select a detection run before exporting GeoJSON.') - return - } - setExporting(true) - setExportError(null) - try { - const response = await exportsApi.exportGeojson({ - analysis_run_id: selectedDetectionRunId, - export_kind: 'detection_run', - }) - setLatestExport(response) - await loadExports(selectedProjectId) - } catch (error) { - setExportError(formatError(error, 'Failed to export detection run')) - } finally { - setExporting(false) - } - } - - const exportSelectedSegmentationRunGeoJson = async () => { - if (!selectedSegmentationRunId) { - setExportError('Select a segmentation run before exporting GeoJSON.') - return - } - setExporting(true) - setExportError(null) - try { - const response = await exportsApi.exportGeojson({ - analysis_run_id: selectedSegmentationRunId, - export_kind: 'segmentation_run', - }) - setLatestExport(response) - await loadExports(selectedProjectId) - } catch (error) { - setExportError(formatError(error, 'Failed to export segmentation run')) - } finally { - setExporting(false) - } - } - - const exportProjectMetadata = async () => { - if (!selectedProjectId) { - setExportError('Select a project before exporting metadata.') - return - } - setExporting(true) - setExportError(null) - try { - const response = await exportsApi.exportProjectMetadata(selectedProjectId) - setLatestExport(response) - await loadExports(selectedProjectId) - } catch (error) { - setExportError(formatError(error, 'Failed to export project metadata')) - } finally { - setExporting(false) - } - } - - const exportProjectReport = async () => { - if (!selectedProjectId) { - setExportError('Select a project before exporting a report.') - return - } - setExporting(true) - setExportError(null) - try { - const response = await exportsApi.exportProjectReport(selectedProjectId) - setLatestExport(response) - await loadExports(selectedProjectId) - } catch (error) { - setExportError(formatError(error, 'Failed to export project report')) - } finally { - setExporting(false) - } - } - - const previewExportContent = async (exportId: string) => { - setExportError(null) - try { - const response = await exportsApi.getContent(exportId) - setExportPreview(response.content) - } catch (error) { - setExportError(formatError(error, 'Failed to load export content')) - } - } - - const downloadExportArtifact = (exportId: string) => { - window.open(exportsApi.downloadUrl(exportId), '_blank', 'noopener,noreferrer') - } - const loadDatasetJobs = async (projectId: string, datasetId: string) => { const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 }) setJobs(response.items) @@ -609,9 +488,7 @@ function App(): JSX.Element { setJobs([]) resetDetectionForProject() resetSegmentationForProject() - setExports([]) - setLatestExport(null) - setExportPreview(null) + resetExportsForProject() return } loadProjectData(selectedProjectId).catch(() => null) @@ -1008,64 +885,6 @@ function App(): JSX.Element { } } - const runQaComparison = async () => { - if (!selectedProjectId) { - setQaError('Select a project first') - return - } - if (!qaCandidateDatasetId) { - setQaError('Select candidate dataset') - return - } - if (!qaReferenceDatasetId) { - setQaError('Select reference dataset') - return - } - if (qaCandidateDatasetId === qaReferenceDatasetId) { - setQaError('Candidate and reference datasets must be different') - return - } - if (!Number.isFinite(qaIouThreshold) || qaIouThreshold < 0 || qaIouThreshold > 1) { - setQaError('IoU threshold must be between 0 and 1') - return - } - setQaError(null) - setQaResult(null) - setQaRunning(true) - try { - const request: QaComparisonRequest = { - candidate_dataset_id: qaCandidateDatasetId, - reference_dataset_id: qaReferenceDatasetId, - iou_threshold: qaIouThreshold, - area_id: qaAreaId || null, - } - const job: JobRead = await qaApi.runQa(request) - if (job.status === 'failed') { - setQaError(job.error_message || 'QA comparison failed') - return - } - const payload = job.result_json - if (!payload || typeof payload !== 'object') { - setQaError('QA result was not available') - return - } - const parsed = payload as unknown as QaComparisonResult - if (!parsed || typeof parsed.status !== 'string') { - setQaError('QA result format was unexpected') - return - } - setQaResult(parsed) - await loadQualityChecks(selectedProjectId) - if (job.output_dataset_id) { - await loadProjectData(selectedProjectId) - } - } catch (error) { - setQaError(error instanceof Error ? error.message : 'QA comparison failed') - } finally { - setQaRunning(false) - } - } - const runChangeDetection = async () => { const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id const targetDatasetId = diff --git a/frontend/src/hooks/useExportWorkflow.ts b/frontend/src/hooks/useExportWorkflow.ts new file mode 100644 index 00000000..471bc884 --- /dev/null +++ b/frontend/src/hooks/useExportWorkflow.ts @@ -0,0 +1,182 @@ +import { useState } from 'react' +import { exportsApi } from '../services/api' +import type { DatasetCreateResponse, ExportCreateResponse, ExportRead } from '../types' +import { formatError } from '../lib/formatError' + +interface ExportWorkflowOptions { + selectedProjectId: string | null + selectedDataset: DatasetCreateResponse | null + selectedDetectionRunId: string + selectedSegmentationRunId: string + isVectorDatasetType: (datasetType: string) => boolean +} + +export function useExportWorkflow({ + selectedProjectId, + selectedDataset, + selectedDetectionRunId, + selectedSegmentationRunId, + isVectorDatasetType, +}: ExportWorkflowOptions) { + const [exports, setExports] = useState([]) + const [latestExport, setLatestExport] = useState(null) + const [exportError, setExportError] = useState(null) + const [loadingExports, setLoadingExports] = useState(false) + const [exporting, setExporting] = useState(false) + const [exportPreview, setExportPreview] = useState | null>(null) + + const loadExports = async (projectId = selectedProjectId) => { + if (!projectId) { + setExports([]) + return + } + setLoadingExports(true) + setExportError(null) + try { + const response = await exportsApi.listProjectExports(projectId) + setExports(response.items) + } catch (error) { + setExportError(formatError(error, 'Failed to load exports')) + } finally { + setLoadingExports(false) + } + } + + const exportSelectedDatasetGeoJson = async () => { + if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) { + setExportError('Select a vector dataset before exporting GeoJSON.') + return + } + setExporting(true) + setExportError(null) + try { + const response = await exportsApi.exportGeojson({ + dataset_id: selectedDataset.id, + export_kind: 'dataset', + name: selectedDataset.name.replace(/\.(geo)?json$/i, ''), + }) + setLatestExport(response) + await loadExports(selectedDataset.project_id) + } catch (error) { + setExportError(formatError(error, 'Failed to export selected dataset')) + } finally { + setExporting(false) + } + } + + const exportSelectedDetectionRunGeoJson = async () => { + if (!selectedDetectionRunId) { + setExportError('Select a detection run before exporting GeoJSON.') + return + } + setExporting(true) + setExportError(null) + try { + const response = await exportsApi.exportGeojson({ + analysis_run_id: selectedDetectionRunId, + export_kind: 'detection_run', + }) + setLatestExport(response) + await loadExports(selectedProjectId) + } catch (error) { + setExportError(formatError(error, 'Failed to export detection run')) + } finally { + setExporting(false) + } + } + + const exportSelectedSegmentationRunGeoJson = async () => { + if (!selectedSegmentationRunId) { + setExportError('Select a segmentation run before exporting GeoJSON.') + return + } + setExporting(true) + setExportError(null) + try { + const response = await exportsApi.exportGeojson({ + analysis_run_id: selectedSegmentationRunId, + export_kind: 'segmentation_run', + }) + setLatestExport(response) + await loadExports(selectedProjectId) + } catch (error) { + setExportError(formatError(error, 'Failed to export segmentation run')) + } finally { + setExporting(false) + } + } + + const exportProjectMetadata = async () => { + if (!selectedProjectId) { + setExportError('Select a project before exporting metadata.') + return + } + setExporting(true) + setExportError(null) + try { + const response = await exportsApi.exportProjectMetadata(selectedProjectId) + setLatestExport(response) + await loadExports(selectedProjectId) + } catch (error) { + setExportError(formatError(error, 'Failed to export project metadata')) + } finally { + setExporting(false) + } + } + + const exportProjectReport = async () => { + if (!selectedProjectId) { + setExportError('Select a project before exporting a report.') + return + } + setExporting(true) + setExportError(null) + try { + const response = await exportsApi.exportProjectReport(selectedProjectId) + setLatestExport(response) + await loadExports(selectedProjectId) + } catch (error) { + setExportError(formatError(error, 'Failed to export project report')) + } finally { + setExporting(false) + } + } + + const previewExportContent = async (exportId: string) => { + setExportError(null) + try { + const response = await exportsApi.getContent(exportId) + setExportPreview(response.content) + } catch (error) { + setExportError(formatError(error, 'Failed to load export content')) + } + } + + const downloadExportArtifact = (exportId: string) => { + window.open(exportsApi.downloadUrl(exportId), '_blank', 'noopener,noreferrer') + } + + const resetExportsForProject = () => { + setExports([]) + setLatestExport(null) + setExportPreview(null) + } + + return { + exports, + latestExport, + exportError, + loadingExports, + exporting, + exportPreview, + loadExports, + exportSelectedDatasetGeoJson, + exportSelectedDetectionRunGeoJson, + exportSelectedSegmentationRunGeoJson, + exportProjectMetadata, + exportProjectReport, + previewExportContent, + downloadExportArtifact, + resetExportsForProject, + } +} diff --git a/frontend/src/hooks/useQualityWorkflow.ts b/frontend/src/hooks/useQualityWorkflow.ts new file mode 100644 index 00000000..45023235 --- /dev/null +++ b/frontend/src/hooks/useQualityWorkflow.ts @@ -0,0 +1,112 @@ +import { useState } from 'react' +import { qaApi } from '../services/api' +import type { JobRead, QaComparisonRequest, QaComparisonResult, QualityCheckRead } from '../types' +import { formatError } from '../lib/formatError' + +interface QualityWorkflowOptions { + selectedProjectId: string | null + loadProjectData: (projectId: string) => Promise +} + +export function useQualityWorkflow({ selectedProjectId, loadProjectData }: QualityWorkflowOptions) { + const [qaCandidateDatasetId, setQaCandidateDatasetId] = useState('') + const [qaReferenceDatasetId, setQaReferenceDatasetId] = useState('') + const [qaAreaId, setQaAreaId] = useState('') + const [qaIouThreshold, setQaIouThreshold] = useState(0.5) + const [qaRunning, setQaRunning] = useState(false) + const [qaResult, setQaResult] = useState(null) + const [qaError, setQaError] = useState(null) + const [qualityChecks, setQualityChecks] = useState([]) + const [qualityChecksError, setQualityChecksError] = useState(null) + + const loadQualityChecks = async (projectId = selectedProjectId): Promise => { + if (!projectId) { + setQualityChecks([]) + return [] + } + setQualityChecksError(null) + try { + const response = await qaApi.listQualityChecks(projectId) + setQualityChecks(response.items) + return response.items + } catch (error) { + setQualityChecksError(formatError(error, 'Failed to load QA/QC results')) + } + } + + const runQaComparison = async () => { + if (!selectedProjectId) { + setQaError('Select a project first') + return + } + if (!qaCandidateDatasetId) { + setQaError('Select candidate dataset') + return + } + if (!qaReferenceDatasetId) { + setQaError('Select reference dataset') + return + } + if (qaCandidateDatasetId === qaReferenceDatasetId) { + setQaError('Candidate and reference datasets must be different') + return + } + if (!Number.isFinite(qaIouThreshold) || qaIouThreshold < 0 || qaIouThreshold > 1) { + setQaError('IoU threshold must be between 0 and 1') + return + } + setQaError(null) + setQaResult(null) + setQaRunning(true) + try { + const request: QaComparisonRequest = { + candidate_dataset_id: qaCandidateDatasetId, + reference_dataset_id: qaReferenceDatasetId, + iou_threshold: qaIouThreshold, + area_id: qaAreaId || null, + } + const job: JobRead = await qaApi.runQa(request) + if (job.status === 'failed') { + setQaError(job.error_message || 'QA comparison failed') + return + } + const payload = job.result_json + if (!payload || typeof payload !== 'object') { + setQaError('QA result was not available') + return + } + const parsed = payload as unknown as QaComparisonResult + if (!parsed || typeof parsed.status !== 'string') { + setQaError('QA result format was unexpected') + return + } + setQaResult(parsed) + await loadQualityChecks(selectedProjectId) + if (job.output_dataset_id) { + await loadProjectData(selectedProjectId) + } + } catch (error) { + setQaError(error instanceof Error ? error.message : 'QA comparison failed') + } finally { + setQaRunning(false) + } + } + + return { + qaCandidateDatasetId, + qaReferenceDatasetId, + qaAreaId, + qaIouThreshold, + qaRunning, + qaResult, + qaError, + qualityChecks, + qualityChecksError, + loadQualityChecks, + runQaComparison, + setQaCandidateDatasetId, + setQaReferenceDatasetId, + setQaAreaId, + setQaIouThreshold, + } +}