From a9235bcad3ad580891a0b8cdb32676c5d4497334 Mon Sep 17 00:00:00 2001 From: Ngole Lawson Date: Sat, 2 May 2026 15:53:35 +0100 Subject: [PATCH] feat: add frontend scaffolding to fetch campaigns/datasets from backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Issue #206 — Fetch campaigns/datasets from backend. Since the backend endpoints are not yet implemented, this PR creates the complete frontend service layer ready to connect once they are. New files: - src/services/config.ts: API base URL config with VITE_API_BASE_URL env var support - src/services/api.ts: typed fetch functions for campaigns and datasets (fetchCampaigns, fetchCampaignById, fetchDatasets, fetchDatasetById, fetchDatasetsByCampaign) with generic error handling - src/types/api.ts: TypeScript interfaces for Campaign, Dataset, PaginatedResponse, ApiError, CampaignGeometry, LatLng - src/hooks/useCampaigns.ts: React hooks (useCampaigns, useCampaignDatasets) with graceful degradation when backend is unavailable - .env.example: documents the VITE_API_BASE_URL environment variable Expects backend endpoints at: - GET /api/v1/campaigns - GET /api/v1/campaigns/:id - GET /api/v1/campaigns/:id/datasets - GET /api/v1/datasets - GET /api/v1/datasets/:id TypeScript compiles cleanly (tsc --noEmit exit 0). --- .env.example | 3 + src/hooks/useCampaigns.ts | 127 ++++++++++++++++++++++++++++++++++++++ src/services/api.ts | 106 +++++++++++++++++++++++++++++++ src/services/config.ts | 11 ++++ src/types/api.ts | 81 ++++++++++++++++++++++++ 5 files changed, 328 insertions(+) create mode 100644 .env.example create mode 100644 src/hooks/useCampaigns.ts create mode 100644 src/services/api.ts create mode 100644 src/services/config.ts create mode 100644 src/types/api.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4e8196f --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Dashboard Backend API URL +# Change this to match your backend server address +VITE_API_BASE_URL=http://localhost:5000 diff --git a/src/hooks/useCampaigns.ts b/src/hooks/useCampaigns.ts new file mode 100644 index 0000000..5c3679a --- /dev/null +++ b/src/hooks/useCampaigns.ts @@ -0,0 +1,127 @@ +import { useState, useEffect, useCallback } from 'react'; +import { fetchCampaigns, fetchDatasetsByCampaign } from '@/services/api'; +import type { Campaign, Dataset, ApiError } from '@/types/api'; + +/** + * State shape for the useCampaigns hook + */ +interface CampaignsState { + /** List of fetched campaigns */ + campaigns: Campaign[]; + /** Whether the initial fetch is in progress */ + loading: boolean; + /** Error message if the fetch failed */ + error: string | null; + /** Refetch campaigns from the API */ + refetch: () => void; +} + +/** + * Custom hook to fetch campaigns from the backend API. + * + * Gracefully handles the case where the backend is not yet available + * by catching errors and setting a user-friendly error message. + * This allows the rest of the app to continue functioning with + * local fixture data while the backend is being developed. + * + * @param {number} page - Page number for pagination (default: 1) + * @param {number} pageSize - Number of items per page (default: 50) + * @returns {CampaignsState} Campaigns data, loading state, error, and refetch function + */ +export const useCampaigns = (page = 1, pageSize = 50): CampaignsState => { + const [campaigns, setCampaigns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadCampaigns = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const response = await fetchCampaigns(page, pageSize); + setCampaigns(response.data); + } catch (err) { + const apiError = err as ApiError; + const message = + apiError.status === 0 || apiError.message?.includes('fetch') + ? 'Backend not available — using local data. Start the dashboard_server to enable live data.' + : `Failed to fetch campaigns: ${apiError.message}`; + setError(message); + setCampaigns([]); + console.warn('[useCampaigns]', message); + } finally { + setLoading(false); + } + }, [page, pageSize]); + + useEffect(() => { + loadCampaigns(); + }, [loadCampaigns]); + + return { campaigns, loading, error, refetch: loadCampaigns }; +}; + +/** + * State shape for the useCampaignDatasets hook + */ +interface CampaignDatasetsState { + /** List of datasets for the selected campaign */ + datasets: Dataset[]; + /** Whether the fetch is in progress */ + loading: boolean; + /** Error message if the fetch failed */ + error: string | null; +} + +/** + * Custom hook to fetch datasets for a specific campaign. + * + * @param {number | null} campaignId - Campaign ID to fetch datasets for. Pass null to skip. + * @returns {CampaignDatasetsState} Datasets data, loading state, and error + */ +export const useCampaignDatasets = ( + campaignId: number | null, +): CampaignDatasetsState => { + const [datasets, setDatasets] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (campaignId === null) { + setDatasets([]); + return; + } + + let cancelled = false; + + const loadDatasets = async () => { + setLoading(true); + setError(null); + + try { + const data = await fetchDatasetsByCampaign(campaignId); + if (!cancelled) { + setDatasets(data); + } + } catch (err) { + if (!cancelled) { + const apiError = err as ApiError; + setError(`Failed to fetch datasets: ${apiError.message}`); + setDatasets([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + loadDatasets(); + + return () => { + cancelled = true; + }; + }, [campaignId]); + + return { datasets, loading, error }; +}; diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..5cab230 --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,106 @@ +import { API_V1_URL } from './config'; +import type { Campaign, Dataset, PaginatedResponse, ApiError } from '@/types/api'; + +/** + * API service for fetching campaigns and datasets from the backend. + * + * All methods return typed promises. When the backend endpoints + * are not yet available, errors are caught and logged with + * meaningful messages so the frontend degrades gracefully. + * + * Expected backend endpoints (to be implemented): + * - GET /api/v1/campaigns → list all campaigns + * - GET /api/v1/campaigns/:id → get a single campaign + * - GET /api/v1/campaigns/:id/datasets → list datasets for a campaign + * - GET /api/v1/datasets → list all datasets + * - GET /api/v1/datasets/:id → get a single dataset + */ + +/** + * Generic fetch wrapper with error handling + */ +async function apiFetch(endpoint: string): Promise { + const url = `${API_V1_URL}${endpoint}`; + + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + const error: ApiError = { + status: response.status, + message: response.statusText, + }; + + try { + const body = await response.json(); + error.message = body.message || error.message; + error.details = body.details; + } catch { + // Response body wasn't JSON — keep the status text + } + + throw error; + } + + return response.json() as Promise; +} + +// ─── Campaigns ─────────────────────────────────────────────────────────── + +/** + * Fetch all campaigns (paginated) + * GET /api/v1/campaigns?page=1&pageSize=50 + */ +export async function fetchCampaigns( + page = 1, + pageSize = 50, +): Promise> { + return apiFetch>( + `/campaigns?page=${page}&pageSize=${pageSize}`, + ); +} + +/** + * Fetch a single campaign by ID + * GET /api/v1/campaigns/:id + */ +export async function fetchCampaignById(id: number): Promise { + return apiFetch(`/campaigns/${id}`); +} + +// ─── Datasets ──────────────────────────────────────────────────────────── + +/** + * Fetch all datasets (paginated) + * GET /api/v1/datasets?page=1&pageSize=50 + */ +export async function fetchDatasets( + page = 1, + pageSize = 50, +): Promise> { + return apiFetch>( + `/datasets?page=${page}&pageSize=${pageSize}`, + ); +} + +/** + * Fetch a single dataset by ID + * GET /api/v1/datasets/:id + */ +export async function fetchDatasetById(id: number): Promise { + return apiFetch(`/datasets/${id}`); +} + +/** + * Fetch all datasets belonging to a specific campaign + * GET /api/v1/campaigns/:campaignId/datasets + */ +export async function fetchDatasetsByCampaign( + campaignId: number, +): Promise { + return apiFetch(`/campaigns/${campaignId}/datasets`); +} diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 0000000..b05ff90 --- /dev/null +++ b/src/services/config.ts @@ -0,0 +1,11 @@ +/** + * API configuration + * Centralizes the backend URL so it's easy to change per environment. + */ + +/** Base URL for the dashboard backend API */ +export const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000'; + +/** Full prefix for v1 API endpoints */ +export const API_V1_URL = `${API_BASE_URL}/api/v1`; diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..ca54294 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,81 @@ +/** + * Type definitions for Campaign and Dataset entities + * returned by the dashboard_server backend. + * + * These types are derived from the backend's Sequelize models + * and the expected API response shape. They will be refined + * once the backend endpoints are finalized. + */ + +/** A geographic coordinate pair */ +export interface LatLng { + lat: number; + lng: number; +} + +/** GeoJSON-compatible geometry for map rendering */ +export interface CampaignGeometry { + type: 'Point' | 'Polygon' | 'MultiPolygon' | 'LineString'; + coordinates: number[] | number[][] | number[][][] | number[][][][]; +} + +/** + * A Campaign represents a conservation/land project. + * Campaigns are the top-level entity that users see on the map. + */ +export interface Campaign { + id: number; + name: string; + description: string; + status: 'active' | 'planning' | 'completed' | 'on_hold'; + partner: string; + location: string; + acreage: number; + watershed: string; + projectType: string; + fundingSource: string; + startDate: string; + endDate?: string; + geometry?: CampaignGeometry; + center?: LatLng; + createdAt: string; + updatedAt: string; +} + +/** + * A Dataset is a data layer associated with a Campaign. + * Datasets contain the actual geospatial features (GeoJSON) + * that are rendered on the map. + */ +export interface Dataset { + id: number; + campaignId: number; + name: string; + description: string; + type: 'species' | 'water' | 'soil' | 'events' | 'boundary'; + /** GeoJSON FeatureCollection as a raw object */ + geojson: GeoJSON.FeatureCollection; + visible: boolean; + createdAt: string; + updatedAt: string; +} + +/** + * Standard paginated API response wrapper + */ +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +/** + * Standard API error response + */ +export interface ApiError { + status: number; + message: string; + details?: string; +}