diff --git a/screenshots/screenshot/archive-sidebar.png b/screenshots/screenshot/archive-sidebar.png index 9786da40..3a57437f 100644 Binary files a/screenshots/screenshot/archive-sidebar.png and b/screenshots/screenshot/archive-sidebar.png differ diff --git a/screenshots/screenshot/create-project-dialog.png b/screenshots/screenshot/create-project-dialog.png index e3d4ba75..7f58e5f9 100644 Binary files a/screenshots/screenshot/create-project-dialog.png and b/screenshots/screenshot/create-project-dialog.png differ diff --git a/screenshots/screenshot/home-project-empty-cloud.png b/screenshots/screenshot/home-project-empty-cloud.png index 9786da40..3a57437f 100644 Binary files a/screenshots/screenshot/home-project-empty-cloud.png and b/screenshots/screenshot/home-project-empty-cloud.png differ diff --git a/screenshots/screenshot/home-project-empty-mobile.png b/screenshots/screenshot/home-project-empty-mobile.png deleted file mode 100644 index 9786da40..00000000 Binary files a/screenshots/screenshot/home-project-empty-mobile.png and /dev/null differ diff --git a/screenshots/screenshot/home-project-empty-mobile.png.argos.json b/screenshots/screenshot/home-project-empty-mobile.png.argos.json deleted file mode 100644 index 25f4d005..00000000 --- a/screenshots/screenshot/home-project-empty-mobile.png.argos.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "url": "http://localhost:5173/", - "colorScheme": "light", - "mediaType": "screen", - "test": { - "id": "69583cefdee5152ccb60-b316422e095cff7171db", - "title": "home-project-empty mobile at desktop", - "titlePath": [ - "home.screenshots.ts", - "Home screen — visual screenshots", - "home-project-empty mobile at desktop" - ], - "retry": 0, - "retries": 0, - "repeat": 0, - "location": { - "file": "tests/e2e/home.screenshots.ts", - "line": 54, - "column": 7 - }, - "annotations": [] - }, - "browser": { "name": "chromium", "version": "148.0.7778.96" }, - "automationLibrary": { "version": "1.60.0", "name": "@playwright/test" }, - "sdk": { "name": "@argos-ci/playwright", "version": "7.0.5" }, - "viewport": { "width": 1440, "height": 900 }, - "transient": {} -} diff --git a/screenshots/screenshot/home-project-empty-sentinel.png b/screenshots/screenshot/home-project-empty-sentinel.png index 9786da40..3a57437f 100644 Binary files a/screenshots/screenshot/home-project-empty-sentinel.png and b/screenshots/screenshot/home-project-empty-sentinel.png differ diff --git a/screenshots/screenshot/home-skeleton.png b/screenshots/screenshot/home-skeleton.png index 5bc6819a..bdc33a8d 100644 Binary files a/screenshots/screenshot/home-skeleton.png and b/screenshots/screenshot/home-skeleton.png differ diff --git a/screenshots/screenshot/language-selector-open.png b/screenshots/screenshot/language-selector-open.png index 650f22fa..06a62c30 100644 Binary files a/screenshots/screenshot/language-selector-open.png and b/screenshots/screenshot/language-selector-open.png differ diff --git a/screenshots/screenshot/mobile-observation-detail.png b/screenshots/screenshot/mobile-observation-detail.png new file mode 100644 index 00000000..6d4e21f8 Binary files /dev/null and b/screenshots/screenshot/mobile-observation-detail.png differ diff --git a/screenshots/screenshot/mobile-settings.png.argos.json b/screenshots/screenshot/mobile-observation-detail.png.argos.json similarity index 72% rename from screenshots/screenshot/mobile-settings.png.argos.json rename to screenshots/screenshot/mobile-observation-detail.png.argos.json index a688db31..aa5c2e7f 100644 --- a/screenshots/screenshot/mobile-settings.png.argos.json +++ b/screenshots/screenshot/mobile-observation-detail.png.argos.json @@ -1,21 +1,21 @@ { - "url": "http://localhost:5173/settings", + "url": "http://localhost:5173/data/observations/nonexistent-id", "colorScheme": "light", "mediaType": "screen", "test": { - "id": "99fb1864c5a05941a39e-216675ff9df5f526e28e", - "title": "mobile-settings light", + "id": "99fb1864c5a05941a39e-a72832ff12247d95f3c8", + "title": "mobile-observation-detail light", "titlePath": [ "mobile-audit.screenshots.ts", "Mobile audit screenshots", - "mobile-settings light" + "mobile-observation-detail light" ], "retry": 0, "retries": 0, "repeat": 0, "location": { "file": "tests/e2e/mobile-audit.screenshots.ts", - "line": 207, + "line": 146, "column": 3 }, "annotations": [] diff --git a/screenshots/screenshot/mobile-settings.png b/screenshots/screenshot/mobile-settings.png deleted file mode 100644 index 41384e6b..00000000 Binary files a/screenshots/screenshot/mobile-settings.png and /dev/null differ diff --git a/screenshots/screenshot/settings-backup-restore.png b/screenshots/screenshot/settings-backup-restore.png index a2ba9d6f..0355316e 100644 Binary files a/screenshots/screenshot/settings-backup-restore.png and b/screenshots/screenshot/settings-backup-restore.png differ diff --git a/screenshots/screenshot/settings-clear-data.png b/screenshots/screenshot/settings-clear-data.png index db0d0cda..2d127335 100644 Binary files a/screenshots/screenshot/settings-clear-data.png and b/screenshots/screenshot/settings-clear-data.png differ diff --git a/screenshots/screenshot/settings-cloud.png b/screenshots/screenshot/settings-cloud.png index 28841504..01e801ab 100644 Binary files a/screenshots/screenshot/settings-cloud.png and b/screenshots/screenshot/settings-cloud.png differ diff --git a/screenshots/screenshot/settings-invite-form.png b/screenshots/screenshot/settings-invite-form.png index a2ba9d6f..0355316e 100644 Binary files a/screenshots/screenshot/settings-invite-form.png and b/screenshots/screenshot/settings-invite-form.png differ diff --git a/screenshots/screenshot/settings-mobile.png b/screenshots/screenshot/settings-mobile.png index 99d258fc..01e801ab 100644 Binary files a/screenshots/screenshot/settings-mobile.png and b/screenshots/screenshot/settings-mobile.png differ diff --git a/screenshots/screenshot/settings-sentinel.png b/screenshots/screenshot/settings-sentinel.png index 28841504..99d258fc 100644 Binary files a/screenshots/screenshot/settings-sentinel.png and b/screenshots/screenshot/settings-sentinel.png differ diff --git a/src/app/router.tsx b/src/app/router.tsx index 164a7053..22e1a64c 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -8,6 +8,7 @@ import { import { AuthenticatedLayout } from '@/components/layout/authenticated-layout'; import { AlertDetailScreen } from '@/screens/AlertDetailScreen'; import { AlertsScreen } from '@/screens/AlertsScreen'; +import { CategoriesEditorScreen } from '@/screens/CategoriesEditor'; import { CreateAlertScreen } from '@/screens/CreateAlertScreen'; import { DataScreen } from '@/screens/DataScreen'; import { HomeScreen } from '@/screens/Home/HomeScreen'; @@ -93,6 +94,18 @@ const settingsRoute = createRoute({ component: SettingsScreen, }); +const categoriesRoute = createRoute({ + getParentRoute: () => _authenticatedRoute, + path: '/categories', + component: CategoriesEditorScreen, +}); + +const categoryDetailRoute = createRoute({ + getParentRoute: () => _authenticatedRoute, + path: '/categories/$categoryId', + component: CategoriesEditorScreen, +}); + const inviteRoute = createRoute({ getParentRoute: () => rootRoute, path: '/invite', @@ -112,6 +125,8 @@ const routeTree = rootRoute.addChildren([ createAlertRoute, alertDetailRoute, settingsRoute, + categoriesRoute, + categoryDetailRoute, ]), ]); @@ -136,6 +151,8 @@ export { createAlertRoute, alertDetailRoute, settingsRoute, + categoriesRoute, + categoryDetailRoute, }; // Export route tree for testing diff --git a/src/components/layout/authenticated-layout.tsx b/src/components/layout/authenticated-layout.tsx index f85fb1ef..30ea254d 100644 --- a/src/components/layout/authenticated-layout.tsx +++ b/src/components/layout/authenticated-layout.tsx @@ -26,6 +26,7 @@ const messages = defineMessages({ data: { id: 'data.title', defaultMessage: 'Data' }, alerts: { id: 'alerts.title', defaultMessage: 'Alerts' }, map: { id: 'map.title', defaultMessage: 'Map' }, + categories: { id: 'categories.title', defaultMessage: 'Categories' }, settings: { id: 'settings.title', defaultMessage: 'Settings' }, appTitle: { id: 'app.title', defaultMessage: 'CoMapeo Cloud' }, }); @@ -112,6 +113,21 @@ function SettingsIcon(): ReactNode { ); } +function CategoriesIcon(): ReactNode { + return ( + + ); +} + /** Normalize a URL for comparison by stripping trailing slashes. */ function normalizeUrl(url: string): string { return url.trim().replace(/\/+$/, ''); @@ -250,6 +266,11 @@ function AuthenticatedLayoutInner() { label: intl.formatMessage(messages.map), icon: , }, + { + path: '/categories', + label: intl.formatMessage(messages.categories), + icon: , + }, { path: '/settings', label: intl.formatMessage(messages.settings), diff --git a/src/hooks/useApiPresets.ts b/src/hooks/useApiPresets.ts new file mode 100644 index 00000000..3810656c --- /dev/null +++ b/src/hooks/useApiPresets.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { RequestConfig } from '@/lib/api-client'; +import { apiClient } from '@/lib/api-client'; +import { useAuthStore } from '@/stores/auth-store'; + +/** Fetches presets directly from the archive server API (wire format). + * Uses the project's remoteId (server projectPublicId, base32) — NOT the + * local DB id (hex). Required because the server route parameter is validated + * against a base32 regex pattern. + * + * Passes the active archive server's {@link RequestConfig} so the request is + * routed through the `/api` proxy with the archive-target header. Without it, + * `resolveApiRequest` falls back to `window.location.origin`, which on the + * Cloudflare Pages deployment returns the SPA index.html (HTTP 200) instead of + * JSON — surfacing as a spurious "Failed to load categories" error. */ +export function useApiPresets(projectRemoteId: string | null) { + const baseUrl = useAuthStore((s) => s.baseUrl); + const token = useAuthStore((s) => s.token); + + return useQuery({ + queryKey: ['api-presets', projectRemoteId, baseUrl, token], + queryFn: () => { + const config: RequestConfig = { baseUrl: baseUrl!, token: token ?? '' }; + return apiClient.getPresets(projectRemoteId!, config); + }, + enabled: projectRemoteId !== null && baseUrl !== null, + select: (data) => data.data, + }); +} diff --git a/src/hooks/useCategories.ts b/src/hooks/useCategories.ts new file mode 100644 index 00000000..c2754121 --- /dev/null +++ b/src/hooks/useCategories.ts @@ -0,0 +1,127 @@ +interface PresetInput { + docId: string; + name: string; + tags: Record; + fieldRefs: Array<{ docId: string }>; + iconRef?: { docId: string }; +} + +export interface Category { + docId: string; + label: string; + fieldRefs: Array<{ docId: string; label?: string }>; + color?: string; + iconRef?: { docId: string }; +} + +export interface CategoryGroup { + type: string; + categories: Category[]; +} + +const ACCENT_RE = /[\u0300-\u036f]/g; + +function stripDiacritics(s: string): string { + return s.normalize('NFD').replace(ACCENT_RE, ''); +} + +function normalizeSearch(s: string): string { + return stripDiacritics(s).toLowerCase(); +} + +function resolveLocaleName( + tags: Record, + locale: string, + sourceName: string, +): string { + const localeKey = `name:${locale}`; + if (typeof tags[localeKey] === 'string') return tags[localeKey] as string; + + if (locale !== 'en') { + const enKey = 'name:en'; + if (typeof tags[enKey] === 'string') return tags[enKey] as string; + } + + return sourceName; +} + +function matchesSearch( + preset: PresetInput, + searchNormalized: string, + locale: string, + fieldLabels?: Map, +): boolean { + const label = resolveLocaleName(preset.tags, locale, preset.name); + if (normalizeSearch(label).includes(searchNormalized)) return true; + + for (const ref of preset.fieldRefs) { + const resolvedLabel = fieldLabels?.get(ref.docId); + if ( + resolvedLabel && + normalizeSearch(resolvedLabel).includes(searchNormalized) + ) { + return true; + } + } + + return false; +} + +export function normalizeCategories( + data: PresetInput[], + locale: string, + searchQuery: string, + fieldLabels?: Map, +): CategoryGroup[] { + if (data.length === 0) return []; + + const searchNormalized = normalizeSearch(searchQuery); + + const groups = new Map(); + + for (const preset of data) { + if ( + searchNormalized && + !matchesSearch(preset, searchNormalized, locale, fieldLabels) + ) { + continue; + } + + const rawType = preset.tags.type; + const type = + typeof rawType === 'string' && rawType.trim() !== '' ? rawType : ''; // sentinel — translated at render (CategoryGrid) + + const label = resolveLocaleName(preset.tags, locale, preset.name); + + if (!groups.has(type)) { + groups.set(type, []); + } + groups.get(type)!.push({ + docId: preset.docId, + label, + fieldRefs: preset.fieldRefs.map((ref) => ({ + docId: ref.docId, + label: fieldLabels?.get(ref.docId), + })), + color: + typeof preset.tags.color === 'string' ? preset.tags.color : undefined, + iconRef: + typeof preset.iconRef === 'object' && + preset.iconRef !== null && + 'docId' in preset.iconRef + ? { docId: (preset.iconRef as { docId: string }).docId } + : undefined, + }); + } + + const result: CategoryGroup[] = []; + const sortedTypes = [...groups.keys()].sort(); + + for (const type of sortedTypes) { + const categories = groups.get(type)!; + categories.sort((a, b) => a.label.localeCompare(b.label)); + result.push({ type, categories }); + } + + return result; +} diff --git a/src/hooks/useCategorySets.ts b/src/hooks/useCategorySets.ts new file mode 100644 index 00000000..b1f6e677 --- /dev/null +++ b/src/hooks/useCategorySets.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { type CategorySetRecord, categoriesDb } from '@/lib/categories-db'; + +export interface CategorySetSummary { + setId: string; + name: string; + importedAt: string; + categoryCount: number; +} + +function toSummary(record: CategorySetRecord): CategorySetSummary { + return { + setId: record.setId, + name: record.name, + importedAt: record.importedAt, + categoryCount: Object.keys(record.categories).length, + }; +} + +export function useCategorySets() { + const [sets, setSets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + categoriesDb.categorySets + .toArray() + .then((records) => { + if (!cancelled) { + setSets(records.map(toSummary)); + setError(null); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load sets'); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const refresh = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const records = await categoriesDb.categorySets.toArray(); + setSets(records.map(toSummary)); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to refresh sets'); + } finally { + setIsLoading(false); + } + }, []); + + return { sets, isLoading, error, refresh }; +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index be6e3def..bee0be73 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -155,6 +155,99 @@ "audioPlayer.play": { "defaultMessage": "Play" }, + "categories.detail.backToCategories": { + "defaultMessage": "← Categories" + }, + "categories.detail.fields": { + "defaultMessage": "{count, plural, one {# field} other {# fields}}" + }, + "categories.detail.selectCategory": { + "defaultMessage": "Select a category" + }, + "categories.empty": { + "defaultMessage": "No categories found" + }, + "categories.error": { + "defaultMessage": "Failed to load categories. Please try again." + }, + "categories.fieldCount": { + "defaultMessage": "{count, plural, one {# field} other {# fields}}" + }, + "categories.fieldType.date": { + "defaultMessage": "Date" + }, + "categories.fieldType.number": { + "defaultMessage": "Number" + }, + "categories.fieldType.unknown": { + "defaultMessage": "Unknown type" + }, + "categories.importSet.cancel": { + "defaultMessage": "Cancel" + }, + "categories.importSet.confirmReplace": { + "defaultMessage": "Replace" + }, + "categories.importSet.description": { + "defaultMessage": "Upload a .comapeocat file to import a category set." + }, + "categories.importSet.errorAnnouncement": { + "defaultMessage": "Import failed" + }, + "categories.importSet.failedToRead": { + "defaultMessage": "Failed to read file" + }, + "categories.importSet.fileLabel": { + "defaultMessage": "Choose .comapeocat file" + }, + "categories.importSet.importButton": { + "defaultMessage": "Import" + }, + "categories.importSet.importError": { + "defaultMessage": "Failed to import: {error}" + }, + "categories.importSet.imported": { + "defaultMessage": "Category set \"{name}\" imported successfully." + }, + "categories.importSet.invalidJson": { + "defaultMessage": "Invalid JSON: {error}" + }, + "categories.importSet.replaceDescription": { + "defaultMessage": "A set with ID \"{setId}\" already exists. Importing will replace it." + }, + "categories.importSet.replaceTitle": { + "defaultMessage": "Replace existing set?" + }, + "categories.importSet.successAnnouncement": { + "defaultMessage": "Import succeeded" + }, + "categories.importSet.title": { + "defaultMessage": "Import Category Set" + }, + "categories.importSet.validationError": { + "defaultMessage": "Validation error: {error}" + }, + "categories.noResults": { + "defaultMessage": "No categories match your search" + }, + "categories.retry": { + "defaultMessage": "Retry" + }, + "categories.search": { + "defaultMessage": "Search categories..." + }, + "categories.selectProject": { + "defaultMessage": "Select a project to view categories" + }, + "categories.title": { + "defaultMessage": "Categories" + }, + "categories.uncategorized": { + "defaultMessage": "Uncategorized" + }, + "categories.untitledProject": { + "defaultMessage": "Untitled Project" + }, "common.refreshAria": { "defaultMessage": "Refresh data" }, diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 82e49e4c..1dc9cc8d 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -41,6 +41,37 @@ "audioPlayer.loading": "Cargando audio...", "audioPlayer.pause": "Pausar", "audioPlayer.play": "Reproducir", + "categories.detail.backToCategories": "← Categorías", + "categories.detail.fields": "{count, plural, one {# campo} other {# campos}}", + "categories.detail.selectCategory": "Seleccione una categoría", + "categories.empty": "No se encontraron categorías", + "categories.error": "Error al cargar categorías. Inténtelo de nuevo.", + "categories.fieldCount": "{count, plural, one {# campo} other {# campos}}", + "categories.fieldType.date": "Fecha", + "categories.fieldType.number": "Número", + "categories.fieldType.unknown": "Tipo desconocido", + "categories.importSet.cancel": "Cancelar", + "categories.importSet.confirmReplace": "Reemplazar", + "categories.importSet.description": "Cargue un archivo .comapeocat para importar un conjunto de categorías.", + "categories.importSet.errorAnnouncement": "Error en la importación", + "categories.importSet.failedToRead": "Error al leer el archivo", + "categories.importSet.fileLabel": "Elegir archivo .comapeocat", + "categories.importSet.importButton": "Importar", + "categories.importSet.importError": "Error al importar: {error}", + "categories.importSet.imported": "Conjunto de categorías \"{name}\" importado exitosamente.", + "categories.importSet.invalidJson": "JSON inválido: {error}", + "categories.importSet.replaceDescription": "Ya existe un conjunto con ID \"{setId}\". La importación lo reemplazará.", + "categories.importSet.replaceTitle": "¿Reemplazar conjunto existente?", + "categories.importSet.successAnnouncement": "Importación exitosa", + "categories.importSet.title": "Importar Conjunto de Categorías", + "categories.importSet.validationError": "Error de validación: {error}", + "categories.noResults": "Ninguna categoría coincide con su búsqueda", + "categories.retry": "Reintentar", + "categories.search": "Buscar categorías...", + "categories.selectProject": "Seleccione un proyecto para ver categorías", + "categories.title": "Categorías", + "categories.uncategorized": "Sin categoría", + "categories.untitledProject": "Proyecto sin título", "dashboard.banner.active": "Activo", "dashboard.banner.defaultDescription": "Un entorno controlado para la integración de nuevos guardabosques y pruebas de protocolos de recopilación de datos de campo.", "dashboard.banner.deleteProject": "Eliminar Proyecto", @@ -288,7 +319,10 @@ "invite.invalid": "No se pudo aceptar esta invitación. La URL o código puede ser inválido.", "invite.progress.connected": "¡Conectado!", "invite.progress.redirecting": "Redirigiendo...", + "map.activeMap.badge": "Mapa sin conexión activo: {name}", + "map.activeMap.onlineBadge": "Mapa activo (en línea): {name}", "map.basemap.label": "Mapa base", + "map.basemapSwitcher.disabledHint": "Mapa base usado cuando el mapa sin conexión está desactivado", "map.bounds.antimeridianCrossing": "La selección no puede cruzar el meridiano de 180°", "map.bounds.areaUpdated": "Área del mapa actualizada", "map.bounds.cancelDraw": "Cancelar dibujo", @@ -297,8 +331,8 @@ "map.bounds.drawingInstructionCancel": "Cancelar", "map.bounds.east": "Este", "map.bounds.frameInstruction": "Desplace y acerque hasta que el área quepa dentro del marco", - "map.bounds.invalidLatOrder": "El norte debe ser mayor que el sur", "map.bounds.invalidCoordinates": "Las coordenadas deben ser números válidos", + "map.bounds.invalidLatOrder": "El norte debe ser mayor que el sur", "map.bounds.invalidLatitude": "La latitud debe estar entre -85.0511 y 85.0511", "map.bounds.invalidLngOrder": "El este debe ser mayor que el oeste", "map.bounds.invalidLongitude": "La longitud debe estar entre -180 y 180", @@ -314,15 +348,14 @@ "map.bounds.zeroAreaBounds": "El área seleccionada no tiene tamaño. Añada más observaciones primero.", "map.download.button": "Descargar mapa", "map.download.cancel": "Cancelar", + "map.download.concurrencyWarning": "Otro mapa se está descargando. Por favor espere a que termine antes de iniciar una nueva descarga.", "map.download.confirmLarge": "Este mapa está estimado en {size}. Puede tomar un tiempo. ¿Continuar?", "map.download.error": "Descarga fallida: {error}", - "map.download.interrupted": "Una descarga anterior fue interrumpida. Puede intentar de nuevo.", - "map.download.missing": "El paquete del mapa guardado falta o no se puede leer. Puede regenerarlo.", - "map.download.unknownError": "Error desconocido", - "map.download.concurrencyWarning": "Otro mapa se está descargando. Por favor espere a que termine antes de iniciar una nueva descarga.", "map.download.estimatedSize": "Tamaño estimado: {size}", "map.download.export": "Descargar archivo SMP", + "map.download.interrupted": "Una descarga anterior fue interrumpida. Puede intentar de nuevo.", "map.download.maxRetries": "Máximo de reintentos alcanzado", + "map.download.missing": "El paquete del mapa guardado falta o no se puede leer. Puede regenerarlo.", "map.download.progress": "Descargando… {downloaded} de {total} teselas ({bytes})", "map.download.ready": "Mapa descargado exitosamente ({size})", "map.download.retry": "Reintentar", @@ -330,6 +363,7 @@ "map.download.starting": "Iniciando descarga…", "map.download.storageWarning": "Espacio de almacenamiento insuficiente. {available} disponibles, {estimated} estimados.", "map.download.tryAnyway": "Intentar de todos modos", + "map.download.unknownError": "Error desconocido", "map.nameDialog.cancel": "Cancelar", "map.nameDialog.description": "Guarde esta configuración de mapa como borrador. Luego puede descargarlo como un archivo .smp.", "map.nameDialog.nameLabel": "Nombre del mapa", @@ -380,10 +414,10 @@ "map.stylePicker.typeStyle": "JSON de estilo", "map.stylePicker.useCustomUrl": "Usar URL personalizada", "map.title": "Mapa", + "map.zoom.downloadNote": "Las descargas incluyen todos los niveles de zoom desde 0 hasta el máximo.", "map.zoom.invalidRange": "El zoom máximo debe ser mayor o igual que el zoom mínimo", "map.zoom.invalidZoom": "El zoom debe estar entre 0 y 22", "map.zoom.maxZoom": "Zoom máximo", - "map.zoom.downloadNote": "Las descargas incluyen todos los niveles de zoom desde 0 hasta el máximo.", "map.zoom.minZoom": "Zoom mínimo", "map.zoom.title": "Rango de zoom", "mapContainer.viewOnly": "Solo visualización", @@ -475,8 +509,5 @@ "theme.ariaSetSystem": "Usar tema del sistema", "theme.dark": "Oscuro", "theme.light": "Claro", - "theme.system": "Sistema", - "map.activeMap.badge": "Mapa sin conexión activo: {name}", - "map.activeMap.onlineBadge": "Mapa activo (en línea): {name}", - "map.basemapSwitcher.disabledHint": "Mapa base usado cuando el mapa sin conexión está desactivado" + "theme.system": "Sistema" } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 6034f1c0..fcfb75f0 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -41,6 +41,37 @@ "audioPlayer.loading": "Carregando áudio...", "audioPlayer.pause": "Pausar", "audioPlayer.play": "Reproduzir", + "categories.detail.backToCategories": "← Categorias", + "categories.detail.fields": "{count, plural, one {# campo} other {# campos}}", + "categories.detail.selectCategory": "Selecione uma categoria", + "categories.empty": "Nenhuma categoria encontrada", + "categories.error": "Falha ao carregar categorias. Tente novamente.", + "categories.fieldCount": "{count, plural, one {# campo} other {# campos}}", + "categories.fieldType.date": "Data", + "categories.fieldType.number": "Número", + "categories.fieldType.unknown": "Tipo desconhecido", + "categories.importSet.cancel": "Cancelar", + "categories.importSet.confirmReplace": "Substituir", + "categories.importSet.description": "Carregue um arquivo .comapeocat para importar um conjunto de categorias.", + "categories.importSet.errorAnnouncement": "Falha na importação", + "categories.importSet.failedToRead": "Falha ao ler o arquivo", + "categories.importSet.fileLabel": "Escolher arquivo .comapeocat", + "categories.importSet.importButton": "Importar", + "categories.importSet.importError": "Falha ao importar: {error}", + "categories.importSet.imported": "Conjunto de categorias \"{name}\" importado com sucesso.", + "categories.importSet.invalidJson": "JSON inválido: {error}", + "categories.importSet.replaceDescription": "Um conjunto com ID \"{setId}\" já existe. A importação irá substituí-lo.", + "categories.importSet.replaceTitle": "Substituir conjunto existente?", + "categories.importSet.successAnnouncement": "Importação concluída", + "categories.importSet.title": "Importar Conjunto de Categorias", + "categories.importSet.validationError": "Erro de validação: {error}", + "categories.noResults": "Nenhuma categoria corresponde à sua pesquisa", + "categories.retry": "Tentar novamente", + "categories.search": "Pesquisar categorias...", + "categories.selectProject": "Selecione um projeto para ver categorias", + "categories.title": "Categorias", + "categories.uncategorized": "Sem categoria", + "categories.untitledProject": "Projeto sem título", "dashboard.banner.active": "Ativo", "dashboard.banner.defaultDescription": "Um ambiente controlado para integração de novos guardas-parques e teste de protocolos de coleta de dados em campo.", "dashboard.banner.deleteProject": "Excluir Projeto", @@ -288,7 +319,10 @@ "invite.invalid": "Não foi possível aceitar este convite. A URL ou código pode ser inválido.", "invite.progress.connected": "Conectado!", "invite.progress.redirecting": "Redirecionando...", + "map.activeMap.badge": "Mapa offline ativo: {name}", + "map.activeMap.onlineBadge": "Mapa ativo (online): {name}", "map.basemap.label": "Mapa base", + "map.basemapSwitcher.disabledHint": "Mapa base usado quando o mapa offline está desativado", "map.bounds.antimeridianCrossing": "A seleção não pode cruzar o meridiano de 180°", "map.bounds.areaUpdated": "Área do mapa atualizada", "map.bounds.cancelDraw": "Cancelar desenho", @@ -297,8 +331,8 @@ "map.bounds.drawingInstructionCancel": "Cancelar", "map.bounds.east": "Leste", "map.bounds.frameInstruction": "Desloque e amplie até a área caber dentro do quadro", - "map.bounds.invalidLatOrder": "O norte deve ser maior que o sul", "map.bounds.invalidCoordinates": "As coordenadas devem ser números válidos", + "map.bounds.invalidLatOrder": "O norte deve ser maior que o sul", "map.bounds.invalidLatitude": "A latitude deve estar entre -85.0511 e 85.0511", "map.bounds.invalidLngOrder": "O leste deve ser maior que o oeste", "map.bounds.invalidLongitude": "A longitude deve estar entre -180 e 180", @@ -314,15 +348,14 @@ "map.bounds.zeroAreaBounds": "A área selecionada não tem tamanho. Adicione mais observações primeiro.", "map.download.button": "Baixar mapa", "map.download.cancel": "Cancelar", + "map.download.concurrencyWarning": "Outro mapa está sendo baixado. Por favor aguarde até que termine antes de iniciar um novo download.", "map.download.confirmLarge": "Este mapa está estimado em {size}. Pode levar um tempo. Continuar?", "map.download.error": "Falha no download: {error}", - "map.download.interrupted": "Um download anterior foi interrompido. Você pode tentar novamente.", - "map.download.missing": "O pacote do mapa salvo está faltando ou é ilegível. Você pode regenerá-lo.", - "map.download.unknownError": "Erro desconhecido", - "map.download.concurrencyWarning": "Outro mapa está sendo baixado. Por favor aguarde até que termine antes de iniciar um novo download.", "map.download.estimatedSize": "Tamanho estimado: {size}", "map.download.export": "Baixar arquivo SMP", + "map.download.interrupted": "Um download anterior foi interrompido. Você pode tentar novamente.", "map.download.maxRetries": "Máximo de tentativas atingido", + "map.download.missing": "O pacote do mapa salvo está faltando ou é ilegível. Você pode regenerá-lo.", "map.download.progress": "Baixando… {downloaded} de {total} tiles ({bytes})", "map.download.ready": "Mapa baixado com sucesso ({size})", "map.download.retry": "Tentar novamente", @@ -330,6 +363,7 @@ "map.download.starting": "Iniciando download…", "map.download.storageWarning": "Espaço de armazenamento insuficiente. {available} disponível, {estimated} estimado.", "map.download.tryAnyway": "Tentar mesmo assim", + "map.download.unknownError": "Erro desconhecido", "map.nameDialog.cancel": "Cancelar", "map.nameDialog.description": "Salve esta configuração de mapa como rascunho. Depois você pode baixá-lo como um arquivo .smp.", "map.nameDialog.nameLabel": "Nome do mapa", @@ -380,10 +414,10 @@ "map.stylePicker.typeStyle": "JSON de estilo", "map.stylePicker.useCustomUrl": "Usar URL personalizada", "map.title": "Mapa", + "map.zoom.downloadNote": "Os downloads incluem todos os níveis de zoom de 0 ao máximo.", "map.zoom.invalidRange": "O zoom máximo deve ser maior ou igual ao zoom mínimo", "map.zoom.invalidZoom": "O zoom deve estar entre 0 e 22", "map.zoom.maxZoom": "Zoom máximo", - "map.zoom.downloadNote": "Os downloads incluem todos os níveis de zoom de 0 ao máximo.", "map.zoom.minZoom": "Zoom mínimo", "map.zoom.title": "Intervalo de zoom", "mapContainer.viewOnly": "Apenas visualização", @@ -475,8 +509,5 @@ "theme.ariaSetSystem": "Usar tema do sistema", "theme.dark": "Escuro", "theme.light": "Claro", - "theme.system": "Sistema", - "map.activeMap.badge": "Mapa offline ativo: {name}", - "map.activeMap.onlineBadge": "Mapa ativo (online): {name}", - "map.basemapSwitcher.disabledHint": "Mapa base usado quando o mapa offline está desativado" + "theme.system": "Sistema" } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 0b551253..876fafc2 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -152,6 +152,7 @@ export const apiClient = { const request = resolveApiRequest(config); const response = await fetch(`${request.baseUrl}/info`, { headers: { ...request.extraHeaders }, + cache: 'no-store', }); return handleResponse(response, serverInfoResponseSchema, config); } catch (error) { @@ -177,6 +178,7 @@ export const apiClient = { const request = resolveApiRequest(config); const response = await fetch(`${request.baseUrl}/projects`, { headers: { ...getAuthHeaders(config), ...request.extraHeaders }, + cache: 'no-store', }); return handleResponse(response, projectsResponseSchema, config); } catch (error) { @@ -190,7 +192,10 @@ export const apiClient = { const request = resolveApiRequest(config); const response = await fetch( `${request.baseUrl}/projects/${encodeURIComponent(projectId)}`, - { headers: { ...getAuthHeaders(config), ...request.extraHeaders } }, + { + headers: { ...getAuthHeaders(config), ...request.extraHeaders }, + cache: 'no-store', + }, ); return handleResponse(response, projectDetailResponseSchema, config); } catch (error) { @@ -204,7 +209,10 @@ export const apiClient = { const request = resolveApiRequest(config); const response = await fetch( `${request.baseUrl}/projects/${encodeURIComponent(projectId)}/observations`, - { headers: { ...getAuthHeaders(config), ...request.extraHeaders } }, + { + headers: { ...getAuthHeaders(config), ...request.extraHeaders }, + cache: 'no-store', + }, ); return handleResponse(response, observationsResponseSchema, config); } catch (error) { @@ -218,7 +226,10 @@ export const apiClient = { const request = resolveApiRequest(config); const response = await fetch( `${request.baseUrl}/projects/${encodeURIComponent(projectId)}/track`, - { headers: { ...getAuthHeaders(config), ...request.extraHeaders } }, + { + headers: { ...getAuthHeaders(config), ...request.extraHeaders }, + cache: 'no-store', + }, ); return await handleResponse(response, tracksResponseSchema, config); } catch (error) { @@ -238,7 +249,10 @@ export const apiClient = { const request = resolveApiRequest(config); const response = await fetch( `${request.baseUrl}/projects/${encodeURIComponent(projectId)}${ALERTS_PATH}`, - { headers: { ...getAuthHeaders(config), ...request.extraHeaders } }, + { + headers: { ...getAuthHeaders(config), ...request.extraHeaders }, + cache: 'no-store', + }, ); return handleResponse(response, alertsResponseSchema, config); } catch (error) { @@ -302,7 +316,10 @@ export const apiClient = { const request = resolveApiRequest(config); const response = await fetch( `${request.baseUrl}/projects/${encodeURIComponent(projectId)}/preset`, - { headers: { ...getAuthHeaders(config), ...request.extraHeaders } }, + { + headers: { ...getAuthHeaders(config), ...request.extraHeaders }, + cache: 'no-store', + }, ); return await handleResponse(response, presetsResponseSchema, config); } catch (error) { @@ -322,7 +339,10 @@ export const apiClient = { const request = resolveApiRequest(config); const response = await fetch( `${request.baseUrl}/projects/${encodeURIComponent(projectId)}/field`, - { headers: { ...getAuthHeaders(config), ...request.extraHeaders } }, + { + headers: { ...getAuthHeaders(config), ...request.extraHeaders }, + cache: 'no-store', + }, ); return await handleResponse(response, fieldsResponseSchema, config); } catch (error) { diff --git a/src/lib/categories-db.ts b/src/lib/categories-db.ts new file mode 100644 index 00000000..502ad03d --- /dev/null +++ b/src/lib/categories-db.ts @@ -0,0 +1,77 @@ +import Dexie, { type EntityTable } from 'dexie'; +import * as v from 'valibot'; + +import { comapeoCatSchema } from '@/lib/schemas/preset'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CategorySetRecord { + setId: string; + name: string; + metadata?: Record; + categories: Record; + fields: Record; + importedAt: string; +} + +// --------------------------------------------------------------------------- +// Database class +// --------------------------------------------------------------------------- + +class CategoriesDB extends Dexie { + categorySets!: EntityTable; + + constructor() { + super('comapeo-categories'); + this.version(1).stores({ + categorySets: '&setId', + }); + } +} + +export const categoriesDb = new CategoriesDB(); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Validate and import a category set into the database. + * If a set with the same setId already exists, it is replaced. + */ +export async function importCategorySet( + setId: string, + name: string, + data: unknown, +): Promise { + const parsed = v.parse(comapeoCatSchema, data); + + const record: CategorySetRecord = { + setId, + name, + metadata: parsed.metadata as Record | undefined, + categories: parsed.categories as Record, + fields: parsed.fields as Record, + importedAt: new Date().toISOString(), + }; + + await categoriesDb.categorySets.put(record); +} + +/** + * Return all imported category sets. + */ +export async function getCategorySets(): Promise { + return categoriesDb.categorySets.toArray(); +} + +/** + * Return a single category set by ID, or undefined if not found. + */ +export async function getCategorySet( + setId: string, +): Promise { + return categoriesDb.categorySets.get(setId); +} diff --git a/src/lib/schemas/index.ts b/src/lib/schemas/index.ts index 11832456..5655aea9 100644 --- a/src/lib/schemas/index.ts +++ b/src/lib/schemas/index.ts @@ -15,7 +15,15 @@ export type { BasemapCategory, } from './imagery-source'; export { presetRefSchema } from './observation'; -export { presetSchema, presetsResponseSchema } from './preset'; +export { + presetSchema, + presetsResponseSchema, + metadataSchema, + importFieldOptionSchema, + fieldSchema as importFieldSchema, + categorySchema, + comapeoCatSchema, +} from './preset'; export { fieldSchema, fieldsResponseSchema } from './field'; export { trackSchema, tracksResponseSchema } from './track'; export { docRefSchema } from './refs'; diff --git a/src/lib/schemas/preset.ts b/src/lib/schemas/preset.ts index b3ba026a..5b2ce089 100644 --- a/src/lib/schemas/preset.ts +++ b/src/lib/schemas/preset.ts @@ -1,6 +1,10 @@ import * as v from 'valibot'; -import { presetRefSchema } from '@/lib/schemas/observation'; +import { docRefSchema } from '@/lib/schemas/refs'; + +// --------------------------------------------------------------------------- +// Server wire-type schemas (preset response from comapeo-cloud) +// --------------------------------------------------------------------------- const geometryTypesSchema = v.array( v.picklist(['point', 'vertex', 'line', 'area', 'relation']), @@ -17,26 +21,95 @@ const tagsSchema = v.record( ]), ); -export const presetSchema = v.object({ +export const presetSchema = v.looseObject({ docId: v.string(), - versionId: v.string(), - originalVersionId: v.string(), + versionId: v.optional(v.string()), + originalVersionId: v.optional(v.string()), schemaName: v.literal('preset'), createdAt: v.string(), updatedAt: v.string(), - links: v.array(v.string()), + links: v.optional(v.array(v.string())), deleted: v.boolean(), name: v.string(), - geometry: geometryTypesSchema, + geometry: v.optional(geometryTypesSchema), tags: tagsSchema, - addTags: tagsSchema, - removeTags: tagsSchema, - fieldRefs: v.array(presetRefSchema), - iconRef: v.optional(presetRefSchema), + addTags: v.optional(tagsSchema), + removeTags: v.optional(tagsSchema), + fieldRefs: v.array(docRefSchema), + iconRef: v.optional(docRefSchema), color: v.optional(v.pipe(v.string(), v.regex(/^#[0-9A-Fa-f]{6}$/))), - terms: v.array(v.string()), + terms: v.optional(v.array(v.string())), }); export const presetsResponseSchema = v.object({ data: v.array(presetSchema), }); + +// --------------------------------------------------------------------------- +// Import-file schemas (.comapeocat / comapeo-default-categories format) +// +// These model the upstream comapeo-default-categories JSON format, which is +// distinct from the server wire type. Category names, icon references, and +// field tagKeys are validated strictly; extensible metadata properties are +// preserved via looseObject passthrough. +// --------------------------------------------------------------------------- + +/** Metadata from a .comapeocat package (e.g. metadata.json). */ +export const metadataSchema = v.looseObject({ + name: v.string(), +}); + +/** Option for a select_one / select_multiple field. */ +export const importFieldOptionSchema = v.object({ + label: v.string(), + value: v.string(), +}); + +/** + * Field definition in the import-file format. + * + * Wire type uses `tagKey` (not `key`) and camelCase `type` values + * (`selectOne` / `selectMultiple` / `text` / `date` / `number`). + * `helperText` and `options` are optional — text/date/number fields + * typically omit them. + */ +export const fieldSchema = v.looseObject({ + tagKey: v.string(), + type: v.picklist(['text', 'selectOne', 'selectMultiple', 'date', 'number']), + label: v.string(), + helperText: v.optional(v.string()), + options: v.optional(v.array(importFieldOptionSchema)), +}); + +/** + * Category definition in the import-file format. + * + * `name`, `icon`, `color`, `fields`, `appliesTo`, and `tags` (with + * required `type` key) are all required. Additional properties are + * allowed for forward compatibility. + */ +export const categorySchema = v.looseObject({ + name: v.string(), + icon: v.string(), + color: v.pipe(v.string(), v.regex(/^#[0-9A-Fa-f]{6}$/)), + fields: v.array(v.string()), + appliesTo: v.pipe(v.array(v.string()), v.minLength(1)), + tags: v.looseObject({ + type: v.string(), + }), +}); + +/** + * Full .comapeocat import-file envelope. + * + * Mirrors the structure produced by comapeo-default-categories: + * `{ metadata?, categorySelection, categories, fields }`. + * `metadata` and `categorySelection` are optional — the core + * contract is categories + fields. + */ +export const comapeoCatSchema = v.looseObject({ + metadata: v.optional(metadataSchema), + categorySelection: v.optional(v.record(v.string(), v.array(v.string()))), + categories: v.record(v.string(), categorySchema), + fields: v.record(v.string(), fieldSchema), +}); diff --git a/src/screens/CategoriesEditor/CategoryCard.tsx b/src/screens/CategoriesEditor/CategoryCard.tsx new file mode 100644 index 00000000..300dd261 --- /dev/null +++ b/src/screens/CategoriesEditor/CategoryCard.tsx @@ -0,0 +1,82 @@ +import { defineMessages, useIntl } from 'react-intl'; + +const messages = defineMessages({ + fieldCount: { + id: 'categories.fieldCount', + defaultMessage: '{count, plural, one {# field} other {# fields}}', + }, +}); + +interface CategoryCardProps { + docId: string; + label: string; + fieldRefs: Array<{ docId: string; label?: string }>; + color?: string; + iconRef?: { docId: string }; + selected?: boolean; + onClick?: (docId: string) => void; +} + +function CategoryCard({ + docId, + label, + fieldRefs, + color, + selected, + onClick, +}: CategoryCardProps) { + const intl = useIntl(); + const fieldCount = fieldRefs.length; + const initial = label.charAt(0).toUpperCase(); + + return ( +
onClick?.(docId)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(docId); + } + }} + className={`group relative overflow-hidden rounded-card outline-none focus-visible:ring-2 focus-visible:ring-primary cursor-pointer ${ + selected ? 'ring-2 ring-primary' : '' + }`} + style={{ + backgroundColor: 'var(--color-surface-card, #fff)', + boxShadow: '0 8px 24px rgba(9,30,66,0.08)', + }} + > + {color && ( +
+ )} + +
+
+ {initial} +
+ +
+

