Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.32.0] - 2026-05-31

### Added
- **Search results now show a dataset's spatial extent on a map.** When a result card is expanded, the `spatial` GeoJSON geometry is drawn on an OpenStreetMap basemap (via Leaflet) and the view is fitted to its bounding box, so the geographic area a dataset covers is visible at a glance instead of being shown as a raw coordinate string.

### Changed
- **The expanded metadata section of a search result is easier to read.** CKAN `extras` fields now use human-friendly labels (e.g. `data_vintage` → "Data vintage"), harvest provenance (`harvest_*`) is grouped into its own subsection, and long opaque values such as UUIDs are shown in a monospace font. All fields previously shown are still surfaced; when a `spatial` value is not parseable geometry it falls back to plain text.

### Backwards compatibility
- UI-only change. No API behavior, request/response shapes, or routes change.

## [0.31.0] - 2026-05-26

### Changed
Expand Down
2 changes: 1 addition & 1 deletion api/config/swagger_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Settings(BaseSettings):

swagger_title: str = "API Documentation"
swagger_description: str = "This is the API documentation."
swagger_version: str = "0.31.0"
swagger_version: str = "0.32.0"
root_path: str = "" # API root path prefix (e.g., "/test" or "")
is_public: bool = True
metrics_endpoint: str = "https://federation.ndp.utah.edu/metrics/"
Expand Down
41 changes: 37 additions & 4 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.10.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.517.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.6.2",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
Expand Down
154 changes: 154 additions & 0 deletions ui/src/components/DatasetMetadata.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import React from 'react';
import { MapPin } from 'lucide-react';
import SpatialMap from './SpatialMap';
import { parseGeometry } from './spatialGeometry';

// Renders a dataset's CKAN `extras` in a readable way: the spatial extent is
// drawn on a map, the remaining fields get human-friendly labels, and harvest
// provenance is split into its own muted subsection. All values present in
// `extras` are still surfaced — nothing is hidden.

// Friendly labels for keys we know about. Anything not listed falls back to a
// humanized version of the raw key.
const LABELS = {
EPSG: 'EPSG',
collection: 'Collection',
data_vintage: 'Data vintage',
encoding: 'Encoding',
resolution: 'Resolution',
service_type: 'Service type',
harvest_object_id: 'Harvest object ID',
harvest_source_id: 'Harvest source ID',
harvest_source_title: 'Harvest source',
ndp_user_id: 'Owner ID',
status: 'Status'
};

const humanize = (key) =>
key
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
.trim();

const labelFor = (key) => LABELS[key] || humanize(key);

const formatValue = (value) =>
typeof value === 'object' && value !== null
? JSON.stringify(value)
: String(value);

// Long opaque values (UUIDs, hashes) read better in a monospace font that can
// wrap, so the rest of the grid stays aligned.
const isOpaque = (value) =>
typeof value === 'string' && /^[0-9a-f-]{16,}$/i.test(value);

const Field = ({ label, value }) => (
<div style={{ fontSize: '0.85rem' }}>
<div style={{ color: '#64748b', fontWeight: 500, marginBottom: '0.1rem' }}>{label}</div>
<div
style={{
color: '#0f172a',
wordBreak: 'break-word',
fontFamily: isOpaque(value) ? 'monospace' : 'inherit',
fontSize: isOpaque(value) ? '0.78rem' : '0.85rem'
}}
>
{formatValue(value)}
</div>
</div>
);

const FieldGrid = ({ entries }) => (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: '0.65rem'
}}
>
{entries.map(([key, value]) => (
<Field key={key} label={labelFor(key)} value={value} />
))}
</div>
);

const DatasetMetadata = ({ extras }) => {
const entries = Object.entries(extras || {});
if (!entries.length) return null;

const spatialEntry = entries.find(([key]) => key === 'spatial');
const spatial = spatialEntry ? spatialEntry[1] : null;
const canMapSpatial = spatial != null && parseGeometry(spatial) != null;

// Fields shown in the main grid: everything except spatial (handled by the
// map) and harvest provenance (its own subsection). If the spatial value
// cannot be drawn, keep it here as plain text so no information is lost.
const general = entries.filter(([key]) => {
if (key.startsWith('harvest_')) return false;
if (key === 'spatial') return !canMapSpatial;
return true;
});
const harvest = entries.filter(([key]) => key.startsWith('harvest_'));

return (
<div
style={{
marginTop: '0.75rem',
padding: '0.9rem',
background: '#f8fafc',
borderRadius: '8px',
border: '1px solid #e2e8f0',
display: 'flex',
flexDirection: 'column',
gap: '0.9rem'
}}
>
{canMapSpatial && (
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.35rem',
color: '#64748b',
fontWeight: 500,
fontSize: '0.85rem',
marginBottom: '0.4rem'
}}
>
<MapPin size={14} />
Spatial extent
</div>
<SpatialMap spatial={spatial} />
</div>
)}

