diff --git a/src/components/CollectionSchemaDialog.tsx b/src/components/CollectionSchemaDialog.tsx new file mode 100644 index 00000000..5a76ed13 --- /dev/null +++ b/src/components/CollectionSchemaDialog.tsx @@ -0,0 +1,433 @@ +import { Close, ContentCopy, DataObject } from '@mui/icons-material' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogContent, + DialogTitle, + Divider, + IconButton, + Link, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, + useMediaQuery, +} from '@mui/material' +import { alpha, useTheme } from '@mui/material/styles' +import { useState } from 'react' +import { SCREENS } from '@/constants/breakpoints' +import { useCollectionSchema } from '@/hooks' +import { settings } from '@/settings' +import { + buildSchemaFieldRows, + collectionSchemaUrl, + roleLabelOf, + type SchemaFieldRow, +} from '@/utils/collectionSchema' + +type SchemaView = 'fields' | 'raw' + +export type CollectionSchemaDialogProps = { + open: boolean + onClose: () => void + collectionId?: string + /** Catalogue title, used until the schema document supplies its own. */ + title: string +} + +/** + * Shows a collection's published JSON Schema as a readable field table, with + * the raw document one toggle away. + * + * The table is the default view on purpose: the schema is read far more often + * to answer "what columns does this dataset have and what do they mean" than + * to be copied verbatim, and the server fills in per-property titles and + * descriptions that a raw JSON dump buries. + */ +export const CollectionSchemaDialog = ({ + open, + onClose, + collectionId, + title, +}: CollectionSchemaDialogProps) => { + const [view, setView] = useState('fields') + const [copied, setCopied] = useState(false) + const { + data: schema, + isLoading, + isError, + error, + } = useCollectionSchema(collectionId, { enabled: open }) + + const rows = schema ? buildSchemaFieldRows(schema) : [] + const schemaUrl = collectionId + ? collectionSchemaUrl(settings.ocotillo_api_url, collectionId) + : undefined + const rawJson = schema ? JSON.stringify(schema, null, 2) : '' + + const handleCopy = async () => { + if (!rawJson) return + await navigator.clipboard.writeText(rawJson) + setCopied(true) + window.setTimeout(() => setCopied(false), 2000) + } + + return ( + + + + + + + {schema?.title || title} + + {collectionId ? ( + + {collectionId} + + ) : null} + + + + + + + + + {isLoading ? ( + + + + Loading schema... + + + ) : isError ? ( + + Failed to load the schema for this dataset. + {error instanceof Error ? ` ${error.message}` : null} + + ) : ( + + {schema?.description ? ( + + {schema.description} + + ) : null} + + + { + if (next) setView(next) + }} + aria-label="Schema layout" + > + + Fields + + + Raw JSON + + + + + + + + + {view === 'fields' ? ( + + ) : ( + ({ + m: 0, + p: 2, + borderRadius: 2, + maxHeight: '60vh', + overflow: 'auto', + fontSize: 12.5, + lineHeight: 1.6, + border: `1px solid ${theme.palette.divider}`, + bgcolor: alpha(theme.palette.text.primary, 0.04), + })} + > + {rawJson} + + )} + + {schemaUrl ? ( + + Source:{' '} + + {schemaUrl} + + + ) : null} + + )} + + + ) +} + +const SchemaFieldTable = ({ rows }: { rows: SchemaFieldRow[] }) => { + const theme = useTheme() + // Three columns cannot fit a phone: the description column is what carries + // the meaning, and in a table it either wraps to a sliver or pushes the row + // off-screen. Below the tablet breakpoint each field becomes its own block. + const isNarrow = useMediaQuery(theme.breakpoints.down('md')) + + if (rows.length === 0) { + return ( + + This dataset does not publish any schema properties. + + ) + } + + if (isNarrow) { + return + } + + return ( + + + + + Field + Type + Description + + + + {rows.map((row) => { + const roleLabel = roleLabelOf(row.role) + + return ( + + + + + {row.name} + + {row.title && row.title !== row.name ? ( + + {row.title} + + ) : null} + + {roleLabel ? ( + + ) : null} + {row.required ? ( + + ) : null} + + + + + + {row.typeLabel} + + + + + {row.description ? ( + + {row.description} + + ) : ( + + — + + )} + {row.enumValues?.length ? ( + + ) : null} + + + + ) + })} + +
+
+ ) +} + +const SchemaFieldList = ({ rows }: { rows: SchemaFieldRow[] }) => ( + + {rows.map((row) => { + const roleLabel = roleLabelOf(row.role) + + return ( + ({ + p: 1.5, + borderRadius: 2, + border: `1px solid ${theme.palette.divider}`, + })} + > + + + + {row.name} + + + {row.typeLabel} + + + {row.title && row.title !== row.name ? ( + + {row.title} + + ) : null} + + {roleLabel ? ( + + ) : null} + {row.required ? ( + + ) : null} + + {row.description ? ( + + {row.description} + + ) : null} + {row.enumValues?.length ? ( + + ) : null} + + + ) + })} + +) + +// Some controlled vocabularies run to two dozen entries, which would swamp the +// row; show a handful and count the rest behind a tooltip. +const ENUM_PREVIEW_COUNT = 6 + +const EnumValues = ({ values }: { values: string[] }) => { + const preview = values.slice(0, ENUM_PREVIEW_COUNT) + const remainder = values.slice(ENUM_PREVIEW_COUNT) + + return ( + + {preview.map((value) => ( + + ))} + {remainder.length ? ( + + + + ) : null} + + ) +} diff --git a/src/components/index.ts b/src/components/index.ts index 3da9e3f6..f2af603f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export * from './Button' export * from './ContactShow' export * from './WellShow' export * from './ClearableSelect' +export * from './CollectionSchemaDialog' export * from './ChipWithExplain' export * from './ConfirmDialog' export * from './Controlled' diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 2cc94a83..0cd9f6ab 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,6 +6,7 @@ export * from './useAll' export * from './useAllNotes' export * from './useDebounce' export * from './useElevation' +export * from './useCollectionSchema' export * from './useGisArtifacts' export * from './useLayer' export * from './useLexicon' diff --git a/src/hooks/useCollectionSchema.ts b/src/hooks/useCollectionSchema.ts new file mode 100644 index 00000000..7f5bba26 --- /dev/null +++ b/src/hooks/useCollectionSchema.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query' +import { fetcher } from '@/providers/ocotillo-data-provider' +import { + type CollectionSchema, + zCollectionSchema, +} from '@/utils/collectionSchema' + +/** + * Fetches the JSON Schema an OGC collection publishes for its features. + * + * `?f=json` is passed explicitly: the schema path is content-negotiated and + * serves HTML by default, so relying on the Accept header risks parsing an + * HTML page as JSON. + * + * Schemas change only when the collection's shape changes, so this caches for + * the session rather than refetching each time the modal opens. + */ +export const useCollectionSchema = ( + collectionId: string | undefined, + options?: { enabled?: boolean } +) => + useQuery({ + queryKey: ['ogcapi-collection-schema', collectionId], + enabled: (options?.enabled ?? true) && Boolean(collectionId), + staleTime: Number.POSITIVE_INFINITY, + queryFn: async () => { + const response = await fetcher( + `ogcapi/collections/${encodeURIComponent(collectionId as string)}/schema?f=json` + ) + return zCollectionSchema.parse(response.data) + }, + }) diff --git a/src/pages/ocotillo/collections/list.tsx b/src/pages/ocotillo/collections/list.tsx index 263e7a72..0be38b0a 100644 --- a/src/pages/ocotillo/collections/list.tsx +++ b/src/pages/ocotillo/collections/list.tsx @@ -1,5 +1,6 @@ import { ArrowOutward, + DataObject, ElectricBolt, Opacity, OpenInNew, @@ -37,6 +38,7 @@ import { ErrorComponent } from '@refinedev/mui' import { useQuery } from '@tanstack/react-query' import { Fragment, useState } from 'react' import { Link as RouterLink } from 'react-router' +import { CollectionSchemaDialog } from '@/components/CollectionSchemaDialog' import { GisConnectionsPanel, GisLayerDownloads, @@ -59,6 +61,17 @@ import { type CollectionsView = 'cards' | 'table' +// Desktop-GIS connection and layer downloads are hidden on this page for now. +// The catalogue data still loads (the collection index keys off it), so +// flipping this back to `true` restores the panel, the table column, and the +// per-card download links together. +const SHOW_DESKTOP_GIS = false + +type SchemaDialogTarget = { + collectionId?: string + title: string +} + type CollectionGroupKey = | 'groundwater' | 'surfaceWater' @@ -401,6 +414,18 @@ export const CollectionsPage = () => { const dataProvider = useDataProvider() const { canViewAmp } = useAccessCapabilities() const [view, setView] = useState('table') + // The target outlives `isSchemaOpen` on purpose: MUI keeps the dialog mounted + // through its closing transition, and clearing the target on close would + // flash an empty schema shell on the way out. + const [schemaTarget, setSchemaTarget] = useState( + null + ) + const [isSchemaOpen, setIsSchemaOpen] = useState(false) + + const openSchema = (target: SchemaDialogTarget) => { + setSchemaTarget(target) + setIsSchemaOpen(true) + } const { data: gisCatalog } = useGisArtifacts({ enabled: access?.can === true, }) @@ -563,7 +588,7 @@ export const CollectionsPage = () => { - {gisCatalog ? ( + {SHOW_DESKTOP_GIS && gisCatalog ? ( { {view === 'table' ? ( ) : ( @@ -637,6 +663,7 @@ export const CollectionsPage = () => { gisLayer={gisLayersByCollection.get( collectionIdOf(collection) ?? '' )} + onOpenSchema={openSchema} index={index} /> ) @@ -653,14 +680,23 @@ export const CollectionsPage = () => { )} + + setIsSchemaOpen(false)} + collectionId={schemaTarget?.collectionId} + title={schemaTarget?.title ?? ''} + /> ) } const CollectionsTable = ({ rows, + onOpenSchema, }: { rows: CollectionsTableRow[] + onOpenSchema: (target: SchemaDialogTarget) => void }) => ( @@ -668,8 +704,8 @@ const CollectionsTable = ({ Dataset Description - Desktop GIS - Map + {SHOW_DESKTOP_GIS ? Desktop GIS : null} + Actions @@ -684,7 +720,7 @@ const CollectionsTable = ({ {startsGroup ? ( )} - - {row.gisLayer ? ( - - ) : ( - - — - - )} - + {SHOW_DESKTOP_GIS ? ( + + {row.gisLayer ? ( + + ) : ( + + — + + )} + + ) : null} - + + + @@ -813,12 +874,14 @@ const CollectionRow = ({ groupKey, displayLabel, gisLayer, + onOpenSchema, }: { collection: OgcCollectionRecord layerKey: string groupKey: CollectionGroupKey displayLabel?: string gisLayer?: GisLayer + onOpenSchema: (target: SchemaDialogTarget) => void index: number }) => { const style = GROUP_STYLES[groupKey] @@ -882,24 +945,35 @@ const CollectionRow = ({ ) : null} - + + + + {description ? ( @@ -910,7 +984,7 @@ const CollectionRow = ({ No published description. )} - {gisLayer ? ( + {SHOW_DESKTOP_GIS && gisLayer ? ( Open in desktop GIS diff --git a/src/test/components/CollectionSchemaDialog.test.tsx b/src/test/components/CollectionSchemaDialog.test.tsx new file mode 100644 index 00000000..8ac06881 --- /dev/null +++ b/src/test/components/CollectionSchemaDialog.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CollectionSchemaDialog } from '@/components/CollectionSchemaDialog' +import { zCollectionSchema } from '@/utils/collectionSchema' + +const { useCollectionSchemaMock } = vi.hoisted(() => ({ + useCollectionSchemaMock: vi.fn(), +})) + +vi.mock('@/hooks', () => ({ + useCollectionSchema: (...args: unknown[]) => useCollectionSchemaMock(...args), +})) + +const schema = zCollectionSchema.parse({ + type: 'object', + title: 'Latest TDS (Water Wells)', + properties: { + geometry: { format: 'geometry-any', 'x-ogc-role': 'primary-geometry' }, + latest_tds_value: { + type: 'number', + title: 'Total dissolved solids', + description: 'Most recent measured concentration.', + }, + thing_type: { + type: 'string', + enum: ['water well', 'spring', 'piezometer'], + }, + }, +}) + +const loaded = { + data: schema, + isLoading: false, + isError: false, + error: null, +} + +beforeEach(() => { + useCollectionSchemaMock.mockReset() + useCollectionSchemaMock.mockReturnValue(loaded) +}) + +const renderDialog = (overrides?: { open?: boolean }) => + render( + {}} + collectionId="latest_tds_wells" + title="Latest TDS" + /> + ) + +describe('CollectionSchemaDialog', () => { + it('renders the schema as a field table with titles and descriptions', () => { + renderDialog() + + expect(screen.getByText('Latest TDS (Water Wells)')).toBeInTheDocument() + expect(screen.getByText('latest_tds_value')).toBeInTheDocument() + expect(screen.getByText('Total dissolved solids')).toBeInTheDocument() + expect( + screen.getByText('Most recent measured concentration.') + ).toBeInTheDocument() + expect(screen.getByText('3 fields')).toBeInTheDocument() + }) + + it('labels the geometry property from its format alone', () => { + renderDialog() + + expect(screen.getByText('Geometry')).toBeInTheDocument() + // Both the property name and its derived type label read `geometry`. + expect(screen.getAllByText('geometry')).toHaveLength(2) + }) + + it('lists enum values as chips', () => { + renderDialog() + + expect(screen.getByText('water well')).toBeInTheDocument() + expect(screen.getByText('spring')).toBeInTheDocument() + }) + + it('switches to the raw JSON view', async () => { + const user = userEvent.setup() + renderDialog() + + await user.click(screen.getByRole('button', { name: 'Raw JSON view' })) + + expect( + screen.getByText(/"x-ogc-role": "primary-geometry"/) + ).toBeInTheDocument() + }) + + it('shows a spinner while loading', () => { + useCollectionSchemaMock.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: null, + }) + renderDialog() + + expect(screen.getByText('Loading schema...')).toBeInTheDocument() + }) + + it('surfaces a fetch failure', () => { + useCollectionSchemaMock.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('404 Not Found'), + }) + renderDialog() + + expect( + screen.getByText(/Failed to load the schema for this dataset/) + ).toBeInTheDocument() + }) + + it('skips fetching while closed', () => { + renderDialog({ open: false }) + + expect(useCollectionSchemaMock).toHaveBeenCalledWith('latest_tds_wells', { + enabled: false, + }) + }) +}) diff --git a/src/test/utils/collectionSchema.test.ts b/src/test/utils/collectionSchema.test.ts new file mode 100644 index 00000000..e4c5c06c --- /dev/null +++ b/src/test/utils/collectionSchema.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + buildSchemaFieldRows, + collectionSchemaUrl, + roleLabelOf, + typeLabelOf, + zCollectionSchema, +} from '@/utils/collectionSchema' + +const schema = zCollectionSchema.parse({ + type: 'object', + title: 'Latest TDS (Water Wells)', + $schema: 'http://json-schema.org/draft/2019-09/schema', + $id: 'https://api.example.org/ogcapi/collections/latest_tds_wells/schema', + required: ['name'], + properties: { + geometry: { format: 'geometry-any', 'x-ogc-role': 'primary-geometry' }, + name: { type: 'string', title: 'Name', description: 'Well name.' }, + id: { type: 'integer', title: 'Feature ID', 'x-ogc-role': 'id' }, + latest_tds_observation_date: { type: 'string', format: 'date' }, + thing_type: { type: 'string', enum: ['water well', 'spring'] }, + }, +}) + +describe('buildSchemaFieldRows', () => { + it('hoists the id and geometry roles, then keeps server key order', () => { + expect(buildSchemaFieldRows(schema).map((row) => row.name)).toEqual([ + 'id', + 'geometry', + 'name', + 'latest_tds_observation_date', + 'thing_type', + ]) + }) + + it('carries titles, descriptions, enums, and required flags', () => { + const rows = buildSchemaFieldRows(schema) + const name = rows.find((row) => row.name === 'name') + const thingType = rows.find((row) => row.name === 'thing_type') + + expect(name).toMatchObject({ + title: 'Name', + description: 'Well name.', + required: true, + }) + expect(thingType?.required).toBe(false) + expect(thingType?.enumValues).toEqual(['water well', 'spring']) + }) + + it('tolerates a schema with no properties', () => { + expect(buildSchemaFieldRows(zCollectionSchema.parse({}))).toEqual([]) + }) +}) + +describe('typeLabelOf', () => { + it('labels a geometry property that carries only a format', () => { + expect(typeLabelOf({ format: 'geometry-any' })).toBe('geometry') + }) + + it('combines type and format', () => { + expect(typeLabelOf({ type: 'string', format: 'date' })).toBe( + 'string (date)' + ) + }) + + it('drops null from a union type', () => { + expect(typeLabelOf({ type: ['number', 'null'] })).toBe('number') + }) + + it('falls back when the property declares nothing', () => { + expect(typeLabelOf({})).toBe('unknown') + }) +}) + +describe('roleLabelOf', () => { + it('maps known OGC roles and passes unknown ones through', () => { + expect(roleLabelOf('primary-geometry')).toBe('Geometry') + expect(roleLabelOf('something-else')).toBe('something-else') + expect(roleLabelOf(undefined)).toBeUndefined() + }) +}) + +describe('collectionSchemaUrl', () => { + it('builds the schema URL without doubling the slash', () => { + expect(collectionSchemaUrl('https://api.example.org/', 'water_wells')).toBe( + 'https://api.example.org/ogcapi/collections/water_wells/schema?f=json' + ) + }) +}) diff --git a/src/utils/collectionSchema.ts b/src/utils/collectionSchema.ts new file mode 100644 index 00000000..d829ebf6 --- /dev/null +++ b/src/utils/collectionSchema.ts @@ -0,0 +1,121 @@ +import { z } from 'zod' + +/** + * Schemas for the OGC API collection schema document + * (`GET /ogcapi/collections/{id}/schema?f=json`). + * + * Hand-written, like `gisArtifacts.ts`: the committed `openapi-auth.json` + * snapshot describes none of the `/ogcapi` paths, so `src/generated` cannot + * describe this response. Replace with generated schemas once the OGC surface + * is in the deployed spec. + * + * The document is JSON Schema draft 2019-09 with the OGC `x-ogc-role` + * extension marking the id and geometry properties. Everything below `type` + * is optional in practice: the server fills `title`/`description` for curated + * collections and leaves them off for the rest, and the geometry property + * carries `format` with no `type` at all. + */ + +export const zSchemaProperty = z.looseObject({ + type: z.union([z.string(), z.array(z.string())]).optional(), + format: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + enum: z.array(z.unknown()).optional(), + 'x-ogc-role': z.string().optional(), + readOnly: z.boolean().optional(), + nullable: z.boolean().optional(), +}) + +export const zCollectionSchema = z.looseObject({ + $schema: z.string().optional(), + $id: z.string().optional(), + type: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + required: z.array(z.string()).optional(), + properties: z.record(z.string(), zSchemaProperty).default({}), +}) + +export type SchemaProperty = z.infer +export type CollectionSchema = z.infer + +export type SchemaFieldRow = { + name: string + title?: string + description?: string + /** Rendered type label, e.g. `string`, `number (date)`, `geometry`. */ + typeLabel: string + enumValues?: string[] + required: boolean + /** `id` or `primary-geometry` when the server tagged the property. */ + role?: string +} + +const OGC_ROLE_LABELS: Record = { + id: 'Feature ID', + 'primary-geometry': 'Geometry', + 'primary-instant': 'Time', + 'primary-interval-start': 'Start time', + 'primary-interval-end': 'End time', +} + +export const roleLabelOf = (role?: string): string | undefined => + role ? (OGC_ROLE_LABELS[role] ?? role) : undefined + +/** + * Builds the human-readable type cell. A geometry property arrives with a + * `format` such as `geometry-any` and no `type`, so format alone has to carry + * the label; a dated string arrives as both and reads best combined. + */ +export const typeLabelOf = (property: SchemaProperty): string => { + const type = Array.isArray(property.type) + ? property.type.filter((entry) => entry !== 'null').join(' | ') + : property.type + const format = property.format + + if (!type && format) { + return format.startsWith('geometry') ? 'geometry' : format + } + if (type && format) return `${type} (${format})` + return type || 'unknown' +} + +/** + * Flattens the schema's property bag into table rows, keeping the server's + * key order. The id and geometry properties are hoisted to the top: they are + * what a reader looks for first when wiring a client against a collection. + */ +export const buildSchemaFieldRows = ( + schema: CollectionSchema +): SchemaFieldRow[] => { + const required = new Set(schema.required ?? []) + + const rows = Object.entries(schema.properties ?? {}).map( + ([name, property]) => ({ + name, + title: property.title, + description: property.description, + typeLabel: typeLabelOf(property), + enumValues: property.enum?.map((value) => String(value)), + required: required.has(name), + role: property['x-ogc-role'], + }) + ) + + const rank = (row: SchemaFieldRow) => { + if (row.role === 'id') return 0 + if (row.role === 'primary-geometry') return 1 + return 2 + } + + return rows + .map((row, index) => ({ row, index })) + .sort((a, b) => rank(a.row) - rank(b.row) || a.index - b.index) + .map(({ row }) => row) +} + +export const collectionSchemaUrl = (baseApiUrl: string, collectionId: string) => + `${baseApiUrl.replace(/\/+$/, '')}/ogcapi/collections/${encodeURIComponent( + collectionId + )}/schema?f=json`