diff --git a/public/content/ogcapi.md b/public/content/ogcapi.md new file mode 100644 index 00000000..5d14294b --- /dev/null +++ b/public/content/ogcapi.md @@ -0,0 +1,70 @@ +--- +title: Connect the Ocotillo OGC API to desktop GIS +deck: Use the Ocotillo OGC API Features endpoint to browse collections in ArcGIS Desktop and in QGIS. +--- + +> [!WARNING] +> OGC API layers are read-only in desktop GIS. Use them for discovery, map display, querying, and export. + +##### Ocotillo OGC landing page URL + +```text +{{ ocotillo_api_url }}/ogcapi +``` + +## ArcGIS Pro / Desktop + +1. On the **Insert** tab, in the **Project** group, click **Connections > Server > New OGC API Server**. The **Add OGC API Server Connection** dialog box appears. +2. Enter this URL ([{{ ocotillo_api_url }}/ogcapi]({{ ocotillo_api_url }}/ogcapi)) in the **Server URL** text box. +3. Leave the rest of the options as-is and click OK. +4. In the Catalog pane, expand the “Servers” folder. You should see the Ocotillo OGC API connection. Expand the connection, then expand “Features”. Drag the datasets you want into your map area. +5. When adding a layer, a dialog box will appear with spatial extent options. Click OK to add the entire contents. You can also check the “Use Spatial Extent” box and spatially filter via options in the “Get extent from:” box - e.g. spatially filter by existing layers, selected polygon extents, visible extent, etc. + +**Official documentation:** +[How to add OGC API datasets to ArcGIS Pro](https://pro.arcgis.com/en/pro-app/latest/help/data/services/add-ogc-api-services.htm) +[https://pro.arcgis.com/en/pro-app/latest/help/data/services/use-ogc-api-services.htm](https://pro.arcgis.com/en/pro-app/latest/help/data/services/use-ogc-api-services.htm) + +--- + +## QGIS + +1. Open **Data Source Manager**. +2. Choose the WFS / OGC API - Features connection tab. +3. Create a new connection using the Ocotillo landing page URL. +4. Connect to the server, select one or more collections, and add them to the map. +5. For large layers, set paging or feature limits in the connection and layer options. + +> [!INFO] +> QGIS expects the OGC API landing page, not a single collection items URL, when you create the server connection. + +**Official documentation:** +[https://docs.qgis.org/latest/en/docs/user_manual/working_with_ogc/ogc_client_support.html](https://docs.qgis.org/latest/en/docs/user_manual/working_with_ogc/ogc_client_support.html) + +--- + +## Useful Ocotillo endpoints + +### Landing page + +[{{ ocotillo_api_url }}/ogcapi]({{ ocotillo_api_url }}/ogcapi) + +Use this as the server URL when creating the connection. + +### Collections + +[{{ ocotillo_api_url }}/ogcapi/collections]({{ ocotillo_api_url }}/ogcapi/collections) + +Review available collections before connecting from desktop GIS. + +--- + +## Common collections to look for + +- [!CHIPS] +- Water Wells +- Springs +- Latest Depth to Water +- Average TDS +- Latest TDS + +Collection names can change by deployment. If you do not see one of these, open the [collections endpoint]({{ ocotillo_api_url }}/ogcapi/collections) and use the names published there. diff --git a/src/App.tsx b/src/App.tsx index 6673c1f7..d2d4a7c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -67,6 +67,10 @@ const App: React.FC = () => ( path="/report-a-bug" element={} /> + } + /> } /> } /> } /> diff --git a/src/components/layout/sider.tsx b/src/components/layout/sider.tsx index 039669d2..47eb7295 100644 --- a/src/components/layout/sider.tsx +++ b/src/components/layout/sider.tsx @@ -416,7 +416,7 @@ export const ThemedSiderV2: React.FC = ({ > {[ { to: '/about', label: 'About' }, - { to: '/ocotillo/help', label: 'Connect Desktop GIS' }, + { to: '/ogcapi', label: 'Connect Desktop GIS' }, { to: '/report-a-bug', label: 'Report a Bug' }, ].map(({ to, label }) => ( diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx index ff296266..91c99300 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -1,7 +1,20 @@ import React, { useEffect, useState } from 'react' import ReactMarkdown from 'react-markdown' -import { Box, CircularProgress, Divider, Typography } from '@mui/material' +import { + Alert, + Box, + CircularProgress, + Divider, + Link, + Typography, + IconButton, + Tooltip, + Stack, + Chip, +} from '@mui/material' +import { ContentCopy } from '@mui/icons-material' import { Components } from 'react-markdown' +import { settings } from '@/settings' export type FrontMatter = { title?: string @@ -28,7 +41,10 @@ export function parseFrontmatter(text: string): { const colonIdx = line.indexOf(':') if (colonIdx === -1) continue const key = line.slice(0, colonIdx).trim() - const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '') + const value = line + .slice(colonIdx + 1) + .trim() + .replace(/^["']|["']$/g, '') if (key === 'title' || key === 'deck' || key === 'date') { data[key] = value } @@ -54,22 +70,131 @@ export const markdownComponents: Components = { ), a: ({ href, children }) => ( - + {children} - - ), - ul: ({ children }) => ( - - {children} - + ), + blockquote: ({ children }) => { + const text = React.Children.toArray(children) + .map((child) => { + if (React.isValidElement(child)) { + return React.Children.toArray(child.props.children).join('') + } + + return String(child) + }) + .join('') + .trim() + + const alertMatch = text.match( + /^\[!(WARNING|INFO|ERROR|SUCCESS)\]\s*([\s\S]*)$/i + ) + + if (alertMatch) { + const severityMap = { + WARNING: 'warning', + INFO: 'info', + ERROR: 'error', + SUCCESS: 'success', + } as const + + const alertType = alertMatch[1].toUpperCase() as keyof typeof severityMap + const alertBody = alertMatch[2].trim() + + return ( + + {alertBody} + + ) + } + + return ( + + {children} + + ) + }, + code: ({ children, className }) => { + const value = String(children).replace(/\n$/, '') + + if (className) { + return + } + + return ( + + {children} + + ) + }, + ul: ({ children, node }) => { + const getListItemText = (listItem: any): string => { + return ( + listItem?.children + ?.map((child: any) => child.value ?? '') + ?.join('') + ?.trim() ?? '' + ) + } + + const listItems = + node?.children?.filter( + (child: any) => child.type === 'element' && child.tagName === 'li' + ) ?? [] + const firstItemText = getListItemText(listItems[0]) + + if (firstItemText === '[!CHIPS]') { + return ( + + {listItems.slice(1).map((item: any, index: number) => { + const label = getListItemText(item) + + return ( + + ) + })} + + ) + } + + return ( + + {children} + + ) + }, ol: ({ children }) => ( {children} ), li: ({ children }) => ( - + {children} ), @@ -128,7 +253,10 @@ export const MarkdownPage: React.FC = ({ )} {frontmatter.date && ( - + {new Date(frontmatter.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', @@ -159,7 +287,28 @@ export const ContentPage: React.FC = ({ src }) => { return res.text() }) .then((text) => { - const parsed = parseFrontmatter(text) + // Replace template placeholders like {{ key }} in the markdown text + // with corresponding values from the `settings` object. + // + // Example: + // "https://{{ ocotillo_api_url }}/ogcapi" + // → "https://actual-value/ogcapi" + // + const hydratedText = text.replace( + /{{\s*([\w]+)\s*}}/g, + (_, key: string) => { + const value = (settings as Record)[key] + + if (typeof value === 'string') { + return value.replace(/\/+$/, '') + } + + // if key not found or not string → reinsert key name + return `{{ ${key} }}` + } + ) + + const parsed = parseFrontmatter(hydratedText) setFrontmatter(parsed.data) setBody(parsed.content) }) @@ -194,3 +343,45 @@ export const ContentPage: React.FC = ({ src }) => { return } + +const CopyCodeBlock = ({ value }: { value: string }) => { + const handleCopy = async () => { + await navigator.clipboard.writeText(value) + } + + return ( + + + {value} + + + + + + + + + ) +} diff --git a/src/pages/ocotillo/help/index.tsx b/src/pages/ocotillo/help/index.tsx deleted file mode 100644 index c1edf1e2..00000000 --- a/src/pages/ocotillo/help/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './list' diff --git a/src/pages/ocotillo/help/list.tsx b/src/pages/ocotillo/help/list.tsx deleted file mode 100644 index 32b62adb..00000000 --- a/src/pages/ocotillo/help/list.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { - Alert, - Box, - Card, - CardContent, - Chip, - Container, - Divider, - IconButton, - Link, - Stack, - Tooltip, - Typography, -} from '@mui/material' -import { ContentCopy } from '@mui/icons-material' -import Grid from '@mui/material/Grid2' -import { settings } from '@/settings' - -const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '') - -const baseApiUrl = trimTrailingSlash(settings.ocotillo_api_url) -const ogcLandingPageUrl = `${baseApiUrl}/ogcapi` -const ogcCollectionsUrl = `${ogcLandingPageUrl}/collections` - -const commonCollections = [ - 'Water Wells', - 'Springs', - 'Latest Depth to Water', - 'Average TDS', - 'Latest TDS', -] - -const docs = { - arcgis: - 'https://pro.arcgis.com/en/pro-app/latest/help/data/services/use-ogc-api-services.htm', - qgis: 'https://docs.qgis.org/latest/en/docs/user_manual/working_with_ogc/ogc_client_support.html', -} - -export const HelpPage = () => { - const handleCopy = async (value: string) => { - try { - await navigator.clipboard.writeText(value) - } catch (error) { - console.error('Failed to copy OGC URL', error) - } - } - - return ( - - - - - Connect the Ocotillo OGC API to desktop GIS - - - Use the Ocotillo OGC API Features endpoint to browse collections in - ArcGIS Desktop and in QGIS. - - - - - OGC API layers are read-only in desktop GIS. Use them for discovery, - map display, querying, and export. - - - - - Ocotillo OGC landing page URL - - - - - - - - - - - - - - - - - Useful Ocotillo endpoints - - - - - - - - - - - Common collections to look for - - - {commonCollections.map((collection) => ( - - ))} - - - Collection names can change by deployment. If you do not see one - of these, open the{' '} - - collections endpoint - {' '} - and use the names published there. - - - - - - - ) -} - -const InstructionCard = ({ - title, - steps, - note, - href, -}: { - title: string - steps: string[] - note?: string - href: string -}) => ( - - - - {title} - - {steps.map((step) => ( - - {step} - - ))} - - - {note ? ( - - {note} - - ) : null} - - Official documentation - - {href} - - - - - -) - -const EndpointRow = ({ - label, - href, - description, -}: { - label: string - href: string - description: string -}) => ( - - {label} - - {href} - - - {description} - - -) - -const CopyUrlBox = ({ - value, - onCopy, -}: { - value: string - onCopy: (value: string) => void -}) => ( - - - {value} - - - onCopy(value)} - sx={{ - position: 'absolute', - top: 6, - right: 6, - }} - aria-label="Copy OGC landing page URL" - > - - - - -) diff --git a/src/providers/authentik-provider.ts b/src/providers/authentik-provider.ts index 15a93b13..3a81e412 100644 --- a/src/providers/authentik-provider.ts +++ b/src/providers/authentik-provider.ts @@ -10,10 +10,9 @@ import { generateCodeChallenge, generateCodeVerifier, generateOAuthState, - getStatusCode, - hasError, - isJwtExpired, -} from '@/utils' +} from '@/utils/Auth' +import { getStatusCode, hasError } from '@/utils/Http' +import { isJwtExpired } from '@/utils/Jwt' import { HttpStatus } from '@/enums' import { AUTHENTIK_URL, @@ -22,7 +21,7 @@ import { STORAGE_KEYS, IS_TESTING_AUTH, } from '@/config' -import { normalizeAccessControlGroups } from '@/utils' +import { normalizeAccessControlGroups } from '@/utils/accessControl' const gravatarUrl = (email: string) => { let hash = email.trim().toLowerCase() diff --git a/src/routes/ocotillo.tsx b/src/routes/ocotillo.tsx index f21cd232..378309df 100644 --- a/src/routes/ocotillo.tsx +++ b/src/routes/ocotillo.tsx @@ -18,7 +18,6 @@ import { SpringShow, } from '@/pages/ocotillo/thing' import { MapView } from '@/pages/ocotillo/map' -import { HelpPage } from '@/pages/ocotillo/help' import { CollectionsPage } from '@/pages/ocotillo/collections' import { LocationList, @@ -132,7 +131,6 @@ export const OcotilloRoutes = () => { } /> } /> - } /> } /> } /> diff --git a/src/test/providers/authentik-provider.access-control.test.ts b/src/test/providers/authentik-provider.access-control.test.ts index d406b1e4..6c5eab8a 100644 --- a/src/test/providers/authentik-provider.access-control.test.ts +++ b/src/test/providers/authentik-provider.access-control.test.ts @@ -50,9 +50,8 @@ describe('authentik provider access-control normalization', () => { }) it('normalizes token groups from the ID token when testing auth is disabled', async () => { - vi.doMock('@/config', async () => { - const actual = - await vi.importActual('@/config') + vi.doMock('@/config', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, IS_TESTING_AUTH: false } }) vi.doMock('jwt-decode', () => ({ @@ -79,9 +78,8 @@ describe('authentik provider access-control normalization', () => { }) it('returns null when there is no ID token', async () => { - vi.doMock('@/config', async () => { - const actual = - await vi.importActual('@/config') + vi.doMock('@/config', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, IS_TESTING_AUTH: false } }) vi.doMock('jwt-decode', () => ({