{general.length > 0 && <FieldGrid entries={general} />}

{harvest.length > 0 && (
<div
style={{
borderTop: '1px solid #e2e8f0',
paddingTop: '0.75rem'
}}
>
<div
style={{
color: '#94a3b8',
fontWeight: 600,
fontSize: '0.72rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
marginBottom: '0.5rem'
}}
>
Harvest provenance
</div>
<FieldGrid entries={harvest} />
</div>
)}
</div>
);
};

export default DatasetMetadata;
85 changes: 85 additions & 0 deletions ui/src/components/DatasetMetadata.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import DatasetMetadata from './DatasetMetadata';
import { parseGeometry } from './spatialGeometry';

// SpatialMap pulls in Leaflet (ESM, DOM/canvas), which CRA's jest does not
// transform, so the renderer is mocked. The pure geometry logic lives in
// ./spatialGeometry and is imported directly. These tests cover the
// presentation logic: labels, grouping, and the spatial-extent fallback.
jest.mock('./SpatialMap', () => ({
__esModule: true,
default: () => <div data-testid="spatial-map">map</div>
}));

const POLYGON = JSON.stringify({
type: 'Polygon',
coordinates: [
[
[-124.6, 32.3],
[-113.7, 32.3],
[-113.7, 42.3],
[-124.6, 42.3],
[-124.6, 32.3]
]
]
});

describe('DatasetMetadata', () => {
it('renders the map and hides the raw spatial string when geometry parses', () => {
render(<DatasetMetadata extras={{ spatial: POLYGON, resolution: '10m' }} />);
expect(screen.getByTestId('spatial-map')).toBeInTheDocument();
expect(screen.queryByText('Spatial', { exact: false })).toBeInTheDocument();
// The raw coordinate JSON must not appear as a plain field value.
expect(screen.queryByText(/coordinates/)).not.toBeInTheDocument();
});

it('applies friendly labels to known keys', () => {
render(<DatasetMetadata extras={{ data_vintage: '2022-2023', EPSG: '4326' }} />);
expect(screen.getByText('Data vintage')).toBeInTheDocument();
expect(screen.getByText('EPSG')).toBeInTheDocument();
});

it('groups harvest_* fields under a provenance subsection', () => {
render(
<DatasetMetadata
extras={{
resolution: '10m',
harvest_source_title: 'WIFIRE Commons',
harvest_object_id: 'ed2c0667-68b2-499d-a4f0-e2375f7f3975'
}}
/>
);
expect(screen.getByText('Harvest provenance')).toBeInTheDocument();
expect(screen.getByText('Harvest source')).toBeInTheDocument();
expect(screen.getByText('WIFIRE Commons')).toBeInTheDocument();
});

it('keeps the spatial value as a plain field when it cannot be parsed', () => {
render(<DatasetMetadata extras={{ spatial: 'not-geojson' }} />);
expect(screen.queryByTestId('spatial-map')).not.toBeInTheDocument();
expect(screen.getByText('not-geojson')).toBeInTheDocument();
});

it('renders nothing when there are no extras', () => {
const { container } = render(<DatasetMetadata extras={{}} />);
expect(container).toBeEmptyDOMElement();
});
});

describe('parseGeometry', () => {
it('parses a JSON string polygon', () => {
expect(parseGeometry(POLYGON)).toMatchObject({ type: 'Polygon' });
});

it('unwraps a Feature into its geometry', () => {
const feature = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 2] } };
expect(parseGeometry(feature)).toMatchObject({ type: 'Point' });
});

it('returns null for non-geometry input', () => {
expect(parseGeometry('nonsense')).toBeNull();
expect(parseGeometry(null)).toBeNull();
expect(parseGeometry({ foo: 'bar' })).toBeNull();
});
});
Loading
Loading