{label}

+ + {intl.formatMessage(messages.fieldCount, { count: fieldCount })} + +
+
+
+ ); +} + +export { CategoryCard }; +export type { CategoryCardProps }; diff --git a/src/screens/CategoriesEditor/CategoryDetail.tsx b/src/screens/CategoriesEditor/CategoryDetail.tsx new file mode 100644 index 00000000..b1103aaa --- /dev/null +++ b/src/screens/CategoriesEditor/CategoryDetail.tsx @@ -0,0 +1,94 @@ +import { defineMessages, useIntl } from 'react-intl'; + +import { Link } from '@tanstack/react-router'; + +import type { Category } from '@/hooks/useCategories'; + +const messages = defineMessages({ + selectCategory: { + id: 'categories.detail.selectCategory', + defaultMessage: 'Select a category', + }, + backToCategories: { + id: 'categories.detail.backToCategories', + defaultMessage: '← Categories', + }, + fields: { + id: 'categories.detail.fields', + defaultMessage: '{count, plural, one {# field} other {# fields}}', + }, +}); + +interface CategoryDetailProps { + category: Category | null; + fieldLabels: Map; +} + +function CategoryDetail({ category, fieldLabels }: CategoryDetailProps) { + const intl = useIntl(); + + if (!category) { + return ( +
+ + {intl.formatMessage(messages.selectCategory)} + +
+ ); + } + + const initial = category.label.charAt(0).toUpperCase(); + + return ( +
+ + {intl.formatMessage(messages.backToCategories)} + + +
+ {initial} +
+ +

{category.label}

+ + {category.color && ( +
+
+ + {category.color} + +
+ )} + +
+ + {intl.formatMessage(messages.fields, { + count: category.fieldRefs.length, + })} + + {category.fieldRefs.length > 0 && ( +
    + {category.fieldRefs.map((ref) => ( +
  • + {fieldLabels.get(ref.docId) ?? ref.docId} +
  • + ))} +
+ )} +
+
+ ); +} + +export { CategoryDetail }; diff --git a/src/screens/CategoriesEditor/CategoryGrid.tsx b/src/screens/CategoriesEditor/CategoryGrid.tsx new file mode 100644 index 00000000..5dfa674a --- /dev/null +++ b/src/screens/CategoriesEditor/CategoryGrid.tsx @@ -0,0 +1,68 @@ +import { defineMessages, useIntl } from 'react-intl'; + +import type { CategoryGroup } from '@/hooks/useCategories'; +import { CategoryCard } from '@/screens/CategoriesEditor/CategoryCard'; + +const messages = defineMessages({ + noResults: { + id: 'categories.noResults', + defaultMessage: 'No categories match your search', + }, + uncategorized: { + id: 'categories.uncategorized', + defaultMessage: 'Uncategorized', + }, +}); + +interface CategoryGridProps { + groups: CategoryGroup[]; + selectedCategoryId?: string | null; + onCategorySelect?: (docId: string) => void; +} + +function CategoryGrid({ + groups, + selectedCategoryId, + onCategorySelect, +}: CategoryGridProps) { + const intl = useIntl(); + + if (groups.length === 0) { + return ( +
+ + {intl.formatMessage(messages.noResults)} + +
+ ); + } + + return ( +
+ {groups.map((group) => ( +
+

+ {group.type || intl.formatMessage(messages.uncategorized)} +

+
+ {group.categories.map((category) => ( + + ))} +
+
+ ))} +
+ ); +} + +export { CategoryGrid }; +export type { CategoryGridProps }; diff --git a/src/screens/CategoriesEditor/FieldViewer.tsx b/src/screens/CategoriesEditor/FieldViewer.tsx new file mode 100644 index 00000000..6f223c6f --- /dev/null +++ b/src/screens/CategoriesEditor/FieldViewer.tsx @@ -0,0 +1,91 @@ +import { defineMessages, useIntl } from 'react-intl'; + +const messages = defineMessages({ + date: { + id: 'categories.fieldType.date', + defaultMessage: 'Date', + }, + number: { + id: 'categories.fieldType.number', + defaultMessage: 'Number', + }, + unknown: { + id: 'categories.fieldType.unknown', + defaultMessage: 'Unknown type', + }, +}); + +export interface FieldInput { + docId: string; + tagKey: string; + type: string; + label: string; + helperText?: string; + options?: Array<{ label: string; value: string }>; +} + +interface FieldViewerProps { + fields: FieldInput[]; +} + +function FieldBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function FieldItem({ field }: { field: FieldInput }) { + const intl = useIntl(); + return ( +
  • + {field.label} + {field.tagKey} + {field.type === 'selectOne' && field.options && ( +
    + {field.options.map((opt) => ( + {opt.label} + ))} +
    + )} + {field.type === 'selectMultiple' && field.options && ( +
    + {field.options.map((opt) => ( + {opt.label} + ))} +
    + )} + {field.type === 'date' && ( + {intl.formatMessage(messages.date)} + )} + {field.type === 'datetime' && ( + {intl.formatMessage(messages.date)} + )} + {field.type === 'number' && ( + {intl.formatMessage(messages.number)} + )} + {field.type !== 'selectOne' && + field.type !== 'selectMultiple' && + field.type !== 'date' && + field.type !== 'datetime' && + field.type !== 'number' && + field.type !== 'text' && + field.type !== 'textarea' && ( + {intl.formatMessage(messages.unknown)} + )} +
  • + ); +} + +function FieldViewer({ fields }: FieldViewerProps) { + return ( +
      + {fields.map((field) => ( + + ))} +
    + ); +} + +export { FieldViewer }; diff --git a/src/screens/CategoriesEditor/ImportSetDialog.tsx b/src/screens/CategoriesEditor/ImportSetDialog.tsx new file mode 100644 index 00000000..9f8ee741 --- /dev/null +++ b/src/screens/CategoriesEditor/ImportSetDialog.tsx @@ -0,0 +1,317 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import * as v from 'valibot'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { Button } from '@/components/ui/button'; +import { getCategorySet, importCategorySet } from '@/lib/categories-db'; +import { comapeoCatSchema } from '@/lib/schemas/preset'; + +const messages = defineMessages({ + title: { + id: 'categories.importSet.title', + defaultMessage: 'Import Category Set', + }, + description: { + id: 'categories.importSet.description', + defaultMessage: 'Upload a .comapeocat file to import a category set.', + }, + fileLabel: { + id: 'categories.importSet.fileLabel', + defaultMessage: 'Choose .comapeocat file', + }, + invalidJson: { + id: 'categories.importSet.invalidJson', + defaultMessage: 'Invalid JSON: {error}', + }, + validationError: { + id: 'categories.importSet.validationError', + defaultMessage: 'Validation error: {error}', + }, + imported: { + id: 'categories.importSet.imported', + defaultMessage: 'Category set "{name}" imported successfully.', + }, + replaceTitle: { + id: 'categories.importSet.replaceTitle', + defaultMessage: 'Replace existing set?', + }, + replaceDescription: { + id: 'categories.importSet.replaceDescription', + defaultMessage: + 'A set with ID "{setId}" already exists. Importing will replace it.', + }, + confirmReplace: { + id: 'categories.importSet.confirmReplace', + defaultMessage: 'Replace', + }, + cancel: { + id: 'categories.importSet.cancel', + defaultMessage: 'Cancel', + }, + importButton: { + id: 'categories.importSet.importButton', + defaultMessage: 'Import', + }, + errorAnnouncement: { + id: 'categories.importSet.errorAnnouncement', + defaultMessage: 'Import failed', + }, + failedToRead: { + id: 'categories.importSet.failedToRead', + defaultMessage: 'Failed to read file', + }, + importError: { + id: 'categories.importSet.importError', + defaultMessage: 'Failed to import: {error}', + }, + successAnnouncement: { + id: 'categories.importSet.successAnnouncement', + defaultMessage: 'Import succeeded', + }, +}); + +interface ImportSetDialogProps { + open: boolean; + onClose: () => void; +} + +type DialogState = + | { status: 'idle' } + | { status: 'confirming-replace'; setId: string; name: string; data: unknown } + | { status: 'success'; name: string } + | { status: 'error'; message: string }; + +function ImportSetDialog({ open, onClose }: ImportSetDialogProps) { + const intl = useIntl(); + const fileInputRef = useRef(null); + const [dialogState, setDialogState] = useState({ + status: 'idle', + }); + + const reset = useCallback(() => { + setDialogState({ status: 'idle' }); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, []); + + const handleClose = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + reset(); + onClose(); + } + }, + [onClose, reset], + ); + + const handleFileChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + let text: string; + try { + text = await file.text(); + } catch { + setDialogState({ + status: 'error', + message: intl.formatMessage(messages.failedToRead), + }); + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setDialogState({ + status: 'error', + message: intl.formatMessage(messages.invalidJson, { error: msg }), + }); + return; + } + + const result = v.safeParse(comapeoCatSchema, parsed); + if (!result.success) { + const firstIssue = result.issues[0]; + const msg = firstIssue + ? `${firstIssue.message} at ${firstIssue.path?.map((p) => p.key ?? p.value).join('.')}` + : 'Unknown error'; + setDialogState({ + status: 'error', + message: intl.formatMessage(messages.validationError, { error: msg }), + }); + return; + } + + const setId = file.name.replace(/\.comapeocat$/i, ''); + const name = result.output.metadata?.name ?? setId; + + // Check for existing set + try { + const existing = await getCategorySet(setId); + if (existing) { + setDialogState({ + status: 'confirming-replace', + setId, + name, + data: parsed, + }); + return; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setDialogState({ + status: 'error', + message: intl.formatMessage(messages.importError, { error: msg }), + }); + return; + } + + try { + await importCategorySet(setId, name, parsed); + setDialogState({ status: 'success', name }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setDialogState({ + status: 'error', + message: intl.formatMessage(messages.importError, { error: msg }), + }); + } + }, + [intl, handleClose], + ); + + const handleConfirmReplace = useCallback(async () => { + if (dialogState.status !== 'confirming-replace') return; + const { setId, name, data } = dialogState; + try { + await importCategorySet(setId, name, data); + setDialogState({ status: 'success', name }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setDialogState({ + status: 'error', + message: intl.formatMessage(messages.importError, { error: msg }), + }); + } + }, [dialogState, handleClose, intl]); + + // Auto-close on success with cleanup on unmount + useEffect(() => { + if (dialogState.status !== 'success') return; + const timer = setTimeout(() => handleClose(false), 1500); + return () => clearTimeout(timer); + }, [dialogState.status, handleClose]); + + return ( + + + + +
    + + {intl.formatMessage(messages.title)} + + + + +
    + + + {intl.formatMessage(messages.description)} + + + {/* ARIA live region for screen reader announcements */} +
    + {dialogState.status === 'success' && + intl.formatMessage(messages.successAnnouncement)} + {dialogState.status === 'error' && + intl.formatMessage(messages.errorAnnouncement)} +
    + + {dialogState.status === 'idle' && ( +
    + +
    + )} + + {dialogState.status === 'confirming-replace' && ( +
    +

    + {intl.formatMessage(messages.replaceDescription, { + setId: dialogState.setId, + })} +

    +
    + + +
    +
    + )} + + {dialogState.status === 'success' && ( +

    + {intl.formatMessage(messages.imported, { + name: dialogState.name, + })} +

    + )} + + {dialogState.status === 'error' && ( +
    +

    + {dialogState.message} +

    + +
    + )} +
    +
    +
    + ); +} + +export { ImportSetDialog }; +export type { ImportSetDialogProps }; diff --git a/src/screens/CategoriesEditor/index.tsx b/src/screens/CategoriesEditor/index.tsx new file mode 100644 index 00000000..a38ac0c6 --- /dev/null +++ b/src/screens/CategoriesEditor/index.tsx @@ -0,0 +1,257 @@ +import { useMemo, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { useShellSlot } from '@/components/layout/shell-slot'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useApiPresets } from '@/hooks/useApiPresets'; +import { normalizeCategories } from '@/hooks/useCategories'; +import { useFields } from '@/hooks/useFields'; +import { useProjects } from '@/hooks/useProjects'; +import { CategoryDetail } from '@/screens/CategoriesEditor/CategoryDetail'; +import { CategoryGrid } from '@/screens/CategoriesEditor/CategoryGrid'; +import { useProjectStore } from '@/stores/project-store'; + +const messages = defineMessages({ + title: { + id: 'categories.title', + defaultMessage: 'Categories', + }, + search: { + id: 'categories.search', + defaultMessage: 'Search categories...', + }, + empty: { + id: 'categories.empty', + defaultMessage: 'No categories found', + }, + error: { + id: 'categories.error', + defaultMessage: 'Failed to load categories. Please try again.', + }, + retry: { + id: 'categories.retry', + defaultMessage: 'Retry', + }, + noResults: { + id: 'categories.noResults', + defaultMessage: 'No categories match your search', + }, + untitledProject: { + id: 'categories.untitledProject', + defaultMessage: 'Untitled Project', + }, + selectProject: { + id: 'categories.selectProject', + defaultMessage: 'Select a project to view categories', + }, +}); + +export function CategoriesEditorScreen() { + const intl = useIntl(); + const selectedProjectId = useProjectStore((s) => s.selectedProjectId); + const projectsQuery = useProjects(); + + const projects = projectsQuery.data ?? []; + const selectedProject = projects.find((p) => p.localId === selectedProjectId); + // Server expects projectPublicId (base32) — use remoteId which is + // populated by pullProjects for remote archive projects, and falls + // back to null (query disabled) for local-only projects. + const presetsQuery = useApiPresets(selectedProject?.remoteId ?? null); + + const topbarWorkspaceName = + selectedProject?.name ?? intl.formatMessage(messages.untitledProject); + const shellSlot = useMemo( + () => ({ + topbarWorkspaceName: selectedProjectId ? topbarWorkspaceName : undefined, + topbarModeLabel: intl.formatMessage(messages.title), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedProjectId, topbarWorkspaceName], + ); + useShellSlot(shellSlot); + + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCategoryId, setSelectedCategoryId] = useState( + null, + ); + + const fieldsQuery = useFields(selectedProjectId); + + const fieldLabels = useMemo(() => { + const map = new Map(); + for (const field of fieldsQuery.data ?? []) { + if (field.remoteId) { + map.set(field.remoteId, field.label); + } + } + return map; + }, [fieldsQuery.data]); + + const categoryGroups = useMemo( + () => + normalizeCategories( + presetsQuery.data ?? [], + intl.locale, + searchQuery, + fieldLabels, + ), + [presetsQuery.data, intl.locale, searchQuery, fieldLabels], + ); + + const selectedCategory = useMemo(() => { + if (!selectedCategoryId) return null; + for (const group of categoryGroups) { + const found = group.categories.find( + (c) => c.docId === selectedCategoryId, + ); + if (found) return found; + } + return null; + }, [selectedCategoryId, categoryGroups]); + + // No project selected — prompt to select one + if (!selectedProjectId) { + return ( +
    +

    + {intl.formatMessage(messages.selectProject)} +

    +
    + ); + } + + // Projects still loading + if (projectsQuery.isPending) { + return ( +
    + + + + +
    + ); + } + + // Projects failed to load + if (projectsQuery.isError) { + return ( +
    +

    + {intl.formatMessage(messages.error)} +

    + +
    + ); + } + + // Selected project not found in the loaded list + if (!selectedProject) { + return ( +
    + + {intl.formatMessage(messages.empty)} + +
    + ); + } + + // Project selected but no remoteId — presets not available via API. + // Local-only projects don't have a server projectPublicId. + if (!selectedProject.remoteId) { + return ( +
    + + {intl.formatMessage(messages.empty)} + +
    + ); + } + + // Loading skeleton + if (presetsQuery.isPending) { + return ( +
    + + + + +
    + ); + } + + // Error state + if (presetsQuery.isError) { + return ( +
    +

    + {intl.formatMessage(messages.error)} +

    + +
    + ); + } + + const hasPresets = (presetsQuery.data ?? []).length > 0; + const hasResults = categoryGroups.length > 0; + + return ( +
    +

    + {intl.formatMessage(messages.title)} +

    + + setSearchQuery(e.target.value)} + className="w-full rounded-button border border-border bg-surface px-3 py-2 text-sm text-text placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary" + /> + + {!hasPresets && ( +
    + + {intl.formatMessage(messages.empty)} + +
    + )} + + {hasPresets && !hasResults && ( +
    + + {intl.formatMessage(messages.noResults)} + +
    + )} + + {hasPresets && hasResults && ( +
    +
    + +
    + +
    + )} +
    + ); +} diff --git a/tests/fixtures/categories/README.md b/tests/fixtures/categories/README.md new file mode 100644 index 00000000..7a694ba2 --- /dev/null +++ b/tests/fixtures/categories/README.md @@ -0,0 +1,56 @@ +# Default Category Fixtures + +Representative fixtures vendored from +[digidem/comapeo-default-categories](https://github.com/digidem/comapeo-default-categories). + +**Upstream version**: v1.1.2 +**Upstream commit**: `35fbc145e172dba8dc3e2af5e0598a753f36cae0` (2026-06-29) +**Vendored date**: 2026-07-22 + +## What's included + +5 representative category JSON files chosen to cover a range of field +references, tag structures, and geometry types: + +- `animal.json` — nature category with select fields +- `plant.json` — nature category with minimal fields +- `body-of-water.json` — nature category with many field refs +- `community.json` — infrastructure category +- `cultural-site.json` — culture category with selectMultiple fields + +Plus `metadata.json` from the upstream package root. + +These are NOT all upstream categories — they are a representative subset for +schema validation testing. + +## Source format + +Each category JSON follows the upstream `.comapeocat` import format: + +```json +{ + "name": "Animal", + "icon": "animal", + "color": "#9E2C54", + "fields": ["name", "animal-type"], + "appliesTo": ["observation"], + "tags": { "type": "nature", "nature": "wildlife", "wildlife": "animal" } +} +``` + +Field JSON follows: + +```json +{ + "tagKey": "animal-type", + "type": "selectOne", + "label": "Animal type", + "helperText": "What kind of animal?", + "options": [{ "label": "Mammal", "value": "mammal" }] +} +``` + +## Updating + +When upstream releases a new version, update the files in this directory and +bump the version/commit above. diff --git a/tests/fixtures/categories/access.json b/tests/fixtures/categories/access.json new file mode 100644 index 00000000..9446f9bb --- /dev/null +++ b/tests/fixtures/categories/access.json @@ -0,0 +1,11 @@ +{ + "tagKey": "access", + "type": "selectOne", + "label": "Access", + "helperText": "How accessible is this location?", + "options": [ + { "label": "Open access", "value": "open-access" }, + { "label": "Restricted access", "value": "restricted-access" }, + { "label": "No access", "value": "no-access" } + ] +} diff --git a/tests/fixtures/categories/animal-type.json b/tests/fixtures/categories/animal-type.json new file mode 100644 index 00000000..85244684 --- /dev/null +++ b/tests/fixtures/categories/animal-type.json @@ -0,0 +1,13 @@ +{ + "tagKey": "animal-type", + "type": "selectOne", + "label": "Animal type", + "helperText": "What kind of animal?", + "options": [ + { "label": "Mammal", "value": "mammal" }, + { "label": "Bird", "value": "bird" }, + { "label": "Reptile/Amphibian", "value": "reptile-amphibian" }, + { "label": "Fish", "value": "fish" }, + { "label": "Other", "value": "other" } + ] +} diff --git a/tests/fixtures/categories/animal.json b/tests/fixtures/categories/animal.json new file mode 100644 index 00000000..e07bdb3d --- /dev/null +++ b/tests/fixtures/categories/animal.json @@ -0,0 +1,18 @@ +{ + "name": "Animal", + "icon": "animal", + "color": "#9E2C54", + "fields": [ + "name", + "animal-type", + "ecological-status", + "conditions", + "natural-resource-type" + ], + "appliesTo": ["observation"], + "tags": { + "type": "nature", + "nature": "wildlife", + "wildlife": "animal" + } +} diff --git a/tests/fixtures/categories/body-of-water-type.json b/tests/fixtures/categories/body-of-water-type.json new file mode 100644 index 00000000..2a440d3d --- /dev/null +++ b/tests/fixtures/categories/body-of-water-type.json @@ -0,0 +1,20 @@ +{ + "tagKey": "body-of-water-type", + "type": "selectOne", + "label": "Type of body of water", + "helperText": "The type of body of water", + "options": [ + { "label": "Headwaters", "value": "headwaters" }, + { "label": "Stream", "value": "stream" }, + { "label": "River", "value": "river" }, + { "label": "Rapids", "value": "rapids" }, + { "label": "Waterfall", "value": "waterfall" }, + { "label": "Lake", "value": "lake" }, + { "label": "Pond", "value": "pond" }, + { "label": "Delta", "value": "delta" }, + { "label": "Subsurface water table", "value": "subsurface-water-table" }, + { "label": "Glacier", "value": "glacier" }, + { "label": "Cold spring", "value": "cold-spring" }, + { "label": "Hotspring", "value": "hotspring" } + ] +} diff --git a/tests/fixtures/categories/body-of-water.json b/tests/fixtures/categories/body-of-water.json new file mode 100644 index 00000000..b7b6b54f --- /dev/null +++ b/tests/fixtures/categories/body-of-water.json @@ -0,0 +1,18 @@ +{ + "name": "Body of water", + "icon": "body-of-water", + "color": "#3399ff", + "fields": [ + "name", + "body-of-water-type", + "conditions", + "natural-resource-type", + "cultural-name" + ], + "appliesTo": ["observation"], + "tags": { + "type": "nature", + "nature": "water", + "water": "body-of-water" + } +} diff --git a/tests/fixtures/categories/community.json b/tests/fixtures/categories/community.json new file mode 100644 index 00000000..1e393c47 --- /dev/null +++ b/tests/fixtures/categories/community.json @@ -0,0 +1,11 @@ +{ + "name": "Community", + "icon": "community", + "color": "#CC00CC", + "fields": ["access"], + "appliesTo": ["observation"], + "tags": { + "type": "infrastructure", + "infrastructure": "community" + } +} diff --git a/tests/fixtures/categories/conditions.json b/tests/fixtures/categories/conditions.json new file mode 100644 index 00000000..38587be6 --- /dev/null +++ b/tests/fixtures/categories/conditions.json @@ -0,0 +1,10 @@ +{ + "tagKey": "conditions", + "type": "selectOne", + "label": "Conditions", + "helperText": "In what condition this was encountered?", + "options": [ + { "label": "Expected", "value": "expected" }, + { "label": "Unusual", "value": "unusual" } + ] +} diff --git a/tests/fixtures/categories/cultural-category.json b/tests/fixtures/categories/cultural-category.json new file mode 100644 index 00000000..2f9fedda --- /dev/null +++ b/tests/fixtures/categories/cultural-category.json @@ -0,0 +1,14 @@ +{ + "tagKey": "cultural-category", + "type": "selectMultiple", + "label": "Cultural activity", + "helperText": "What type of cultural activity?", + "options": [ + { "label": "Archeology", "value": "archeology" }, + { "label": "History", "value": "history" }, + { "label": "Mythology", "value": "mythology" }, + { "label": "Sacred site", "value": "sacred-site" }, + { "label": "Gathering site", "value": "gathering-site" }, + { "label": "Story", "value": "story" } + ] +} diff --git a/tests/fixtures/categories/cultural-details.json b/tests/fixtures/categories/cultural-details.json new file mode 100644 index 00000000..2a8de4f5 --- /dev/null +++ b/tests/fixtures/categories/cultural-details.json @@ -0,0 +1,6 @@ +{ + "tagKey": "cultural-details", + "type": "text", + "label": "Cultural details", + "helperText": "Additional cultural details or significance" +} diff --git a/tests/fixtures/categories/cultural-name.json b/tests/fixtures/categories/cultural-name.json new file mode 100644 index 00000000..bd822849 --- /dev/null +++ b/tests/fixtures/categories/cultural-name.json @@ -0,0 +1,6 @@ +{ + "tagKey": "cultural-name", + "type": "text", + "label": "Cultural name", + "helperText": "Local or traditional name for this place" +} diff --git a/tests/fixtures/categories/cultural-site.json b/tests/fixtures/categories/cultural-site.json new file mode 100644 index 00000000..7bbd64af --- /dev/null +++ b/tests/fixtures/categories/cultural-site.json @@ -0,0 +1,16 @@ +{ + "name": "Cultural site", + "icon": "cultural-site", + "color": "#FFCC00", + "fields": [ + "cultural-name", + "cultural-category", + "cultural-details", + "access" + ], + "appliesTo": ["observation"], + "tags": { + "type": "culture", + "culture": "site" + } +} diff --git a/tests/fixtures/categories/ecological-status.json b/tests/fixtures/categories/ecological-status.json new file mode 100644 index 00000000..6438c244 --- /dev/null +++ b/tests/fixtures/categories/ecological-status.json @@ -0,0 +1,11 @@ +{ + "tagKey": "ecological-status", + "type": "selectOne", + "label": "Ecological status", + "helperText": "What is the ecological status of this area?", + "options": [ + { "label": "Pristine", "value": "pristine" }, + { "label": "Degraded", "value": "degraded" }, + { "label": "Restored", "value": "restored" } + ] +} diff --git a/tests/fixtures/categories/index.ts b/tests/fixtures/categories/index.ts new file mode 100644 index 00000000..5059fcf5 --- /dev/null +++ b/tests/fixtures/categories/index.ts @@ -0,0 +1,69 @@ +import accessField from './access.json'; +import animalTypeField from './animal-type.json'; +import animalCategory from './animal.json'; +import bodyOfWaterTypeField from './body-of-water-type.json'; +import bodyOfWaterCategory from './body-of-water.json'; +import communityCategory from './community.json'; +import conditionsField from './conditions.json'; +import culturalCategoryField from './cultural-category.json'; +import culturalDetailsField from './cultural-details.json'; +import culturalNameField from './cultural-name.json'; +import culturalSiteCategory from './cultural-site.json'; +import ecologicalStatusField from './ecological-status.json'; +import metadata from './metadata.json'; +import nameField from './name.json'; +import naturalResourceTypeField from './natural-resource-type.json'; +import plantCategory from './plant.json'; + +export type CategoryFixture = { + name: string; + icon: string; + color: string; + fields: string[]; + appliesTo: string[]; + tags: Record; +}; + +export type FieldFixture = { + tagKey: string; + type: string; + label: string; + helperText?: string; + options?: Array<{ label: string; value: string }>; +}; + +export type MetadataFixture = { + name: string; + [key: string]: unknown; +}; + +export type CategoryFixtures = { + metadata: MetadataFixture; + categories: Record; + fields: Record; +}; + +export function loadDefaultCategoryFixtures(): CategoryFixtures { + return { + metadata: metadata as MetadataFixture, + categories: { + animal: animalCategory as CategoryFixture, + plant: plantCategory as CategoryFixture, + 'body-of-water': bodyOfWaterCategory as CategoryFixture, + community: communityCategory as CategoryFixture, + 'cultural-site': culturalSiteCategory as CategoryFixture, + }, + fields: { + name: nameField as FieldFixture, + 'animal-type': animalTypeField as FieldFixture, + 'body-of-water-type': bodyOfWaterTypeField as FieldFixture, + conditions: conditionsField as FieldFixture, + 'cultural-category': culturalCategoryField as FieldFixture, + 'cultural-details': culturalDetailsField as FieldFixture, + 'cultural-name': culturalNameField as FieldFixture, + 'ecological-status': ecologicalStatusField as FieldFixture, + 'natural-resource-type': naturalResourceTypeField as FieldFixture, + access: accessField as FieldFixture, + }, + }; +} diff --git a/tests/fixtures/categories/metadata.json b/tests/fixtures/categories/metadata.json new file mode 100644 index 00000000..09ea668a --- /dev/null +++ b/tests/fixtures/categories/metadata.json @@ -0,0 +1,3 @@ +{ + "name": "CoMapeo Default Categories" +} diff --git a/tests/fixtures/categories/name.json b/tests/fixtures/categories/name.json new file mode 100644 index 00000000..8143e595 --- /dev/null +++ b/tests/fixtures/categories/name.json @@ -0,0 +1,6 @@ +{ + "tagKey": "name", + "type": "text", + "label": "Name", + "helperText": "Common name of this thing" +} diff --git a/tests/fixtures/categories/natural-resource-type.json b/tests/fixtures/categories/natural-resource-type.json new file mode 100644 index 00000000..1240f932 --- /dev/null +++ b/tests/fixtures/categories/natural-resource-type.json @@ -0,0 +1,16 @@ +{ + "tagKey": "natural-resource-type", + "type": "selectOne", + "label": "Natural resource type", + "helperText": "What type of natural resource?", + "options": [ + { "label": "Timber", "value": "timber" }, + { + "label": "Non-timber forest product", + "value": "non-timber-forest-product" + }, + { "label": "Water", "value": "water" }, + { "label": "Mineral", "value": "mineral" }, + { "label": "Other", "value": "other" } + ] +} diff --git a/tests/fixtures/categories/plant.json b/tests/fixtures/categories/plant.json new file mode 100644 index 00000000..5aca94a6 --- /dev/null +++ b/tests/fixtures/categories/plant.json @@ -0,0 +1,12 @@ +{ + "name": "Plant", + "icon": "plant", + "color": "#009900", + "fields": ["ecological-status", "natural-resource-type", "conditions"], + "appliesTo": ["observation"], + "tags": { + "type": "nature", + "nature": "vegetation", + "vegetation": "plant" + } +} diff --git a/tests/unit/hooks/useApiPresets.test.tsx b/tests/unit/hooks/useApiPresets.test.tsx new file mode 100644 index 00000000..07987f79 --- /dev/null +++ b/tests/unit/hooks/useApiPresets.test.tsx @@ -0,0 +1,94 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ReactNode } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { useApiPresets } from '@/hooks/useApiPresets'; +import { apiClient } from '@/lib/api-client'; +import { useAuthStore } from '@/stores/auth-store'; + +vi.mock('@/lib/api-client', () => ({ + apiClient: { + getPresets: vi.fn(), + }, +})); + +const getPresets = vi.mocked(apiClient.getPresets); + +const REMOTE_ID = '4ymf7qmcafhpbmcrpdx6yxubstk66ipfq5ek5bporosi7594z8ty'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return {children}; +} + +describe('useApiPresets', () => { + beforeEach(() => { + getPresets.mockReset(); + getPresets.mockResolvedValue({ data: [] }); + useAuthStore.getState().clearAll(); + }); + + it('passes the active server RequestConfig (baseUrl + token) to getPresets', async () => { + useAuthStore.setState({ + baseUrl: 'https://archive.example.org', + token: 'secret-token', + }); + + const { result } = renderHook(() => useApiPresets(REMOTE_ID), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getPresets).toHaveBeenCalledWith(REMOTE_ID, { + baseUrl: 'https://archive.example.org', + token: 'secret-token', + }); + }); + + it('is disabled (never fetches) when no active server is configured', async () => { + // No baseUrl set — clearAll() leaves baseUrl null. + const { result } = renderHook(() => useApiPresets(REMOTE_ID), { wrapper }); + + // Give the query a chance to run if it were enabled. + await Promise.resolve(); + + expect(result.current.fetchStatus).toBe('idle'); + expect(getPresets).not.toHaveBeenCalled(); + }); + + it('is disabled when projectRemoteId is null', async () => { + useAuthStore.setState({ + baseUrl: 'https://archive.example.org', + token: 'secret-token', + }); + + const { result } = renderHook(() => useApiPresets(null), { wrapper }); + + await Promise.resolve(); + + expect(result.current.fetchStatus).toBe('idle'); + expect(getPresets).not.toHaveBeenCalled(); + }); + + it('exposes only the inner data array via select', async () => { + useAuthStore.setState({ + baseUrl: 'https://archive.example.org', + token: 'secret-token', + }); + getPresets.mockResolvedValue({ + data: [{ docId: 'p1', name: 'Forest', tags: {}, fieldRefs: [] }], + } as never); + + const { result } = renderHook(() => useApiPresets(REMOTE_ID), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual([ + { docId: 'p1', name: 'Forest', tags: {}, fieldRefs: [] }, + ]); + }); +}); diff --git a/tests/unit/hooks/useCategories.test.ts b/tests/unit/hooks/useCategories.test.ts new file mode 100644 index 00000000..200721e1 --- /dev/null +++ b/tests/unit/hooks/useCategories.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeCategories } from '@/hooks/useCategories'; + +const PRESETS_WITH_TYPES = [ + { + docId: 'p1', + name: 'Deforestation', + tags: { type: 'environment', category: 'forest' }, + fieldRefs: [{ docId: 'f1' }], + }, + { + docId: 'p2', + name: 'Water Pollution', + tags: { type: 'water', category: 'pollution' }, + fieldRefs: [], + }, + { + docId: 'p3', + name: 'Illegal Logging', + tags: { type: 'environment', category: 'forest' }, + fieldRefs: [{ docId: 'f2' }], + }, +]; + +const PRESETS_WITHOUT_TYPES = [ + { + docId: 'p4', + name: 'Mystery Sighting', + tags: {}, + fieldRefs: [], + }, + { + docId: 'p5', + name: 'Unknown Event', + tags: { type: '' }, + fieldRefs: [], + }, +]; + +const PRESETS_WITH_LOCALE = [ + { + docId: 'p6', + name: 'Deforestation', + tags: { + type: 'env', + 'name:en': 'Deforestation', + 'name:pt': 'Desmatamento', + }, + fieldRefs: [], + }, + { + docId: 'p7', + name: 'Water', + tags: { type: 'env', 'name:en': 'Water', 'name:es': 'Agua' }, + fieldRefs: [], + }, +]; + +const PRESETS_WITH_DIACRITICS = [ + { + docId: 'p8', + name: 'Água', + tags: { type: 'water', 'name:pt': 'Água' }, + fieldRefs: [], + }, + { + docId: 'p9', + name: 'São Paulo', + tags: { type: 'city', 'name:pt': 'São Paulo' }, + fieldRefs: [], + }, + { + docId: 'p10', + name: 'Ação', + tags: { type: 'action' }, + fieldRefs: [], + }, +]; + +describe('normalizeCategories', () => { + it('groups presets by tags.type', () => { + const result = normalizeCategories(PRESETS_WITH_TYPES, 'en', ''); + + expect(result).toHaveLength(2); + const envGroup = result.find((g) => g.type === 'environment'); + const waterGroup = result.find((g) => g.type === 'water'); + + expect(envGroup).toBeDefined(); + expect(envGroup!.categories).toHaveLength(2); + expect(waterGroup).toBeDefined(); + expect(waterGroup!.categories).toHaveLength(1); + }); + + it('resolves locale fallback (current locale → English → source value)', () => { + const result = normalizeCategories(PRESETS_WITH_LOCALE, 'pt', ''); + + const envGroup = result.find((g) => g.type === 'env'); + expect(envGroup).toBeDefined(); + + const deforestation = envGroup!.categories.find((c) => c.docId === 'p6'); + expect(deforestation!.label).toBe('Desmatamento'); + + const water = envGroup!.categories.find((c) => c.docId === 'p7'); + expect(water!.label).toBe('Water'); + }); + + it('falls back to English when current locale not available', () => { + const result = normalizeCategories(PRESETS_WITH_LOCALE, 'fr', ''); + + const envGroup = result.find((g) => g.type === 'env'); + expect(envGroup).toBeDefined(); + + const deforestation = envGroup!.categories.find((c) => c.docId === 'p6'); + expect(deforestation!.label).toBe('Deforestation'); + }); + + it('filters by search query (case insensitive)', () => { + const result = normalizeCategories( + PRESETS_WITH_TYPES, + 'en', + 'deforestation', + ); + + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('environment'); + expect(result[0]!.categories).toHaveLength(1); + expect(result[0]!.categories[0]!.label).toBe('Deforestation'); + }); + + it('filters by search query (diacritic insensitive)', () => { + const result = normalizeCategories( + PRESETS_WITH_LOCALE, + 'pt', + 'desmatamento', + ); + + expect(result).toHaveLength(1); + expect(result[0]!.categories[0]!.label).toBe('Desmatamento'); + }); + + it('searches across field labels via fieldLabels map', () => { + const fieldLabels = new Map([['f1', 'Severity']]); + const result = normalizeCategories( + PRESETS_WITH_TYPES, + 'en', + 'severity', + fieldLabels, + ); + + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('environment'); + expect(result[0]!.categories).toHaveLength(1); + expect(result[0]!.categories[0]!.label).toBe('Deforestation'); + }); + + it('assigns empty string sentinel for missing tags.type', () => { + const result = normalizeCategories(PRESETS_WITHOUT_TYPES, 'en', ''); + + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe(''); + expect(result[0]!.categories).toHaveLength(2); + }); + + it('assigns empty string sentinel for blank tags.type', () => { + const result = normalizeCategories( + [{ docId: 'p8', name: 'Test', tags: { type: '' }, fieldRefs: [] }], + 'en', + '', + ); + + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe(''); + }); + + it('returns stable alphabetical ordering within groups', () => { + const result = normalizeCategories(PRESETS_WITH_TYPES, 'en', ''); + + const envGroup = result.find((g) => g.type === 'environment'); + expect(envGroup!.categories[0]!.label).toBe('Deforestation'); + expect(envGroup!.categories[1]!.label).toBe('Illegal Logging'); + }); + + it('returns stable alphabetical ordering of groups', () => { + const result = normalizeCategories(PRESETS_WITH_TYPES, 'en', ''); + + expect(result[0]!.type).toBe('environment'); + expect(result[1]!.type).toBe('water'); + }); + + it('resolves field ref labels from fieldLabels map', () => { + const fieldLabels = new Map([ + ['f1', 'Severity'], + ['f2', 'Area (ha)'], + ]); + const result = normalizeCategories( + PRESETS_WITH_TYPES, + 'en', + '', + fieldLabels, + ); + + const envGroup = result.find((g) => g.type === 'environment'); + const deforestation = envGroup!.categories.find((c) => c.docId === 'p1'); + expect(deforestation!.fieldRefs[0]!.label).toBe('Severity'); + }); + + it('returns empty array for empty input', () => { + const result = normalizeCategories([], 'en', ''); + expect(result).toEqual([]); + }); + + it('returns empty groups when search matches nothing', () => { + const result = normalizeCategories(PRESETS_WITH_TYPES, 'en', 'nonexistent'); + expect(result).toEqual([]); + }); + + it('matches accented names with unaccented search (real diacritics)', () => { + const result = normalizeCategories(PRESETS_WITH_DIACRITICS, 'pt', 'agua'); + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('water'); + expect(result[0]!.categories[0]!.label).toBe('Água'); + }); + + it('matches multi-word accented names without accents', () => { + const result = normalizeCategories( + PRESETS_WITH_DIACRITICS, + 'pt', + 'sao paulo', + ); + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('city'); + expect(result[0]!.categories[0]!.label).toBe('São Paulo'); + }); + + it('matches plain name with accented search query', () => { + const result = normalizeCategories(PRESETS_WITH_DIACRITICS, 'en', 'ação'); + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('action'); + }); + + it('passes tags.color through to category.color', () => { + const presets = [ + { + docId: 'c1', + name: 'Colored', + tags: { type: 'test', color: '#123456' }, + fieldRefs: [], + }, + ]; + const result = normalizeCategories(presets as never, 'en', ''); + expect(result[0]!.categories[0]!.color).toBe('#123456'); + }); + + it('passes iconRef object through to category.iconRef', () => { + const presets = [ + { + docId: 'i1', + name: 'With Icon', + tags: { type: 'test' }, + fieldRefs: [], + iconRef: { docId: 'icon-1' }, + }, + ]; + const result = normalizeCategories(presets as never, 'en', ''); + expect(result[0]!.categories[0]!.iconRef).toEqual({ docId: 'icon-1' }); + }); + + it('yields undefined iconRef for non-object iconRef (string)', () => { + const presets = [ + { + docId: 'i2', + name: 'Bad Icon', + tags: { type: 'test' }, + fieldRefs: [], + iconRef: 'not-an-object', + }, + ]; + const result = normalizeCategories(presets as never, 'en', ''); + expect(result[0]!.categories[0]!.iconRef).toBeUndefined(); + }); + + it('yields undefined iconRef for null iconRef', () => { + const presets = [ + { + docId: 'i3', + name: 'Null Icon', + tags: { type: 'test' }, + fieldRefs: [], + iconRef: null, + }, + ]; + const result = normalizeCategories(presets as never, 'en', ''); + expect(result[0]!.categories[0]!.iconRef).toBeUndefined(); + }); +}); diff --git a/tests/unit/hooks/useCategorySets.test.ts b/tests/unit/hooks/useCategorySets.test.ts new file mode 100644 index 00000000..7445168a --- /dev/null +++ b/tests/unit/hooks/useCategorySets.test.ts @@ -0,0 +1,107 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useCategorySets } from '@/hooks/useCategorySets'; +import { categoriesDb } from '@/lib/categories-db'; + +beforeEach(async () => { + await categoriesDb.categorySets.clear(); +}); + +describe('useCategorySets', () => { + it('returns empty sets when DB is empty', async () => { + const { result } = renderHook(() => useCategorySets()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.sets).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it('returns summaries when DB has sets', async () => { + await categoriesDb.categorySets.bulkPut([ + { + setId: 'set-a', + name: 'Set A', + categories: { cat1: {}, cat2: {}, cat3: {} }, + fields: {}, + importedAt: '2024-01-01T00:00:00Z', + }, + { + setId: 'set-b', + name: 'Set B', + categories: { cat1: {} }, + fields: {}, + importedAt: '2024-01-02T00:00:00Z', + }, + ]); + + const { result } = renderHook(() => useCategorySets()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.sets).toHaveLength(2); + expect(result.current.sets[0]!.setId).toBe('set-a'); + expect(result.current.sets[0]!.name).toBe('Set A'); + expect(result.current.sets[0]!.categoryCount).toBe(3); + expect(result.current.sets[1]!.setId).toBe('set-b'); + expect(result.current.sets[1]!.name).toBe('Set B'); + expect(result.current.sets[1]!.categoryCount).toBe(1); + }); + + it('returns error when DB access fails', async () => { + const originalToArray = categoriesDb.categorySets.toArray; + categoriesDb.categorySets.toArray = vi + .fn() + .mockRejectedValue(new Error('DB error')); + + const { result } = renderHook(() => useCategorySets()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe('DB error'); + + categoriesDb.categorySets.toArray = originalToArray; + }); + + it('refresh reloads data after mutation', async () => { + await categoriesDb.categorySets.put({ + setId: 'initial', + name: 'Initial', + categories: { a: {} }, + fields: {}, + importedAt: '2024-01-01T00:00:00Z', + }); + + const { result } = renderHook(() => useCategorySets()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.sets).toHaveLength(1); + + // Add another set + await categoriesDb.categorySets.put({ + setId: 'added-later', + name: 'Added Later', + categories: { b: {}, c: {} }, + fields: {}, + importedAt: '2024-01-03T00:00:00Z', + }); + + await act(async () => { + await result.current.refresh(); + }); + + expect(result.current.sets).toHaveLength(2); + const added = result.current.sets.find((s) => s.setId === 'added-later'); + expect(added).toBeDefined(); + expect(added!.categoryCount).toBe(2); + }); +}); diff --git a/tests/unit/hooks/usePresets.test.ts b/tests/unit/hooks/usePresets.test.ts index af1dc9e1..addfee3e 100644 --- a/tests/unit/hooks/usePresets.test.ts +++ b/tests/unit/hooks/usePresets.test.ts @@ -1,64 +1,98 @@ import { renderHook, waitFor } from '@testing-library/react'; import { createQueryWrapper } from '@tests/mocks/test-utils'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { usePresets } from '@/hooks/usePresets'; -import { getDb, resetDb } from '@/lib/db'; -beforeEach(async () => { - await resetDb(); -}); +const VALID_PRESETS = [ + { + localId: '1', + docId: 'preset-001', + versionId: 'preset-001/0', + originalVersionId: 'preset-001/0', + schemaName: 'preset' as const, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + links: [], + deleted: false, + name: 'Deforestation', + geometry: ['area'] as const, + tags: { type: 'environment', category: 'forest' }, + addTags: {}, + removeTags: {}, + fieldRefs: [{ docId: 'f1', versionId: 'v1', url: '/fields/f1' }], + iconRef: { docId: 'i1', versionId: 'v1', url: '/icons/i1' }, + color: '#FF0000', + terms: [], + projectLocalId: 'proj-local-1', + sourceType: 'remote' as const, + sourceId: 'proj-1', + dirtyLocal: false, + }, + { + localId: '2', + docId: 'preset-002', + versionId: 'preset-002/0', + originalVersionId: 'preset-002/0', + schemaName: 'preset' as const, + createdAt: '2025-02-01T00:00:00Z', + updatedAt: '2025-02-01T00:00:00Z', + links: [], + deleted: false, + name: 'Water Pollution', + geometry: ['point'] as const, + tags: { type: 'water' }, + addTags: {}, + removeTags: {}, + fieldRefs: [{ docId: 'f2', versionId: 'v2', url: '/fields/f2' }], + terms: [], + projectLocalId: 'proj-local-1', + sourceType: 'remote' as const, + sourceId: 'proj-1', + dirtyLocal: false, + }, +]; + +const mockGetPresets = vi.fn(); +vi.mock('@/lib/data-layer', () => ({ + getPresets: (...args: unknown[]) => mockGetPresets(...args), +})); describe('usePresets', () => { - it('returns presets for a valid projectLocalId', async () => { - // Seed presets directly in DB - const db = getDb(); - await db.presets.bulkPut([ - { - localId: 'preset-1', - projectLocalId: 'proj-local-1', - sourceType: 'remoteArchive', - sourceId: 's1', - remoteId: 'forest', - name: 'Forest', - terms: [], - fieldRefs: [], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - dirtyLocal: false, - deleted: false, - }, - { - localId: 'preset-2', - projectLocalId: 'proj-local-1', - sourceType: 'remoteArchive', - sourceId: 's1', - remoteId: 'water', - name: 'Water', - terms: [], - fieldRefs: [], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - dirtyLocal: false, - deleted: false, - }, - ]); + beforeEach(() => { + vi.clearAllMocks(); + }); - const { result } = renderHook(() => usePresets('proj-local-1'), { + it('returns presets on successful fetch', async () => { + mockGetPresets.mockResolvedValue(VALID_PRESETS); + + const { result } = renderHook(() => usePresets('proj-1'), { wrapper: createQueryWrapper(), }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toBeInstanceOf(Array); + expect(result.current.data).toEqual(VALID_PRESETS); expect(result.current.data).toHaveLength(2); - expect(result.current.data![0]!.name).toBe('Forest'); + expect(result.current.data![0]!.name).toBe('Deforestation'); + }); + + it('surfaces errors when getPresets throws', async () => { + mockGetPresets.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => usePresets('proj-1'), { + wrapper: createQueryWrapper(), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeDefined(); }); - it('does not fetch when projectLocalId is null', async () => { + it('does not fetch when projectLocalId is null', () => { const { result } = renderHook(() => usePresets(null), { wrapper: createQueryWrapper(), }); - expect(result.current.fetchStatus).toBe('idle'); + expect(result.current.isPending).toBe(true); + expect(mockGetPresets).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/lib/api-client.test.ts b/tests/unit/lib/api-client.test.ts index 5186e035..ce866077 100644 --- a/tests/unit/lib/api-client.test.ts +++ b/tests/unit/lib/api-client.test.ts @@ -1,898 +1,132 @@ -import { server } from '@tests/mocks/node'; -import { HttpResponse, http } from 'msw'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - ALERTS_PATH, - ApiError, - apiClient, - getAttachmentUrl, - resolveApiRequest, -} from '@/lib/api-client'; -import { useAuthStore } from '@/stores/auth-store'; +import { ApiError, apiClient } from '@/lib/api-client'; -const BASE_URL = 'http://localhost:3000'; +// Mock the stores used by api-client internals +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: { + getState: vi.fn(() => ({ baseUrl: null, token: null })), + }, +})); -beforeEach(() => { - useAuthStore.setState({ - token: 'test-token', - baseUrl: BASE_URL, - isAuthenticated: true, - }); -}); - -afterEach(() => { - useAuthStore.setState({ - token: null, - baseUrl: null, - isAuthenticated: false, - }); -}); - -// --------------------------------------------------------------------------- -// getServerInfo -// --------------------------------------------------------------------------- -describe('getServerInfo', () => { - it('returns validated server info on success', async () => { - const result = await apiClient.getServerInfo(); - expect(result).toEqual({ - data: { deviceId: 'test-device-id', name: 'Test Server' }, - }); - }); - - it('does not send auth header', async () => { - let capturedAuth: string | null = null; - server.use( - http.get(`${BASE_URL}/info`, ({ request }) => { - capturedAuth = request.headers.get('Authorization'); - return HttpResponse.json({ - data: { deviceId: 'abc', name: 'S' }, - }); - }), - ); - await apiClient.getServerInfo(); - expect(capturedAuth).toBeNull(); - }); - - it('throws ApiError on error response', async () => { - server.use( - http.get(`${BASE_URL}/info`, () => - HttpResponse.json( - { error: { code: 'INTERNAL_ERROR', message: 'Something broke' } }, - { status: 500 }, - ), - ), - ); - await expect(apiClient.getServerInfo()).rejects.toThrow(ApiError); - try { - await apiClient.getServerInfo(); - } catch (err) { - const apiErr = err as ApiError; - expect(apiErr.status).toBe(500); - expect(apiErr.code).toBe('INTERNAL_ERROR'); - expect(apiErr.message).toBe('Something broke'); - } - }); +describe('apiClient', () => { + const originalFetch = globalThis.fetch; - it('throws on network failure', async () => { - server.use(http.get(`${BASE_URL}/info`, () => HttpResponse.error())); - await expect(apiClient.getServerInfo()).rejects.toThrow( - 'Unable to connect', - ); + beforeEach(() => { + vi.clearAllMocks(); }); -}); -// --------------------------------------------------------------------------- -// healthCheck -// --------------------------------------------------------------------------- -describe('healthCheck', () => { - it('returns true on 200', async () => { - const result = await apiClient.healthCheck(); - expect(result).toBe(true); + afterEach(() => { + globalThis.fetch = originalFetch; }); - it('returns false on non-200', async () => { - server.use( - http.get(`${BASE_URL}/healthcheck`, () => - HttpResponse.json( - { error: { code: 'UNAVAILABLE', message: 'Down' } }, - { status: 503 }, - ), - ), + const makeFetchResponse = (status: number, body?: unknown) => { + const resp = new Response( + body !== undefined ? JSON.stringify(body) : null, + { status }, ); - const result = await apiClient.healthCheck(); - expect(result).toBe(false); - }); - - it('returns false on network failure', async () => { - server.use(http.get(`${BASE_URL}/healthcheck`, () => HttpResponse.error())); - const result = await apiClient.healthCheck(); - expect(result).toBe(false); - }); - - it('does not send auth header', async () => { - let capturedAuth: string | null = null; - server.use( - http.get(`${BASE_URL}/healthcheck`, ({ request }) => { - capturedAuth = request.headers.get('Authorization'); - return new HttpResponse(null, { status: 200 }); - }), - ); - await apiClient.healthCheck(); - expect(capturedAuth).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// getProjects -// --------------------------------------------------------------------------- -describe('getProjects', () => { - it('returns validated projects list', async () => { - const result = await apiClient.getProjects(); - expect(result.data).toBeInstanceOf(Array); - expect(result.data[0]).toHaveProperty('projectId'); - }); - - it('sends Authorization header', async () => { - let capturedAuth: string | null = null; - server.use( - http.get(`${BASE_URL}/projects`, ({ request }) => { - capturedAuth = request.headers.get('Authorization'); - return HttpResponse.json({ - data: [{ projectId: 'p1' }], - }); - }), - ); - await apiClient.getProjects(); - expect(capturedAuth).toBe('Bearer test-token'); - }); - - it('throws ApiError on error response', async () => { - server.use( - http.get(`${BASE_URL}/projects`, () => - HttpResponse.json( - { error: { code: 'FORBIDDEN', message: 'No access' } }, - { status: 403 }, - ), - ), - ); - await expect(apiClient.getProjects()).rejects.toThrow(ApiError); - }); - - it('throws on network failure', async () => { - server.use(http.get(`${BASE_URL}/projects`, () => HttpResponse.error())); - await expect(apiClient.getProjects()).rejects.toThrow('Unable to connect'); - }); -}); - -// --------------------------------------------------------------------------- -// getObservations -// --------------------------------------------------------------------------- -describe('getObservations', () => { - const projectId = 'proj-obs-1'; - - it('returns validated observations', async () => { - server.use( - http.get(`${BASE_URL}/projects/${projectId}/observations`, () => - HttpResponse.json({ - data: [ - { - docId: 'obs-1', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - deleted: false, - attachments: [], - tags: {}, - }, - ], - }), - ), - ); - const result = await apiClient.getObservations(projectId); - expect(result.data).toHaveLength(1); - expect(result.data[0]!.docId).toBe('obs-1'); - }); - - it('sends Authorization header', async () => { - let capturedAuth: string | null = null; - server.use( - http.get( - `${BASE_URL}/projects/${projectId}/observations`, - ({ request }) => { - capturedAuth = request.headers.get('Authorization'); - return HttpResponse.json({ data: [] }); - }, - ), - ); - await apiClient.getObservations(projectId); - expect(capturedAuth).toBe('Bearer test-token'); - }); - - it('throws ApiError on error response', async () => { - server.use( - http.get(`${BASE_URL}/projects/${projectId}/observations`, () => - HttpResponse.json( - { error: { code: 'NOT_FOUND', message: 'No project' } }, - { status: 404 }, - ), - ), - ); - await expect(apiClient.getObservations(projectId)).rejects.toThrow( - ApiError, - ); - }); - - it('throws on network failure', async () => { - server.use( - http.get(`${BASE_URL}/projects/${projectId}/observations`, () => - HttpResponse.error(), - ), - ); - await expect(apiClient.getObservations(projectId)).rejects.toThrow( - 'Unable to connect', - ); - }); -}); - -// --------------------------------------------------------------------------- -// getTracks -// --------------------------------------------------------------------------- -describe('getTracks', () => { - const projectId = 'proj-track-1'; - - it('uses the singular 0.4 track endpoint and validates tracks', async () => { - let capturedUrl: string | null = null; - server.use( - http.get(`${BASE_URL}/projects/${projectId}/track`, ({ request }) => { - capturedUrl = request.url; - return HttpResponse.json({ - data: [ - { - docId: 'track-1', - versionId: 'track-1/0', - originalVersionId: 'track-1/0', - schemaName: 'track', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - links: [], - deleted: false, - locations: [ - { - coords: { - latitude: -8.35, - longitude: -55.45, - }, - timestamp: '2024-01-01T00:00:00Z', - }, - ], - observationRefs: [ - { - docId: 'obs-1', - versionId: 'obs-1/0', - url: '/projects/proj/observation/obs-1', - }, - ], - tags: { patrol: 'north' }, - }, - ], - }); - }), - ); - - const result = await apiClient.getTracks(projectId); - - expect(capturedUrl).toBe(`${BASE_URL}/projects/${projectId}/track`); - const firstTrack = result.data[0]; - expect(firstTrack).toBeDefined(); - expect(firstTrack!.docId).toBe('track-1'); - expect(firstTrack!.locations).toHaveLength(1); - const firstLocation = firstTrack!.locations?.[0]; - expect(firstLocation).toBeDefined(); - expect(firstLocation!.coords.longitude).toBe(-55.45); - }); - - it('returns empty data array on 404 (legacy server)', async () => { - server.use( - http.get(`${BASE_URL}/projects/${projectId}/track`, () => - HttpResponse.json( - { error: { code: 'NOT_FOUND', message: 'Not found' } }, - { status: 404 }, - ), - ), - ); - const result = await apiClient.getTracks(projectId); - expect(result).toEqual({ data: [] }); - }); - - it('throws ApiError on non-404 error response', async () => { - server.use( - http.get(`${BASE_URL}/projects/${projectId}/track`, () => - HttpResponse.json( - { error: { code: 'INTERNAL_ERROR', message: 'Boom' } }, - { status: 500 }, - ), - ), - ); - await expect(apiClient.getTracks(projectId)).rejects.toThrow(ApiError); - }); -}); - -// --------------------------------------------------------------------------- -// ALERTS_PATH constant -// --------------------------------------------------------------------------- -describe('ALERTS_PATH constant', () => { - it('matches the canonical comapeo-cloud server route', () => { - // The comapeo-cloud server uses /remoteDetectionAlerts, not /alerts. - // This test ensures the constant stays in sync with the server. - expect(ALERTS_PATH).toBe('/remoteDetectionAlerts'); - }); -}); - -// --------------------------------------------------------------------------- -// getAlerts -// --------------------------------------------------------------------------- -describe('getAlerts', () => { - const projectId = 'proj-alert-1'; - - it('returns validated alerts', async () => { - server.use( - http.get(`${BASE_URL}/projects/${projectId}/remoteDetectionAlerts`, () => - HttpResponse.json({ - data: [ - { - docId: 'alert-1', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - deleted: false, - geometry: { type: 'Point', coordinates: [0, 0] }, - }, - ], - }), - ), - ); - const result = await apiClient.getAlerts(projectId); - expect(result.data).toHaveLength(1); - expect(result.data[0]!.docId).toBe('alert-1'); - }); - - it('sends Authorization header', async () => { - let capturedAuth: string | null = null; - server.use( - http.get( - `${BASE_URL}/projects/${projectId}/remoteDetectionAlerts`, - ({ request }) => { - capturedAuth = request.headers.get('Authorization'); - return HttpResponse.json({ data: [] }); - }, - ), - ); - await apiClient.getAlerts(projectId); - expect(capturedAuth).toBe('Bearer test-token'); - }); - - it('throws ApiError on error response', async () => { - server.use( - http.get(`${BASE_URL}/projects/${projectId}/remoteDetectionAlerts`, () => - HttpResponse.json( - { error: { code: 'SERVER_ERROR', message: 'Oops' } }, - { status: 500 }, - ), - ), - ); - await expect(apiClient.getAlerts(projectId)).rejects.toThrow(ApiError); - }); - - it('throws on network failure', async () => { - server.use( - http.get(`${BASE_URL}/projects/${projectId}/remoteDetectionAlerts`, () => - HttpResponse.error(), - ), - ); - await expect(apiClient.getAlerts(projectId)).rejects.toThrow( - 'Unable to connect', - ); - }); -}); - -// --------------------------------------------------------------------------- -// createAlert -// --------------------------------------------------------------------------- -describe('createAlert', () => { - const projectId = 'proj-create-alert'; - const body = { - geometry: { type: 'Point', coordinates: [1.0, 2.0] }, + return resp; }; - it('returns successfully on 201', async () => { - server.use( - http.post( - `${BASE_URL}/projects/${projectId}/remoteDetectionAlerts`, - () => new HttpResponse(null, { status: 201 }), - ), - ); - const result = await apiClient.createAlert(projectId, body); - expect(result.success).toBe(true); - }); - - it('sends Authorization header and request body', async () => { - let capturedAuth: string | null = null; - let capturedBody: unknown = null; - server.use( - http.post( - `${BASE_URL}/projects/${projectId}/remoteDetectionAlerts`, - async ({ request }) => { - capturedAuth = request.headers.get('Authorization'); - capturedBody = await request.json(); - return new HttpResponse(null, { status: 201 }); - }, - ), - ); - await apiClient.createAlert(projectId, body); - expect(capturedAuth).toBe('Bearer test-token'); - expect(capturedBody).toEqual(body); - }); - - it('throws ApiError on non-201 error', async () => { - server.use( - http.post(`${BASE_URL}/projects/${projectId}/remoteDetectionAlerts`, () => - HttpResponse.json( - { error: { code: 'BAD_REQUEST', message: 'Invalid' } }, - { status: 400 }, - ), - ), - ); - await expect(apiClient.createAlert(projectId, body)).rejects.toThrow( - ApiError, - ); - }); - - it('throws on network failure', async () => { - server.use( - http.post(`${BASE_URL}/projects/${projectId}/remoteDetectionAlerts`, () => - HttpResponse.error(), - ), - ); - await expect(apiClient.createAlert(projectId, body)).rejects.toThrow( - 'Unable to connect', - ); - }); -}); - -// --------------------------------------------------------------------------- -// 401 interceptor -// --------------------------------------------------------------------------- -describe('401 interceptor', () => { - it('clears auth store on 401 from authenticated endpoint', async () => { - expect(useAuthStore.getState().isAuthenticated).toBe(true); - expect(useAuthStore.getState().token).toBe('test-token'); - - server.use( - http.get(`${BASE_URL}/projects`, () => - HttpResponse.json( - { error: { code: 'UNAUTHORIZED', message: 'Token expired' } }, - { status: 401 }, - ), - ), - ); - - await expect(apiClient.getProjects()).rejects.toThrow(ApiError); - expect(useAuthStore.getState().isAuthenticated).toBe(false); - expect(useAuthStore.getState().token).toBeNull(); - }); - - it('clears auth store on 401 from any authenticated endpoint', async () => { - server.use( - http.get(`${BASE_URL}/projects/proj-x/observations`, () => - HttpResponse.json( - { error: { code: 'UNAUTHORIZED', message: 'Nope' } }, - { status: 401 }, - ), - ), - ); - - await expect(apiClient.getObservations('proj-x')).rejects.toThrow(); - expect(useAuthStore.getState().isAuthenticated).toBe(false); - expect(useAuthStore.getState().token).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// Base URL from auth store -// --------------------------------------------------------------------------- -describe('base URL from auth store', () => { - it('uses baseUrl from auth store', async () => { - const customUrl = 'https://custom-server.example.com'; - useAuthStore.setState({ baseUrl: customUrl }); - - let capturedUrl: string | null = null; - server.use( - http.get(`${customUrl}/info`, ({ request }) => { - capturedUrl = request.url; - return HttpResponse.json({ - data: { deviceId: 'x', name: 'y' }, - }); - }), - ); - - await apiClient.getServerInfo(); - expect(capturedUrl).toContain('custom-server.example.com/info'); - }); - - it('falls back to window.location.origin when baseUrl is null', async () => { - useAuthStore.setState({ baseUrl: null }); - - let requestReachedHandler = false; - server.use( - http.get('http://localhost:3000/info', () => { - requestReachedHandler = true; - return HttpResponse.json({ - data: { deviceId: 'x', name: 'y' }, - }); - }), - ); - - const result = await apiClient.getServerInfo(); - expect(result.data).toBeDefined(); - expect(requestReachedHandler).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// Explicit RequestConfig -// --------------------------------------------------------------------------- - -describe('explicit RequestConfig', () => { - const archiveUrl = 'https://archive.example.com'; - const archiveToken = 'archive-token'; - - it('uses baseUrl from RequestConfig for getProjects', async () => { - let capturedUrl: string | null = null; - server.use( - http.get(`${archiveUrl}/projects`, ({ request }) => { - capturedUrl = request.url; - return HttpResponse.json({ - data: [{ projectId: 'archive-proj-1' }], - }); - }), - ); - - const result = await apiClient.getProjects({ - baseUrl: archiveUrl, - token: archiveToken, + describe('getPresets', () => { + it('validates and returns real presets data', async () => { + const presetsPayload = { + data: [ + { + docId: 'preset-1', + versionId: 'preset-1/0', + originalVersionId: 'preset-1/0', + schemaName: 'preset' as const, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + links: [], + deleted: false, + name: 'Test Preset', + geometry: ['point' as const], + tags: { type: 'test' }, + addTags: {}, + removeTags: {}, + fieldRefs: [], + terms: [], + }, + ], + }; + + globalThis.fetch = vi + .fn() + .mockResolvedValue(makeFetchResponse(200, presetsPayload)); + + const result = await apiClient.getPresets('base32proj1'); + expect(result).toEqual(presetsPayload); }); - expect(capturedUrl).toContain(archiveUrl); - expect(result.data).toHaveLength(1); - }); - - it('sends bearer auth from RequestConfig', async () => { - let capturedAuth: string | null = null; - server.use( - http.get(`${archiveUrl}/projects`, ({ request }) => { - capturedAuth = request.headers.get('Authorization'); - return HttpResponse.json({ data: [] }); - }), - ); + it('returns empty data for 404 (legacy server)', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + makeFetchResponse(404, { + error: { code: 'NOT_FOUND', message: 'Project not found' }, + }), + ); - await apiClient.getProjects({ - baseUrl: archiveUrl, - token: archiveToken, + const result = await apiClient.getPresets('badproj'); + expect(result).toEqual({ data: [] }); }); - expect(capturedAuth).toBe(`Bearer ${archiveToken}`); - }); + it('throws ApiError for a 500 error', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + makeFetchResponse(500, { + error: { code: 'SERVER_ERROR', message: 'Boom' }, + }), + ); - it('RequestConfig overrides auth store values', async () => { - // Auth store has different credentials - useAuthStore.setState({ - token: 'wrong-token', - baseUrl: 'https://wrong.com', + await expect(apiClient.getPresets('base32proj1')).rejects.toThrow( + ApiError, + ); }); - let capturedAuth: string | null = null; - server.use( - http.get(`${archiveUrl}/projects`, ({ request }) => { - capturedAuth = request.headers.get('Authorization'); - return HttpResponse.json({ data: [] }); - }), - ); + it('throws ApiError for a 304 Not Modified (no cached body)', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(makeFetchResponse(304)); - await apiClient.getProjects({ - baseUrl: archiveUrl, - token: archiveToken, + await expect(apiClient.getPresets('base32proj1')).rejects.toThrow( + ApiError, + ); }); - - expect(capturedAuth).toBe(`Bearer ${archiveToken}`); }); - it('explicit RequestConfig works with getObservations', async () => { - const projectId = 'archive-proj'; - server.use( - http.get(`${archiveUrl}/projects/${projectId}/observations`, () => - HttpResponse.json({ data: [] }), - ), - ); + describe('getFields', () => { + it('returns empty data for 404 (legacy server)', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + makeFetchResponse(404, { + error: { code: 'NOT_FOUND', message: 'Not found' }, + }), + ); - const result = await apiClient.getObservations(projectId, { - baseUrl: archiveUrl, - token: archiveToken, + const result = await apiClient.getFields('badproj'); + expect(result).toEqual({ data: [] }); }); - - expect(result.data).toEqual([]); }); - it('explicit RequestConfig works with getAlerts', async () => { - const projectId = 'archive-proj'; - server.use( - http.get( - `${archiveUrl}/projects/${projectId}/remoteDetectionAlerts`, - () => HttpResponse.json({ data: [] }), - ), - ); + describe('getProject — 304 regression guard', () => { + it('throws ApiError on 304 (never resolves to wrong-shaped data)', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(makeFetchResponse(304)); - const result = await apiClient.getAlerts(projectId, { - baseUrl: archiveUrl, - token: archiveToken, + // getProject returns { data: { projectId, name? } }, NOT { data: [] }. + // A 304 must NOT silently resolve to the wrong shape. + await expect(apiClient.getProject('base32proj1')).rejects.toThrow( + ApiError, + ); }); - expect(result.data).toEqual([]); - }); + it('returns project detail on success', async () => { + const projectPayload = { + data: { projectId: 'base32proj1', name: 'Test Project' }, + }; - it('explicit RequestConfig works with createAlert', async () => { - const projectId = 'archive-proj'; - server.use( - http.post( - `${archiveUrl}/projects/${projectId}/remoteDetectionAlerts`, - () => new HttpResponse(null, { status: 201 }), - ), - ); + globalThis.fetch = vi + .fn() + .mockResolvedValue(makeFetchResponse(200, projectPayload)); - const result = await apiClient.createAlert( - projectId, - { geometry: { type: 'Point', coordinates: [0, 0] } }, - { baseUrl: archiveUrl, token: archiveToken }, - ); - - expect(result.success).toBe(true); - }); - - it('does NOT clear auth store on 401 with RequestConfig', async () => { - // Auth store has a valid session - expect(useAuthStore.getState().isAuthenticated).toBe(true); - expect(useAuthStore.getState().token).toBe('test-token'); - - server.use( - http.get(`${archiveUrl}/projects`, () => - HttpResponse.json( - { error: { code: 'UNAUTHORIZED', message: 'Token expired' } }, - { status: 401 }, - ), - ), - ); - - // Call with explicit RequestConfig — should NOT clear auth - await expect( - apiClient.getProjects({ baseUrl: archiveUrl, token: archiveToken }), - ).rejects.toThrow(ApiError); - - // Auth store should remain intact - expect(useAuthStore.getState().isAuthenticated).toBe(true); - expect(useAuthStore.getState().token).toBe('test-token'); - }); - - it('routes explicit archive calls through /api outside Vitest', () => { - const resolved = resolveApiRequest( - { baseUrl: 'https://archive.example.com/', token: archiveToken }, - { VITEST: false }, - ); - - expect(resolved.baseUrl).toBe('/api'); - expect(resolved.extraHeaders).toEqual({ - 'x-target-url': 'https://archive.example.com', + const result = await apiClient.getProject('base32proj1'); + expect(result).toEqual(projectPayload); }); }); - - it('keeps direct archive URLs in Vitest so MSW tests stay simple', () => { - const resolved = resolveApiRequest( - { baseUrl: archiveUrl, token: archiveToken }, - { VITEST: true }, - ); - - expect(resolved.baseUrl).toBe(archiveUrl); - expect(resolved.extraHeaders).toEqual({}); - }); -}); - -// --------------------------------------------------------------------------- -// getPresets -// --------------------------------------------------------------------------- -describe('getPresets', () => { - it('returns validated presets list', async () => { - const result = await apiClient.getPresets('proj1'); - expect(result.data).toBeInstanceOf(Array); - expect(result.data[0]).toHaveProperty('name'); - expect(result.data[0]).toHaveProperty('docId'); - }); - - it('sends Authorization header', async () => { - let capturedAuth: string | null = null; - server.use( - http.get(`${BASE_URL}/projects/proj1/preset`, ({ request }) => { - capturedAuth = request.headers.get('Authorization'); - return HttpResponse.json({ - data: [ - { - docId: 'preset-001', - versionId: 'preset-001/0', - originalVersionId: 'preset-001/0', - schemaName: 'preset', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - links: [], - deleted: false, - name: 'Test', - geometry: ['point'], - tags: {}, - addTags: {}, - removeTags: {}, - fieldRefs: [], - terms: [], - }, - ], - }); - }), - ); - await apiClient.getPresets('proj1'); - expect(capturedAuth).toBe('Bearer test-token'); - }); - - it('throws ApiError on error response', async () => { - server.use( - http.get(`${BASE_URL}/projects/proj1/preset`, () => - HttpResponse.json( - { error: { code: 'INTERNAL_ERROR', message: 'Boom' } }, - { status: 500 }, - ), - ), - ); - await expect(apiClient.getPresets('proj1')).rejects.toThrow(ApiError); - }); - - it('returns empty data array on 404 (legacy server)', async () => { - server.use( - http.get(`${BASE_URL}/projects/proj1/preset`, () => - HttpResponse.json( - { error: { code: 'NOT_FOUND', message: 'Not found' } }, - { status: 404 }, - ), - ), - ); - const result = await apiClient.getPresets('proj1'); - expect(result).toEqual({ data: [] }); - }); -}); - -// --------------------------------------------------------------------------- -// getFields -// --------------------------------------------------------------------------- -describe('getFields', () => { - it('returns validated fields list', async () => { - const result = await apiClient.getFields('proj1'); - expect(result.data).toBeInstanceOf(Array); - expect(result.data[0]).toHaveProperty('key'); - }); - - it('returns empty data array on 404 (legacy server)', async () => { - server.use( - http.get(`${BASE_URL}/projects/proj1/field`, () => - HttpResponse.json( - { error: { code: 'NOT_FOUND', message: 'Not found' } }, - { status: 404 }, - ), - ), - ); - const result = await apiClient.getFields('proj1'); - expect(result).toEqual({ data: [] }); - }); - - it('throws ApiError on non-404 error response', async () => { - server.use( - http.get(`${BASE_URL}/projects/proj1/field`, () => - HttpResponse.json( - { error: { code: 'INTERNAL_ERROR', message: 'Boom' } }, - { status: 500 }, - ), - ), - ); - await expect(apiClient.getFields('proj1')).rejects.toThrow(ApiError); - }); -}); - -// --------------------------------------------------------------------------- -// getIcon -// --------------------------------------------------------------------------- -describe('getIcon', () => { - it('returns the icon binary as a Blob', async () => { - const svgBytes = new TextEncoder().encode(''); - server.use( - http.get( - `${BASE_URL}/projects/proj1/icon/icon-001`, - () => - new HttpResponse(svgBytes, { - status: 200, - headers: { 'Content-Type': 'image/svg+xml' }, - }), - ), - ); - const result = await apiClient.getIcon('proj1', 'icon-001'); - expect(result.size).toBeGreaterThan(0); - expect(result.type).toBe('image/svg+xml'); - }); - - it('throws on non-200 status', async () => { - server.use( - http.get( - `${BASE_URL}/projects/proj1/icon/icon-001`, - () => new HttpResponse(null, { status: 404 }), - ), - ); - await expect(apiClient.getIcon('proj1', 'icon-001')).rejects.toThrow( - ApiError, - ); - }); -}); - -// --------------------------------------------------------------------------- -// getAttachmentUrl -// --------------------------------------------------------------------------- -describe('getAttachmentUrl', () => { - it('builds correct URL without variant', () => { - const url = getAttachmentUrl('proj-1', 'drive-abc', 'photo', 'image.jpg'); - expect(url).toBe( - `${BASE_URL}/projects/proj-1/attachments/drive-abc/photo/image.jpg`, - ); - }); - - it('builds correct URL with variant', () => { - const url = getAttachmentUrl( - 'proj-1', - 'drive-abc', - 'photo', - 'image.jpg', - 'thumbnail', - ); - expect(url).toBe( - `${BASE_URL}/projects/proj-1/attachments/drive-abc/photo/image.jpg?variant=thumbnail`, - ); - }); - - it('uses baseUrl from auth store', () => { - useAuthStore.setState({ baseUrl: 'https://other.example.com' }); - const url = getAttachmentUrl('proj-1', 'drive-abc', 'photo', 'img.png'); - expect(url).toBe( - 'https://other.example.com/projects/proj-1/attachments/drive-abc/photo/img.png', - ); - }); - - it('allows an explicit archive base URL override', () => { - useAuthStore.setState({ baseUrl: 'https://active.example.com' }); - const url = getAttachmentUrl( - 'proj-1', - 'drive-abc', - 'photo', - 'img.png', - undefined, - { baseUrl: 'https://archive.example.com/base/' }, - ); - - expect(url).toBe( - 'https://archive.example.com/base/projects/proj-1/attachments/drive-abc/photo/img.png', - ); - }); - - it('builds a relative URL when no auth store baseUrl is active', () => { - useAuthStore.setState({ baseUrl: null }); - const url = getAttachmentUrl('proj-1', 'drive-abc', 'photo', 'img.png'); - expect(url).toBe('/projects/proj-1/attachments/drive-abc/photo/img.png'); - }); }); diff --git a/tests/unit/lib/categories-db.test.ts b/tests/unit/lib/categories-db.test.ts new file mode 100644 index 00000000..0774cbb2 --- /dev/null +++ b/tests/unit/lib/categories-db.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + categoriesDb, + getCategorySet, + getCategorySets, + importCategorySet, +} from '@/lib/categories-db'; + +beforeEach(async () => { + await categoriesDb.categorySets.clear(); +}); + +describe('importCategorySet', () => { + it('imports a valid category set', async () => { + const data = { + metadata: { name: 'Test Set' }, + categories: { + animal: { + name: 'Animal', + icon: 'icon-animal.png', + color: '#FF0000', + fields: ['animal-type'], + appliesTo: ['point'], + tags: { type: 'animal' }, + }, + }, + fields: { + 'animal-type': { + tagKey: 'animal-type', + type: 'selectOne', + label: 'Animal Type', + options: [ + { label: 'Dog', value: 'dog' }, + { label: 'Cat', value: 'cat' }, + ], + }, + }, + }; + + await importCategorySet('test-set', 'Test Set', data); + + const result = await getCategorySet('test-set'); + expect(result).toBeDefined(); + expect(result!.setId).toBe('test-set'); + expect(result!.name).toBe('Test Set'); + expect(result!.categories).toEqual(data.categories); + expect(result!.fields).toEqual(data.fields); + expect(result!.metadata).toEqual(data.metadata); + expect(result!.importedAt).toBeDefined(); + }); + + it('throws on invalid data', async () => { + const invalidData = { + metadata: { name: 'Bad Set' }, + categories: 'not-an-object', + fields: {}, + }; + + await expect( + importCategorySet('bad-set', 'Bad Set', invalidData as never), + ).rejects.toThrow(); + }); + + it('throws when categories are missing required fields', async () => { + const invalidData = { + categories: { + animal: { + name: 'Animal', + // missing icon, color, fields, appliesTo, tags + }, + }, + fields: {}, + }; + + await expect( + importCategorySet('bad-set', 'Bad Set', invalidData as never), + ).rejects.toThrow(); + }); + + it('re-imports same setId replacing the existing set', async () => { + const dataV1 = { + categories: { + animal: { + name: 'Animal', + icon: 'icon.png', + color: '#FF0000', + fields: [], + appliesTo: ['point'], + tags: { type: 'animal' }, + }, + }, + fields: {}, + }; + + await importCategorySet('replace-me', 'Original', dataV1); + + const dataV2 = { + metadata: { name: 'Updated' }, + categories: { + plant: { + name: 'Plant', + icon: 'icon-plant.png', + color: '#00FF00', + fields: [], + appliesTo: ['area'], + tags: { type: 'plant' }, + }, + }, + fields: {}, + }; + + await importCategorySet('replace-me', 'Updated', dataV2); + + const result = await getCategorySet('replace-me'); + expect(result!.name).toBe('Updated'); + expect(result!.categories).toEqual(dataV2.categories); + }); +}); + +describe('getCategorySets', () => { + it('returns all imported sets', async () => { + await importCategorySet('set-a', 'Set A', { + categories: {}, + fields: {}, + }); + await importCategorySet('set-b', 'Set B', { + categories: {}, + fields: {}, + }); + + const sets = await getCategorySets(); + expect(sets).toHaveLength(2); + + const ids = sets.map((s) => s.setId).sort(); + expect(ids).toEqual(['set-a', 'set-b']); + }); + + it('returns empty array when no sets exist', async () => { + const sets = await getCategorySets(); + expect(sets).toEqual([]); + }); +}); + +describe('getCategorySet', () => { + it('returns undefined for non-existent set', async () => { + const result = await getCategorySet('nonexistent'); + expect(result).toBeUndefined(); + }); + + it('returns the set with all fields', async () => { + const data = { + metadata: { name: 'Full Set' }, + categories: { + test: { + name: 'Test', + icon: 'icon.png', + color: '#0000FF', + fields: ['field-a'], + appliesTo: ['line'], + tags: { type: 'test' }, + }, + }, + fields: { + 'field-a': { + tagKey: 'field-a', + type: 'text', + label: 'Field A', + }, + }, + }; + + await importCategorySet('full-set', 'Full Set', data); + + const result = await getCategorySet('full-set'); + expect(result).toEqual({ + setId: 'full-set', + name: 'Full Set', + metadata: { name: 'Full Set' }, + categories: data.categories, + fields: data.fields, + importedAt: expect.any(String), + }); + }); +}); diff --git a/tests/unit/lib/schemas/preset.test.ts b/tests/unit/lib/schemas/preset.test.ts index 69b7f369..a28816c6 100644 --- a/tests/unit/lib/schemas/preset.test.ts +++ b/tests/unit/lib/schemas/preset.test.ts @@ -1,7 +1,17 @@ +import { loadDefaultCategoryFixtures } from '@tests/fixtures/categories'; import * as v from 'valibot'; import { describe, expect, it } from 'vitest'; -import { presetSchema, presetsResponseSchema } from '@/lib/schemas/preset'; +import { + categorySchema, + comapeoCatSchema, + fieldSchema, + metadataSchema, + presetSchema, + presetsResponseSchema, +} from '@/lib/schemas/preset'; + +// --- Server wire-type schemas (existing) --- describe('presetSchema', () => { const validPreset = { @@ -79,6 +89,23 @@ describe('presetSchema', () => { }; expect(v.safeParse(presetSchema, invalid).success).toBe(false); }); + + // Regression: presetRefSchema required 'url' but server omits it + it('accepts fieldRefs without url (server omits url per comapeo-schema v1)', () => { + const withoutUrl = { + ...validPreset, + fieldRefs: [{ docId: 'field-001', versionId: 'field-001/0' }], + }; + expect(v.safeParse(presetSchema, withoutUrl).success).toBe(true); + }); + + it('accepts iconRef without url (server omits url per comapeo-schema v1)', () => { + const withoutUrl = { + ...validPreset, + iconRef: { docId: 'icon-001', versionId: 'icon-001/0' }, + }; + expect(v.safeParse(presetSchema, withoutUrl).success).toBe(true); + }); }); describe('presetsResponseSchema', () => { @@ -112,4 +139,352 @@ describe('presetsResponseSchema', () => { false, ); }); + + // Regression: real comapeo-cloud server omits optional fields like + // addTags, removeTags, terms, links, geometry, versionId, originalVersionId. + // The schema must accept these omissions without failing. + it('validates a real server response with optional fields omitted', () => { + const realServerResponse = { + data: [ + { + docId: 'preset-abc123', + schemaName: 'preset', + createdAt: '2024-03-15T10:00:00Z', + updatedAt: '2024-03-15T10:00:00Z', + deleted: false, + name: 'Deforestation', + tags: { category: 'forest-risk' }, + fieldRefs: [], + }, + { + docId: 'preset-def456', + schemaName: 'preset', + createdAt: '2024-03-14T14:00:00Z', + updatedAt: '2024-03-14T14:00:00Z', + deleted: false, + name: 'Water Contamination', + tags: {}, + fieldRefs: [{ docId: 'field-001' }], + color: '#3357FF', + }, + ], + }; + const result = v.safeParse(presetsResponseSchema, realServerResponse); + expect(result.success).toBe(true); + }); + + it('validates server response with extra unknown fields', () => { + const responseWithExtras = { + data: [ + { + docId: 'preset-xyz', + schemaName: 'preset', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deleted: false, + name: 'Test', + tags: {}, + fieldRefs: [], + futureField: 'ignored', + anotherNewField: 42, + }, + ], + }; + expect(v.safeParse(presetsResponseSchema, responseWithExtras).success).toBe( + true, + ); + }); +}); + +// --- Import-file schemas (new for #143) --- + +describe('metadataSchema', () => { + it('validates metadata with name', () => { + const result = v.safeParse(metadataSchema, { + name: 'CoMapeo Default Categories', + }); + expect(result.success).toBe(true); + }); + + it('accepts metadata with optional fields omitted', () => { + const result = v.safeParse(metadataSchema, { name: 'Test' }); + expect(result.success).toBe(true); + }); + + it('accepts metadata with additional properties (extensible)', () => { + const result = v.safeParse(metadataSchema, { + name: 'Test', + version: '1.0.0', + extra: 'allowed', + }); + expect(result.success).toBe(true); + }); + + it('rejects metadata missing name', () => { + const result = v.safeParse(metadataSchema, {}); + expect(result.success).toBe(false); + }); +}); + +describe('fieldSchema (import format)', () => { + const validTextField = { + tagKey: 'name', + type: 'text', + label: 'Name', + helperText: 'Common name of this thing', + }; + + const validSelectOneField = { + tagKey: 'animal-type', + type: 'selectOne', + label: 'Animal type', + helperText: 'What kind of animal?', + options: [ + { label: 'Mammal', value: 'mammal' }, + { label: 'Bird', value: 'bird' }, + ], + }; + + const validSelectMultipleField = { + tagKey: 'cultural-category', + type: 'selectMultiple', + label: 'Cultural activity', + helperText: 'What type of cultural activity?', + options: [ + { label: 'Archeology', value: 'archeology' }, + { label: 'History', value: 'history' }, + ], + }; + + it('validates a text field', () => { + expect(v.safeParse(fieldSchema, validTextField).success).toBe(true); + }); + + it('validates a selectOne field with options', () => { + expect(v.safeParse(fieldSchema, validSelectOneField).success).toBe(true); + }); + + it('validates a selectMultiple field with options', () => { + expect(v.safeParse(fieldSchema, validSelectMultipleField).success).toBe( + true, + ); + }); + + it('validates a date field', () => { + const dateField = { + tagKey: 'observation-date', + type: 'date', + label: 'Date', + helperText: 'When was this observed?', + }; + expect(v.safeParse(fieldSchema, dateField).success).toBe(true); + }); + + it('validates a number field', () => { + const numberField = { + tagKey: 'count', + type: 'number', + label: 'Count', + helperText: 'How many?', + }; + expect(v.safeParse(fieldSchema, numberField).success).toBe(true); + }); + + it('rejects field missing type', () => { + const { type: _type, ...rest } = validTextField; + expect(v.safeParse(fieldSchema, rest).success).toBe(false); + }); + + it('rejects field with invalid type value', () => { + const invalid = { ...validTextField, type: 'invalid-type' }; + expect(v.safeParse(fieldSchema, invalid).success).toBe(false); + }); + + it('rejects field missing tagKey', () => { + const { tagKey: _tagKey, ...rest } = validTextField; + expect(v.safeParse(fieldSchema, rest).success).toBe(false); + }); + + it('rejects field missing label', () => { + const { label: _label, ...rest } = validTextField; + expect(v.safeParse(fieldSchema, rest).success).toBe(false); + }); + + it('accepts field with optional helperText omitted', () => { + const minimal = { + tagKey: 'notes', + type: 'text', + label: 'Notes', + }; + expect(v.safeParse(fieldSchema, minimal).success).toBe(true); + }); + + it('accepts additional properties (extensible metadata)', () => { + const extended = { + ...validTextField, + extraProp: 'allowed', + }; + expect(v.safeParse(fieldSchema, extended).success).toBe(true); + }); +}); + +describe('categorySchema (import format)', () => { + const validCategory = { + name: 'Animal', + icon: 'animal', + color: '#9E2C54', + fields: ['name', 'animal-type'], + appliesTo: ['observation'], + tags: { + type: 'nature', + nature: 'wildlife', + wildlife: 'animal', + }, + }; + + it('validates a complete category', () => { + const result = v.safeParse(categorySchema, validCategory); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.name).toBe('Animal'); + expect(result.output.icon).toBe('animal'); + } + }); + + it('rejects category missing name', () => { + const { name: _name, ...rest } = validCategory; + expect(v.safeParse(categorySchema, rest).success).toBe(false); + }); + + it('rejects category with invalid color format', () => { + const invalid = { ...validCategory, color: 'not-a-color' }; + expect(v.safeParse(categorySchema, invalid).success).toBe(false); + }); + + it('rejects category missing icon', () => { + const { icon: _icon, ...rest } = validCategory; + expect(v.safeParse(categorySchema, rest).success).toBe(false); + }); + + it('rejects category with empty tags', () => { + const invalid = { ...validCategory, tags: {} }; + expect(v.safeParse(categorySchema, invalid).success).toBe(false); + }); + + it('rejects category missing appliesTo', () => { + const { appliesTo: _a, ...rest } = validCategory; + expect(v.safeParse(categorySchema, rest).success).toBe(false); + }); + + it('rejects category with empty appliesTo', () => { + const invalid = { ...validCategory, appliesTo: [] }; + expect(v.safeParse(categorySchema, invalid).success).toBe(false); + }); + + it('accepts additional properties (extensible metadata)', () => { + const extended = { + ...validCategory, + extraProp: 'allowed', + }; + expect(v.safeParse(categorySchema, extended).success).toBe(true); + }); +}); + +describe('comapeoCatSchema (import file envelope)', () => { + const validPayload = { + metadata: { name: 'CoMapeo Default Categories' }, + categorySelection: { + observation: ['animal', 'plant'], + track: ['roadway'], + }, + categories: { + animal: { + name: 'Animal', + icon: 'animal', + color: '#9E2C54', + fields: ['name'], + appliesTo: ['observation'], + tags: { type: 'nature', nature: 'wildlife' }, + }, + }, + fields: { + name: { + tagKey: 'name', + type: 'text', + label: 'Name', + }, + }, + }; + + it('validates a complete .comapeocat payload', () => { + expect(v.safeParse(comapeoCatSchema, validPayload).success).toBe(true); + }); + + it('rejects payload missing categories', () => { + const { categories: _c, ...rest } = validPayload; + expect(v.safeParse(comapeoCatSchema, rest).success).toBe(false); + }); + + it('rejects payload missing fields', () => { + const { fields: _f, ...rest } = validPayload; + expect(v.safeParse(comapeoCatSchema, rest).success).toBe(false); + }); + + it('validates payload without metadata (optional)', () => { + const { metadata: _m, ...rest } = validPayload; + expect(v.safeParse(comapeoCatSchema, rest).success).toBe(true); + }); +}); + +// --- Upstream fixture validation --- + +describe('upstream default category fixtures', () => { + const fixtures = loadDefaultCategoryFixtures(); + + it('loads representative fixtures', () => { + expect(Object.keys(fixtures.categories).length).toBeGreaterThanOrEqual(3); + }); + + it.each(Object.keys(fixtures.categories))( + 'category "%s" parses successfully', + (key) => { + const result = v.safeParse(categorySchema, fixtures.categories[key]); + expect(result.success).toBe(true); + }, + ); + + it.each(Object.keys(fixtures.fields))( + 'field "%s" parses successfully', + (key) => { + const result = v.safeParse(fieldSchema, fixtures.fields[key]); + expect(result.success).toBe(true); + }, + ); + + it('metadata parses successfully', () => { + expect(v.safeParse(metadataSchema, fixtures.metadata).success).toBe(true); + }); + + it('field types cover all supported variants', () => { + const types = new Set(Object.values(fixtures.fields).map((f) => f.type)); + expect(types.has('text')).toBe(true); + expect(types.has('selectOne')).toBe(true); + expect(types.has('selectMultiple')).toBe(true); + }); + + it('category tags always include type key', () => { + for (const cat of Object.values(fixtures.categories)) { + expect(cat.tags.type).toBeDefined(); + expect(typeof cat.tags.type).toBe('string'); + } + }); + + it('categories reference valid field keys', () => { + const fieldKeys = new Set(Object.keys(fixtures.fields)); + for (const [_catKey, cat] of Object.entries(fixtures.categories)) { + for (const fieldRef of cat.fields) { + expect(fieldKeys.has(fieldRef)).toBe(true); + } + } + }); }); diff --git a/tests/unit/screens/CategoriesEditor/CategoryCard.test.tsx b/tests/unit/screens/CategoriesEditor/CategoryCard.test.tsx new file mode 100644 index 00000000..ef9901cb --- /dev/null +++ b/tests/unit/screens/CategoriesEditor/CategoryCard.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen } from '@tests/mocks/test-utils'; +import { describe, expect, it, vi } from 'vitest'; + +import { CategoryCard } from '@/screens/CategoriesEditor/CategoryCard'; + +const defaultProps = { + docId: 'cat-1', + label: 'Deforestation', + fieldRefs: [{ docId: 'field-1', label: 'Severity' }], +}; + +describe('CategoryCard', () => { + it('calls onClick with docId when clicked', () => { + const onClick = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('category-card')); + + expect(onClick).toHaveBeenCalledWith('cat-1'); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('calls onClick when Enter is pressed on the card', () => { + const onClick = vi.fn(); + render(); + + fireEvent.keyDown(screen.getByTestId('category-card'), { key: 'Enter' }); + + expect(onClick).toHaveBeenCalledWith('cat-1'); + }); + + it('calls onClick when Space is pressed on the card', () => { + const onClick = vi.fn(); + render(); + + fireEvent.keyDown(screen.getByTestId('category-card'), { key: ' ' }); + + expect(onClick).toHaveBeenCalledWith('cat-1'); + }); + + it('sets aria-pressed to true when selected', () => { + render(); + + expect(screen.getByTestId('category-card')).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); + + it('sets aria-pressed to false when not selected', () => { + render(); + + expect(screen.getByTestId('category-card')).toHaveAttribute( + 'aria-pressed', + 'false', + ); + }); + + it('renders the label as heading', () => { + render(); + + expect(screen.getByText('Deforestation')).toBeInTheDocument(); + }); + + it('renders field count using i18n plural', () => { + render(); + + expect(screen.getByText('1 field')).toBeInTheDocument(); + }); + + it('renders plural field count for multiple fieldRefs', () => { + render( + , + ); + + expect(screen.getByText('3 fields')).toBeInTheDocument(); + }); + + it('renders color accent bar when color is provided', () => { + render(); + + const accent = screen.getByTestId('color-accent'); + expect(accent).toBeInTheDocument(); + expect(accent).toHaveStyle({ backgroundColor: '#FF0000' }); + }); + + it('renders initial avatar with first letter uppercase', () => { + render(); + + expect(screen.getByText('M')).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/screens/CategoriesEditor/CategoryDetail.test.tsx b/tests/unit/screens/CategoriesEditor/CategoryDetail.test.tsx new file mode 100644 index 00000000..fc4e1fc5 --- /dev/null +++ b/tests/unit/screens/CategoriesEditor/CategoryDetail.test.tsx @@ -0,0 +1,124 @@ +import { render, screen } from '@tests/mocks/test-utils'; +import { describe, expect, it, vi } from 'vitest'; + +import type { Category } from '@/hooks/useCategories'; +import { CategoryDetail } from '@/screens/CategoriesEditor/CategoryDetail'; + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ + children, + to, + ...props + }: { + children: React.ReactNode; + to: string; + }) => ( + + {children} + + ), +})); + +const sampleCategory: Category = { + docId: 'cat-1', + label: 'Deforestation', + fieldRefs: [ + { docId: 'field-1', label: 'Severity' }, + { docId: 'field-2', label: 'Area' }, + { docId: 'field-3' }, + ], + color: '#22c55e', +}; + +describe('CategoryDetail', () => { + it('shows placeholder when category is null', () => { + render(); + expect(screen.getByText('Select a category')).toBeInTheDocument(); + }); + + it('renders category name', () => { + render( + , + ); + expect( + screen.getByRole('heading', { name: 'Deforestation' }), + ).toBeInTheDocument(); + }); + + it('renders category icon with first letter', () => { + render( + , + ); + const icon = screen.getByTestId('category-detail-icon'); + expect(icon).toHaveTextContent('D'); + }); + + it('renders category color swatch with hex value', () => { + render( + , + ); + expect(screen.getByText('#22c55e')).toBeInTheDocument(); + const swatch = screen.getByTestId('color-swatch'); + expect(swatch).toHaveStyle({ backgroundColor: '#22c55e' }); + }); + + it('renders field count and field refs with resolved labels', () => { + render( + , + ); + expect(screen.getByText(/3 fields/)).toBeInTheDocument(); + expect(screen.getByText('Severity')).toBeInTheDocument(); + expect(screen.getByText('Area (ha)')).toBeInTheDocument(); + }); + + it('shows docId for field refs without a label in fieldLabels map', () => { + render( + , + ); + expect(screen.getByText('field-3')).toBeInTheDocument(); + }); + + it('renders back navigation link to /categories', () => { + render( + , + ); + const backLink = screen.getByRole('link', { name: /Categories/ }); + expect(backLink).toHaveAttribute('href', '/categories'); + }); + + it('renders appliesTo badges from fieldRefs', () => { + render( + , + ); + expect(screen.getByText(/fields/)).toBeInTheDocument(); + }); + + it('renders color swatch with background color matching category color', () => { + render( + , + ); + const swatch = screen.getByTestId('color-swatch'); + expect(swatch).toHaveStyle({ backgroundColor: '#22c55e' }); + }); + + it('renders empty fieldRefs gracefully', () => { + const emptyFieldsCategory: Category = { + docId: 'cat-2', + label: 'Mining', + fieldRefs: [], + color: '#f59e0b', + }; + render( + , + ); + expect(screen.getByText(/0 fields/)).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/screens/CategoriesEditor/CategoryGrid.test.tsx b/tests/unit/screens/CategoriesEditor/CategoryGrid.test.tsx new file mode 100644 index 00000000..87b22080 --- /dev/null +++ b/tests/unit/screens/CategoriesEditor/CategoryGrid.test.tsx @@ -0,0 +1,115 @@ +import { render, screen } from '@tests/mocks/test-utils'; +import { describe, expect, it } from 'vitest'; + +import type { CategoryGroup } from '@/hooks/useCategories'; +import { CategoryGrid } from '@/screens/CategoriesEditor/CategoryGrid'; + +const sampleGroups: CategoryGroup[] = [ + { + type: 'environment', + categories: [ + { + docId: 'cat-1', + label: 'Deforestation', + fieldRefs: [ + { docId: 'f1', label: 'Severity' }, + { docId: 'f2', label: 'Area' }, + ], + color: '#22c55e', + iconRef: undefined, + }, + { + docId: 'cat-2', + label: 'Mining', + fieldRefs: [{ docId: 'f3' }], + color: '#f59e0b', + iconRef: { docId: 'icon-1' }, + }, + ], + }, + { + type: 'infrastructure', + categories: [ + { + docId: 'cat-3', + label: 'Roads', + fieldRefs: [], + color: '#3b82f6', + iconRef: undefined, + }, + ], + }, +]; + +describe('CategoryGrid', () => { + it('renders group headings for each tags.type', () => { + render(); + expect(screen.getByText('environment')).toBeInTheDocument(); + expect(screen.getByText('infrastructure')).toBeInTheDocument(); + }); + + it('renders category cards with names', () => { + render(); + expect(screen.getByText('Deforestation')).toBeInTheDocument(); + expect(screen.getByText('Mining')).toBeInTheDocument(); + expect(screen.getByText('Roads')).toBeInTheDocument(); + }); + + it('renders color accent on category cards', () => { + render(); + const cards = document.querySelectorAll('[data-testid="category-card"]'); + expect(cards.length).toBe(3); + + const deforestationCard = cards[0] as HTMLElement; + const accent = deforestationCard.querySelector( + '[data-testid="color-accent"]', + ); + expect(accent).toBeInTheDocument(); + expect(accent).toHaveStyle({ backgroundColor: '#22c55e' }); + }); + + it('renders first letter of name as icon fallback', () => { + render(); + const cards = document.querySelectorAll('[data-testid="category-card"]'); + const deforestationCard = cards[0] as HTMLElement; + const icon = deforestationCard.querySelector( + '[data-testid="category-icon"]', + ); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveTextContent('D'); + }); + + it('renders field count badge', () => { + render(); + const cards = document.querySelectorAll('[data-testid="category-card"]'); + // Deforestation has 2 fields + expect(cards[0]).toHaveTextContent('2 fields'); + // Mining has 1 field + expect(cards[1]).toHaveTextContent('1 field'); + // Roads has 0 fields + expect(cards[2]).toHaveTextContent('0 fields'); + }); + + it('renders empty state when no groups', () => { + render(); + expect( + screen.getByText('No categories match your search'), + ).toBeInTheDocument(); + }); + + it('category cards are keyboard focusable', () => { + render(); + const cards = document.querySelectorAll('[data-testid="category-card"]'); + for (const card of cards) { + expect(card).toHaveAttribute('tabindex', '0'); + } + }); + + it('category cards use semantic article element', () => { + render(); + const articles = document.querySelectorAll( + 'article[data-testid="category-card"]', + ); + expect(articles.length).toBe(3); + }); +}); diff --git a/tests/unit/screens/CategoriesEditor/FieldViewer.test.tsx b/tests/unit/screens/CategoriesEditor/FieldViewer.test.tsx new file mode 100644 index 00000000..21d393b0 --- /dev/null +++ b/tests/unit/screens/CategoriesEditor/FieldViewer.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from '@tests/mocks/test-utils'; +import { describe, expect, it } from 'vitest'; + +import { FieldViewer } from '@/screens/CategoriesEditor/FieldViewer'; +import type { FieldInput } from '@/screens/CategoriesEditor/FieldViewer'; + +const textField: FieldInput = { + docId: 'field-1', + tagKey: 'species', + type: 'text', + label: 'Species Name', +}; + +const selectOneField: FieldInput = { + docId: 'field-2', + tagKey: 'threat_level', + type: 'selectOne', + label: 'Threat Level', + options: [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, + ], +}; + +const selectMultipleField: FieldInput = { + docId: 'field-3', + tagKey: 'observed_species', + type: 'selectMultiple', + label: 'Observed Species', + options: [ + { label: 'Eagle', value: 'eagle' }, + { label: 'Jaguar', value: 'jaguar' }, + ], +}; + +const dateField: FieldInput = { + docId: 'field-4', + tagKey: 'observation_date', + type: 'date', + label: 'Observation Date', +}; + +const numberField: FieldInput = { + docId: 'field-5', + tagKey: 'tree_count', + type: 'number', + label: 'Tree Count', +}; + +const unknownField: FieldInput = { + docId: 'field-6', + tagKey: 'weird', + type: 'custom_type', + label: 'Custom Field', +}; + +describe('FieldViewer', () => { + it('renders text field preview with label and tag key', () => { + render(); + expect(screen.getByText('Species Name')).toBeInTheDocument(); + expect(screen.getByText('species')).toBeInTheDocument(); + }); + + it('renders select_one field with options as badges', () => { + render(); + expect(screen.getByText('Threat Level')).toBeInTheDocument(); + expect(screen.getByText('Low')).toBeInTheDocument(); + expect(screen.getByText('Medium')).toBeInTheDocument(); + expect(screen.getByText('High')).toBeInTheDocument(); + }); + + it('renders select_multiple field with options as badges', () => { + render(); + expect(screen.getByText('Observed Species')).toBeInTheDocument(); + expect(screen.getByText('Eagle')).toBeInTheDocument(); + expect(screen.getByText('Jaguar')).toBeInTheDocument(); + }); + + it('renders date field with date indicator', () => { + render(); + expect(screen.getByText('Observation Date')).toBeInTheDocument(); + expect(screen.getByText('Date')).toBeInTheDocument(); + }); + + it('renders number field with number indicator', () => { + render(); + expect(screen.getByText('Tree Count')).toBeInTheDocument(); + expect(screen.getByText('Number')).toBeInTheDocument(); + }); + + it('renders unknown type with fallback label', () => { + render(); + expect(screen.getByText('Custom Field')).toBeInTheDocument(); + expect(screen.getByText('Unknown type')).toBeInTheDocument(); + }); + + it('renders multiple fields', () => { + render(); + expect(screen.getByText('Species Name')).toBeInTheDocument(); + expect(screen.getByText('Threat Level')).toBeInTheDocument(); + expect(screen.getByText('Observation Date')).toBeInTheDocument(); + }); + + it('uses accessible list markup', () => { + render(); + const list = screen.getByRole('list'); + expect(list).toBeInTheDocument(); + const items = screen.getAllByRole('listitem'); + expect(items.length).toBe(2); + }); + + it('each field item has an accessible label', () => { + render(); + const items = screen.getAllByRole('listitem'); + expect(items[0]).toHaveAccessibleName('Species Name'); + expect(items[1]).toHaveAccessibleName('Threat Level'); + }); +}); diff --git a/tests/unit/screens/CategoriesEditor/ImportSetDialog.test.tsx b/tests/unit/screens/CategoriesEditor/ImportSetDialog.test.tsx new file mode 100644 index 00000000..d0ee181e --- /dev/null +++ b/tests/unit/screens/CategoriesEditor/ImportSetDialog.test.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen, waitFor } from '@tests/mocks/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { categoriesDb } from '@/lib/categories-db'; +import { ImportSetDialog } from '@/screens/CategoriesEditor/ImportSetDialog'; + +beforeEach(async () => { + await categoriesDb.categorySets.clear(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function makeValidFileContent() { + return JSON.stringify({ + metadata: { name: 'Test Import' }, + categories: { + animal: { + name: 'Animal', + icon: 'icon-animal.png', + color: '#FF0000', + fields: ['animal-type'], + appliesTo: ['point'], + tags: { type: 'animal' }, + }, + }, + fields: { + 'animal-type': { + tagKey: 'animal-type', + type: 'selectOne', + label: 'Animal Type', + options: [ + { label: 'Dog', value: 'dog' }, + { label: 'Cat', value: 'cat' }, + ], + }, + }, + }); +} + +function makeInvalidFileContent() { + return JSON.stringify({ + categories: 'not-an-object', + fields: {}, + }); +} + +function simulateFileSelect(content: string, fileName = 'test.comapeocat') { + const file = new File([content], fileName, { type: 'application/json' }); + const input = screen.getByLabelText(/file/i) as HTMLInputElement; + Object.defineProperty(input, 'files', { + value: [file], + configurable: true, + }); + fireEvent.change(input); +} + +describe('ImportSetDialog', () => { + it('renders file input when open', () => { + render(); + expect(screen.getByLabelText(/file/i)).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + render(); + expect(screen.queryByLabelText(/file/i)).toBeNull(); + }); + + it('shows validation error for invalid JSON', async () => { + render(); + + simulateFileSelect(makeInvalidFileContent()); + + await waitFor(() => { + expect(screen.getByText(/invalid/i)).toBeInTheDocument(); + }); + }); + + it('shows validation error for malformed JSON', async () => { + render(); + + simulateFileSelect('not json at all'); + + await waitFor(() => { + expect(screen.getByText(/invalid json/i)).toBeInTheDocument(); + }); + }); + + it('shows success on valid import', async () => { + render(); + + simulateFileSelect(makeValidFileContent()); + + await waitFor(() => { + expect(screen.getByText(/imported/i)).toBeInTheDocument(); + }); + }); + + it('stores the set in the database on valid import', async () => { + render(); + + simulateFileSelect(makeValidFileContent()); + + await waitFor(async () => { + const sets = await categoriesDb.categorySets.toArray(); + expect(sets).toHaveLength(1); + expect(sets[0]!.name).toBe('Test Import'); + }); + }); + + it('shows confirmation before replacing existing set', async () => { + // Pre-populate with an existing set whose setId matches the file name + const { importCategorySet } = await import('@/lib/categories-db'); + await importCategorySet('test-import', 'Original', { + categories: {}, + fields: {}, + }); + + render(); + + simulateFileSelect(makeValidFileContent(), 'test-import.comapeocat'); + + // Should show confirmation prompt with Replace button + await waitFor(() => { + expect( + screen.getByRole('button', { name: /replace/i }), + ).toBeInTheDocument(); + }); + }); + + it('closes dialog after successful import', async () => { + const onClose = vi.fn(); + render(); + + simulateFileSelect(makeValidFileContent()); + + await waitFor( + () => { + expect(onClose).toHaveBeenCalled(); + }, + { timeout: 3000 }, + ); + }); + + it('executes replace when user confirms replacement of existing set', async () => { + // Pre-populate an existing set + const { importCategorySet } = await import('@/lib/categories-db'); + await importCategorySet('test-replace', 'Original', { + categories: {}, + fields: {}, + }); + + render(); + + simulateFileSelect(makeValidFileContent(), 'test-replace.comapeocat'); + + // Wait for Replace button to appear + const replaceBtn = await screen.findByRole('button', { name: /replace/i }); + fireEvent.click(replaceBtn); + + // Should show success message + await waitFor(() => { + expect(screen.getByText(/imported/i)).toBeInTheDocument(); + }); + + // Verify the set was actually replaced + const sets = await categoriesDb.categorySets.toArray(); + expect(sets).toHaveLength(1); + expect(sets[0]!.name).toBe('Test Import'); + }); + + it('shows file read error when file.text() rejects', async () => { + render(); + + const input = screen.getByLabelText(/file/i) as HTMLInputElement; + // Create a File whose .text() method rejects + const badFile = new File([''], 'bad.comapeocat', { + type: 'application/json', + }); + vi.spyOn(badFile, 'text').mockRejectedValue(new Error('Disk error')); + Object.defineProperty(input, 'files', { + value: [badFile], + configurable: true, + }); + fireEvent.change(input); + + await waitFor(() => { + expect(screen.getByText(/failed to read file/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/tests/unit/screens/CategoriesEditor/index.test.tsx b/tests/unit/screens/CategoriesEditor/index.test.tsx new file mode 100644 index 00000000..208f9c8f --- /dev/null +++ b/tests/unit/screens/CategoriesEditor/index.test.tsx @@ -0,0 +1,228 @@ +import { render, screen } from '@tests/mocks/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useApiPresets } from '@/hooks/useApiPresets'; +import { useProjects } from '@/hooks/useProjects'; +import { CategoriesEditorScreen } from '@/screens/CategoriesEditor'; + +const defaultPresets: Array<{ + docId: string; + name: string; + tags: Record; + fieldRefs: Array<{ docId: string; label?: string }>; +}> = [ + { + docId: 'preset-1', + name: 'Deforestation', + tags: { type: 'environment' }, + fieldRefs: [{ docId: 'field-1', label: 'Severity' }], + }, + { + docId: 'preset-2', + name: 'Mining', + tags: { type: 'environment' }, + fieldRefs: [], + }, +]; + +let mockSelectedProjectId: string | null = 'proj-1'; +let mockPresetsQuery: { + data?: typeof defaultPresets; + isPending: boolean; + isError?: boolean; + refetch?: () => void; +} = { data: defaultPresets, isPending: false }; + +let mockProjectsQuery: { + data: Array<{ localId: string; name: string; remoteId?: string }>; + isPending: boolean; + isError?: boolean; + refetch?: () => void; +} = { + data: [{ localId: 'proj-1', name: 'Test Project', remoteId: 'base32proj1' }], + isPending: false, +}; + +vi.mock('@/components/layout/shell-slot', () => ({ + useShellSlot: vi.fn(), +})); + +vi.mock('@/stores/project-store', () => ({ + useProjectStore: vi.fn( + (selector: (s: { selectedProjectId: string | null }) => string | null) => + selector({ selectedProjectId: mockSelectedProjectId }), + ), +})); + +vi.mock('@/hooks/useProjects', () => ({ + useProjects: vi.fn(() => mockProjectsQuery), +})); + +vi.mock('@/hooks/useApiPresets', () => ({ + useApiPresets: vi.fn(() => mockPresetsQuery), +})); + +vi.mock('@/hooks/useFields', () => ({ + useFields: vi.fn(() => ({ + data: [], + isPending: false, + })), +})); + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( + {children} + ), + useNavigate: () => vi.fn(), + useSearch: () => ({}), +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useSearchParams: () => [new URLSearchParams(), vi.fn()], + }; +}); + +function resetMocks() { + mockSelectedProjectId = 'proj-1'; + mockPresetsQuery = { data: defaultPresets, isPending: false }; + mockProjectsQuery = { + data: [ + { localId: 'proj-1', name: 'Test Project', remoteId: 'base32proj1' }, + ], + isPending: false, + }; + vi.clearAllMocks(); +} + +describe('CategoriesEditorScreen', () => { + beforeEach(() => { + resetMocks(); + }); + + // --- project loading / error guard states (HIGH-1 fix) --- + + it('renders projects loading skeleton while projects query is pending', () => { + mockProjectsQuery = { + data: [], + isPending: true, + }; + render(); + const skeletons = document.querySelectorAll( + '[class*="animate-pulse"], [class*="bg-muted"]', + ); + expect(skeletons.length).toBeGreaterThanOrEqual(1); + // Should NOT show the select-project prompt + expect( + screen.queryByText('Select a project to view categories'), + ).not.toBeInTheDocument(); + }); + + it('renders projects error with retry when projects query fails', () => { + const refetch = vi.fn(); + mockProjectsQuery = { + data: [], + isPending: false, + isError: true, + refetch, + }; + render(); + expect( + screen.getByText('Failed to load categories. Please try again.'), + ).toBeInTheDocument(); + screen.getByText('Retry').click(); + expect(refetch).toHaveBeenCalled(); + }); + + it('shows empty state when selected project not found in loaded list', () => { + mockSelectedProjectId = 'proj-1'; + mockProjectsQuery = { + data: [{ localId: 'proj-2', name: 'Other', remoteId: 'base32other' }], + isPending: false, + }; + render(); + expect(screen.getByText('No categories found')).toBeInTheDocument(); + }); + + // --- presets loading / error --- + + it('renders loading skeleton while presets load', () => { + mockPresetsQuery = { data: undefined, isPending: true }; + + render(); + const skeletons = document.querySelectorAll( + '[class*="animate-pulse"], [class*="bg-muted"]', + ); + expect(skeletons.length).toBeGreaterThanOrEqual(1); + }); + + it('renders error state with retry button when presets fetch fails', () => { + mockPresetsQuery = { + data: undefined, + isPending: false, + isError: true, + refetch: vi.fn(), + }; + + render(); + expect( + screen.getByText('Failed to load categories. Please try again.'), + ).toBeInTheDocument(); + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); + + it('renders empty state when no categories exist', () => { + mockPresetsQuery = { data: [], isPending: false }; + + render(); + expect(screen.getByText('No categories found')).toBeInTheDocument(); + }); + + it('renders search input', () => { + render(); + expect( + screen.getByPlaceholderText('Search categories...'), + ).toBeInTheDocument(); + }); + + // --- core remoteId flow (HIGH-2 fix) --- + + it('calls useApiPresets with project remoteId when project has one', () => { + render(); + expect(vi.mocked(useApiPresets)).toHaveBeenCalledWith('base32proj1'); + }); + + it('renders category cards when presets are loaded via remoteId', () => { + render(); + expect(screen.getByText('Deforestation')).toBeInTheDocument(); + expect(screen.getByText('Mining')).toBeInTheDocument(); + }); + + it('shows no-project prompt when no project is selected', () => { + mockSelectedProjectId = null; + render(); + expect( + screen.getByText('Select a project to view categories'), + ).toBeInTheDocument(); + }); + + it('shows empty state for local project without remoteId and passes null to useApiPresets', () => { + mockSelectedProjectId = 'proj-1'; + vi.mocked(useProjects).mockReturnValue({ + data: [{ localId: 'proj-1', name: 'Local Project' }], + isPending: false, + } as ReturnType); + render(); + expect(screen.getByText('No categories found')).toBeInTheDocument(); + // Verify the hook was called with null — no wasted API call + expect(vi.mocked(useApiPresets)).toHaveBeenCalledWith(null); + }); + + it('shows no-results message when search filters everything out', () => { + mockPresetsQuery = { data: [], isPending: false }; + render(); + expect(screen.getByText('No categories found')).toBeInTheDocument(); + }); +});