diff --git a/frontend/src/components/analysis/ChangeDetectionPanel.tsx b/frontend/src/components/analysis/ChangeDetectionPanel.tsx index b057c41d..c6054876 100644 --- a/frontend/src/components/analysis/ChangeDetectionPanel.tsx +++ b/frontend/src/components/analysis/ChangeDetectionPanel.tsx @@ -136,7 +136,7 @@ export function ChangeDetectionPanel({ ) : null} - {result?.warnings.length ? ( + {result?.warnings?.length ? (
Aandachtspunten
- PyTorch-runtime + Rekenomgeving {yoloRuntimeReady ? 'Gereed' : loadingDetectionModels ? 'Controleren...' : 'Niet gereed'}

{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}

diff --git a/frontend/src/components/inspector/WorkbenchInspector.tsx b/frontend/src/components/inspector/WorkbenchInspector.tsx index e6bd3ec5..05f37cc6 100644 --- a/frontend/src/components/inspector/WorkbenchInspector.tsx +++ b/frontend/src/components/inspector/WorkbenchInspector.tsx @@ -189,7 +189,7 @@ export function WorkbenchInspector({ - +
-
Bronkoppelingen{configuredCount} van {providers.length} actief
+
Bronkoppelingen{configuredCount} van {koppelingen.length} actief
WerkmodusBegrensde bronopvraging
@@ -115,7 +118,7 @@ export function ProviderPanel({

{capabilitiesError}

) : null} - {providers.length === 0 && !loadingCapabilities ? ( + {koppelingen.length === 0 && !loadingCapabilities ? (
Geen databronnen gemeld.

Vernieuw de status zodra de backend bereikbaar is.

@@ -157,7 +160,7 @@ export function ProviderPanel({
    - {providers.map((provider) => ( + {koppelingen.map((provider) => (
  • diff --git a/frontend/src/components/quality/DetectionReviewPanel.tsx b/frontend/src/components/quality/DetectionReviewPanel.tsx index a2bbe6b9..1d265611 100644 --- a/frontend/src/components/quality/DetectionReviewPanel.tsx +++ b/frontend/src/components/quality/DetectionReviewPanel.tsx @@ -197,7 +197,7 @@ export function DetectionReviewPanel({ ) : null}
      - {queue?.items.map((item) => { + {queue?.items?.map((item) => { const key = reviewKey(item) const decision = draftDecisions[key] ?? item.decision return ( diff --git a/frontend/src/components/quality/QualityResultsPanel.tsx b/frontend/src/components/quality/QualityResultsPanel.tsx index 12d01d5d..c0afcb0a 100644 --- a/frontend/src/components/quality/QualityResultsPanel.tsx +++ b/frontend/src/components/quality/QualityResultsPanel.tsx @@ -80,7 +80,7 @@ function qualityCheckTypeLabel(checkType: string | null | undefined): string { } function metricByKey(check: QualityCheckRead | null, metricKey: string): MetricRead | undefined { - return check?.metrics.find((metric) => metric.metric_key === metricKey) + return check?.metrics?.find((metric) => metric.metric_key === metricKey) } function findingEvidenceList(check: QualityCheckRead | null, key: string): Record[] { diff --git a/frontend/src/components/shell/WorkspaceErrorBoundary.test.tsx b/frontend/src/components/shell/WorkspaceErrorBoundary.test.tsx new file mode 100644 index 00000000..7dfa2a1f --- /dev/null +++ b/frontend/src/components/shell/WorkspaceErrorBoundary.test.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceErrorBoundary } from './WorkspaceErrorBoundary' + +function Struikelt({ gooi }: { gooi: boolean }): JSX.Element { + if (gooi) { + throw new Error('checks is undefined') + } + return

      werkblad staat

      +} + +describe('WorkspaceErrorBoundary', () => { + afterEach(() => cleanup()) + + it('houdt de schil overeind en biedt een nieuwe poging aan', () => { + // React logt de gevangen fout zelf ook; die ruis hoort niet in de uitvoer. + const stil = vi.spyOn(console, 'error').mockImplementation(() => {}) + + function Proef(): JSX.Element { + const [gooi, setGooi] = useState(true) + return ( +
      + + + + +
      + ) + } + + render() + + // De fout is opgevangen: de melding staat er, de schil eromheen ook. + expect(screen.getByTestId('workspace-error')).toBeTruthy() + expect(screen.getByText('Beeldanalyse kon niet worden getoond')).toBeTruthy() + expect(screen.getByText('checks is undefined')).toBeTruthy() + expect(screen.getByRole('button', { name: /herstel de bron/i })).toBeTruthy() + + // Na herstel van de oorzaak brengt "Opnieuw proberen" het werkblad terug. + fireEvent.click(screen.getByRole('button', { name: /herstel de bron/i })) + fireEvent.click(screen.getByRole('button', { name: /opnieuw proberen/i })) + expect(screen.getByText('werkblad staat')).toBeTruthy() + + stil.mockRestore() + }) + + it('wist de fout zodra de gebruiker naar een ander werkblad gaat', () => { + const stil = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { rerender } = render( + + + , + ) + expect(screen.getByTestId('workspace-error')).toBeTruthy() + + rerender( + + + , + ) + expect(screen.getByText('werkblad staat')).toBeTruthy() + expect(screen.queryByTestId('workspace-error')).toBeNull() + + stil.mockRestore() + }) +}) diff --git a/frontend/src/components/shell/WorkspaceErrorBoundary.tsx b/frontend/src/components/shell/WorkspaceErrorBoundary.tsx new file mode 100644 index 00000000..7e90b620 --- /dev/null +++ b/frontend/src/components/shell/WorkspaceErrorBoundary.tsx @@ -0,0 +1,83 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' +import { CircleAlert, RotateCcw } from 'lucide-react' + +interface WorkspaceErrorBoundaryProps { + /** Verandert deze sleutel, dan probeert de grens het opnieuw. Zet hier de + actieve werkruimte in, zodat wegnavigeren de fout wist. */ + resetKey: string + /** Naam van het werkblad, voor de melding. */ + label: string + children: ReactNode +} + +interface WorkspaceErrorBoundaryState { + error: Error | null + resetKey: string +} + +/** + * Vangt een fout in één werkblad op. + * + * Zonder deze grens nam een enkele component die gooit de hele werkbank mee: + * React ontkoppelt dan de volledige boom en de operator houdt een leeg scherm + * over, inclusief de kaart en de navigatie. Dat is voor een operationeel + * gereedschap de verkeerde verhouding tussen oorzaak en gevolg. + * + * Nu blijft de schil staan. De gebruiker ziet welk werkblad het liet afweten, + * kan naar een ander werkblad, en kan dit werkblad opnieuw proberen. + */ +export class WorkspaceErrorBoundary extends Component { + constructor(props: WorkspaceErrorBoundaryProps) { + super(props) + this.state = { error: null, resetKey: props.resetKey } + } + + static getDerivedStateFromError(error: Error): Partial { + return { error } + } + + static getDerivedStateFromProps( + props: WorkspaceErrorBoundaryProps, + state: WorkspaceErrorBoundaryState, + ): Partial | null { + if (props.resetKey !== state.resetKey) { + return { error: null, resetKey: props.resetKey } + } + return null + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // De stack blijft in de console beschikbaar voor wie meekijkt; er gaat + // niets naar buiten. + console.error(`Werkblad "${this.props.label}" is gestopt:`, error, info.componentStack) + } + + private retry = (): void => { + this.setState({ error: null }) + } + + render(): ReactNode { + const { error } = this.state + if (!error) { + return this.props.children + } + + return ( +
      +
      + ) + } +} diff --git a/frontend/src/components/status/SourceFreshnessPanel.test.tsx b/frontend/src/components/status/SourceFreshnessPanel.test.tsx new file mode 100644 index 00000000..3ebff1ed --- /dev/null +++ b/frontend/src/components/status/SourceFreshnessPanel.test.tsx @@ -0,0 +1,59 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SourceFreshnessPanel } from './SourceFreshnessPanel' +import type { SourceFreshnessReport } from '../../types' + +function props(overschrijf: Partial[0]> = {}) { + return { + report: null, + loading: false, + error: null, + onRefresh: vi.fn(), + catalogReport: null, + catalogLoading: false, + catalogError: null, + onProbeCatalogs: vi.fn(), + grbRefreshPlan: null, + grbRefreshPlanLoading: false, + grbRefreshPlanError: null, + ...overschrijf, + } +} + +const leegRapport: SourceFreshnessReport = { + project_id: 'p-1', + generated_at: '2026-08-23T09:00:00Z', + summary: { + source_count: 0, + dataset_count: 0, + current_count: 0, + due_count: 0, + review_required_count: 0, + local_count: 0, + sources_with_integrity_issues: 0, + integrity_issue_count: 0, + }, + items: [], + limitations: [], +} + +describe('SourceFreshnessPanel', () => { + afterEach(() => cleanup()) + + it('blijft overeind bij een antwoord dat zijn eigen contract schendt', () => { + // Het type zegt dat items altijd meekomt, maar een type is een belofte van + // de compiler en geen garantie van het netwerk. Vandaar de optionele keten + // in de component; deze test legt vast dat die er blijft. + const zonderItems = { ...leegRapport, items: undefined } as unknown as SourceFreshnessReport + expect(() => render()).not.toThrow() + }) + + it('toont een leeg rapport zonder te struikelen', () => { + expect(() => render()).not.toThrow() + }) + + it('meldt een fout in plaats van hem te verzwijgen', () => { + render() + expect(screen.getByText(/kon de brondekking niet ophalen/i)).toBeTruthy() + }) +}) diff --git a/frontend/src/components/status/SourceFreshnessPanel.tsx b/frontend/src/components/status/SourceFreshnessPanel.tsx index 195bee09..d382c994 100644 --- a/frontend/src/components/status/SourceFreshnessPanel.tsx +++ b/frontend/src/components/status/SourceFreshnessPanel.tsx @@ -142,7 +142,10 @@ export function SourceFreshnessPanel({ grbRefreshPlanLoading, grbRefreshPlanError, }: SourceFreshnessPanelProps): JSX.Element { - const attentionItems = report?.items.filter((item) => item.status === 'due' || item.status === 'review_required') ?? [] + // Eén normalisatie aan de kop in plaats van een guard per gebruiksplek; de + // vorige versie had er één op de filter en geen op de map. + const bronnen = report?.items ?? [] + const attentionItems = bronnen.filter((item) => item.status === 'due' || item.status === 'review_required') const summary = report?.summary return ( @@ -191,7 +194,7 @@ export function SourceFreshnessPanel({ gecontroleerd {formatDate(report.generated_at)}
      - {report.items.map((item) => )} + {bronnen.map((item) => )}

      {report.limitations.join(' ')}

      diff --git a/frontend/src/hooks/useDatasetWorkflow.ts b/frontend/src/hooks/useDatasetWorkflow.ts index 6b53a441..838a395e 100644 --- a/frontend/src/hooks/useDatasetWorkflow.ts +++ b/frontend/src/hooks/useDatasetWorkflow.ts @@ -82,6 +82,13 @@ export function useDatasetWorkflow({ setErrorMessage, isVectorDatasetType, }: DatasetWorkflowOptions) { + // De detailgegevens van een bron werden bij een fout stilzwijgend + // overgeslagen; het paneel bleef dan leeg zonder dat iemand wist waarom. + const meldDetailFout = (fout: unknown) => { + setErrorMessage(fout instanceof Error ? fout.message : 'De brondetails konden niet worden geladen') + return null + } + const [selectedDatasetId, setSelectedDatasetId] = useState(null) const [selectedDataset, setSelectedDataset] = useState(null) const [selectedDatasetSummary, setSelectedDatasetSummary] = useState(null) @@ -145,7 +152,7 @@ export function useDatasetWorkflow({ datasets.find((dataset) => dataset.status === 'ready') ?? datasets[0] if (defaultDataset) { - loadDatasetDetails(selectedProjectId, defaultDataset).catch(() => null) + loadDatasetDetails(selectedProjectId, defaultDataset).catch(meldDetailFout) } }, [datasets, isVectorDatasetType, selectedDatasetId, selectedProjectId]) @@ -284,7 +291,7 @@ export function useDatasetWorkflow({ await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) if (refreshed) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) + loadDatasetDetails(selectedProjectId, refreshed).catch(meldDetailFout) } } @@ -521,7 +528,7 @@ export function useDatasetWorkflow({ if (selectedDataset?.id === datasetId) { setSelectedDataset(refreshed) if (isVectorDatasetType(refreshed.dataset_type)) { - loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) + loadDatasetDetails(selectedProjectId, refreshed).catch(meldDetailFout) } } } diff --git a/frontend/src/hooks/useDemoWorkflow.ts b/frontend/src/hooks/useDemoWorkflow.ts index 0cf7244e..e8ee0962 100644 --- a/frontend/src/hooks/useDemoWorkflow.ts +++ b/frontend/src/hooks/useDemoWorkflow.ts @@ -77,8 +77,8 @@ export function useDemoWorkflow({ loadQualityChecks(result.project_id), analysisLoads, ]) - const candidateDataset = projectData?.datasets.find((dataset) => dataset.id === result.candidate_dataset_id) - const rasterDataset = projectData?.datasets.find((dataset) => dataset.id === result.raster_dataset_id) + const candidateDataset = projectData?.datasets?.find((dataset) => dataset.id === result.candidate_dataset_id) + const rasterDataset = projectData?.datasets?.find((dataset) => dataset.id === result.raster_dataset_id) if (candidateDataset) { await loadDatasetDetails(result.project_id, candidateDataset) } else if (rasterDataset) { diff --git a/frontend/src/hooks/useMapWorkspaceState.ts b/frontend/src/hooks/useMapWorkspaceState.ts index 1511ad36..ea7788a6 100644 --- a/frontend/src/hooks/useMapWorkspaceState.ts +++ b/frontend/src/hooks/useMapWorkspaceState.ts @@ -89,8 +89,8 @@ export function useMapWorkspaceState({ } return 'Geen actieve kaartlaag' }, [changeDetectionGeoJson, datasetLayerActive, detectionGeoJson, segmentationGeoJson, selectedDataset]) - const mapFeatureCount = mapFeatureCollection?.features.length ?? 0 - const areaFeatureCount = areaFeatureCollection?.features.length ?? 0 + const mapFeatureCount = mapFeatureCollection?.features?.length ?? 0 + const areaFeatureCount = areaFeatureCollection?.features?.length ?? 0 useEffect(() => { setSelectedMapFeature(null)