From a50dfe15a75a9c04ad5bf9925bcf70be77825222 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 27 Aug 2026 13:24:40 -0700 Subject: [PATCH] feat(collections): show a dataset's JSON schema in a modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each row in the datasets catalog gets a Schema button that opens the collection's published JSON Schema (`/ogcapi/collections/{id}/schema?f=json`) as a readable field table: name, title, OGC role, type, description, and enum values. A Raw JSON toggle and a copy button cover the case where the document itself is what the reader wants. The field table is the default view because the server fills in per-property titles and descriptions that a raw JSON dump buries, and the schema is read far more often to answer "what columns does this dataset have" than to be copied verbatim. Two shape details the renderer has to handle: a geometry property arrives with a `format` and no `type`, so the type label falls back to format; and controlled vocabularies run to two dozen entries, so enum chips are capped with the remainder behind a tooltip. Below the tablet breakpoint the three-column table is replaced by a stacked per-field layout, which a phone can actually read. The schema types are hand-written zod, like `gisArtifacts.ts`: the committed `openapi-auth.json` snapshot describes none of the `/ogcapi` paths, so `src/generated` cannot describe this response yet. Also hides the desktop-GIS connections panel, table column, and per-card download links behind a single `SHOW_DESKTOP_GIS` constant. The catalog data still loads — the collection index keys off it — so flipping the constant back to `true` restores all three together. Co-Authored-By: Claude Opus 5 --- src/components/CollectionSchemaDialog.tsx | 433 ++++++++++++++++++ src/components/index.ts | 1 + src/hooks/index.ts | 1 + src/hooks/useCollectionSchema.ts | 32 ++ src/pages/ocotillo/collections/list.tsx | 162 +++++-- .../CollectionSchemaDialog.test.tsx | 127 +++++ src/test/utils/collectionSchema.test.ts | 89 ++++ src/utils/collectionSchema.ts | 121 +++++ 8 files changed, 922 insertions(+), 44 deletions(-) create mode 100644 src/components/CollectionSchemaDialog.tsx create mode 100644 src/hooks/useCollectionSchema.ts create mode 100644 src/test/components/CollectionSchemaDialog.test.tsx create mode 100644 src/test/utils/collectionSchema.test.ts create mode 100644 src/utils/collectionSchema.ts 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`