From f567a459b0aafc6a9990f6d9586e033bf1542c81 Mon Sep 17 00:00:00 2001 From: Jens Date: Sun, 23 Aug 2026 15:00:08 +0200 Subject: [PATCH] Laat een kapot paneel geen kapotte applicatie meer zijn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Er was nergens een error boundary: geen componentDidCatch, geen getDerivedStateFromError. Eén component die gooit nam de hele werkbank mee, inclusief de kaart en de navigatie. Nu blijft de schil staan, ziet de gebruiker welk werkblad het liet afweten, en wist wegnavigeren de fout. Daarnaast een patroon dat op zeven plekken half was toegepast: een optionele keten die te vroeg stopt. report?.items?.filter(...) stond geguard, twintig regels verderop stond report.items.map(...) zonder. Half geguard is de eigenlijke fout, want het wekt zekerheid zonder die te bieden. Waar de lijst op meerdere plekken gebruikt wordt staat nu één normalisatie aan de kop. De brondetails hadden geen enkele afhandeling en werden met .catch(() => null) overgeslagen; die melden nu wel. De overige elf catch-blokken bleken bovenop hooks te staan die de fout al zelf opvangen en tonen, wat ik heb nagemeten door een verzoek te laten mislukken. Co-Authored-By: Claude Opus 5 --- .../analysis/ChangeDetectionPanel.tsx | 2 +- .../src/components/detection/DetectionLab.tsx | 8 +- .../inspector/WorkbenchInspector.tsx | 2 +- .../map/useMapWorkspaceViewModel.ts | 14 ++-- .../providers/ProviderPanel.test.tsx | 41 +++++++++ .../components/providers/ProviderPanel.tsx | 11 ++- .../quality/DetectionReviewPanel.tsx | 2 +- .../quality/QualityResultsPanel.tsx | 2 +- .../shell/WorkspaceErrorBoundary.test.tsx | 68 +++++++++++++++ .../shell/WorkspaceErrorBoundary.tsx | 83 +++++++++++++++++++ .../status/SourceFreshnessPanel.test.tsx | 59 +++++++++++++ .../status/SourceFreshnessPanel.tsx | 7 +- frontend/src/hooks/useDatasetWorkflow.ts | 13 ++- frontend/src/hooks/useDemoWorkflow.ts | 4 +- frontend/src/hooks/useMapWorkspaceState.ts | 4 +- 15 files changed, 294 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/providers/ProviderPanel.test.tsx create mode 100644 frontend/src/components/shell/WorkspaceErrorBoundary.test.tsx create mode 100644 frontend/src/components/shell/WorkspaceErrorBoundary.tsx create mode 100644 frontend/src/components/status/SourceFreshnessPanel.test.tsx 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
    diff --git a/frontend/src/components/detection/DetectionLab.tsx b/frontend/src/components/detection/DetectionLab.tsx index 589fda90..e70b4dfb 100644 --- a/frontend/src/components/detection/DetectionLab.tsx +++ b/frontend/src/components/detection/DetectionLab.tsx @@ -206,9 +206,9 @@ export function DetectionLab({ (profile) => profile.modelAssetId === selectedModelAssetId, ) ?? null const yoloRuntimeReady = Boolean( - yoloPreflight?.checks.enabled && - yoloPreflight.checks.dependencies_available && - yoloPreflight.checks.model_file_exists, + yoloPreflight?.checks?.enabled && + yoloPreflight.checks?.dependencies_available && + yoloPreflight.checks?.model_file_exists, ) const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured' const detectionHasDataset = selectedDetectionDatasetId.length > 0 @@ -308,7 +308,7 @@ export function DetectionLab({

    {selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Lokaal YOLO-model'}

- 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)