From 4129bdabf2f68e40ad64f5c25907a02d32c8b2c6 Mon Sep 17 00:00:00 2001 From: Fhatuwani Sikhwari Date: Mon, 31 Aug 2026 22:45:04 +0200 Subject: [PATCH] feat(dashboard): add registry export links --- dashboard/app/lib/registry-query.test.ts | 44 ++++++++++++++++++++++ dashboard/app/lib/registry-query.ts | 31 +++++++++++++++ dashboard/components/registry-controls.tsx | 29 ++++++++++++-- 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/dashboard/app/lib/registry-query.test.ts b/dashboard/app/lib/registry-query.test.ts index 010f42f..6536390 100644 --- a/dashboard/app/lib/registry-query.test.ts +++ b/dashboard/app/lib/registry-query.test.ts @@ -9,6 +9,8 @@ import { groupRankedByCategory, matchesQuery, parseRegistryParams, + registryExportHref, + registryExportLinks, partitionScored, registryHref, sortRanked, @@ -273,6 +275,48 @@ describe('registryHref', () => { }); }); +describe('registryExportLinks', () => { + it('targets the current-state export endpoint for CSV and JSON', () => { + assert.equal(registryExportHref('csv'), '/api/v1/protocols/export?format=csv'); + assert.equal(registryExportHref('json'), '/api/v1/protocols/export?format=json'); + assert.deepEqual( + registryExportLinks().map((link) => [link.label, link.href]), + [ + ['CSV', '/api/v1/protocols/export?format=csv'], + ['JSON', '/api/v1/protocols/export?format=json'], + ], + ); + }); + + it('does not encode the filtered or sorted registry view into export URLs', () => { + const filtered = registryHref({ + q: 'blend', + status: 'scored', + category: 'lending', + sort: 'score-asc', + }); + assert.equal(filtered, '/registry?q=blend&status=scored&category=lending&sort=score-asc'); + + for (const link of registryExportLinks()) { + const url = new URL(link.href, 'https://stenion.test'); + assert.equal(url.pathname, '/api/v1/protocols/export'); + assert.equal(url.searchParams.get('format'), link.format); + assert.equal(url.searchParams.has('q'), false); + assert.equal(url.searchParams.has('status'), false); + assert.equal(url.searchParams.has('category'), false); + assert.equal(url.searchParams.has('sort'), false); + } + }); + + it('provides ordinary labelled links, so keyboard access stays native', () => { + for (const link of registryExportLinks()) { + assert.match(link.ariaLabel, /^Download current registry snapshot as (CSV|JSON)$/); + assert.ok(link.href.startsWith('/api/v1/protocols/export?format=')); + assert.equal(link.label.length > 0, true); + } + }); +}); + describe('matchesQuery', () => { it('finds an entry by what a reader sees, not by its slug spelling', () => { // The punctuation fold: someone typing the display name finds the entry diff --git a/dashboard/app/lib/registry-query.ts b/dashboard/app/lib/registry-query.ts index 3fe8cc9..e3b0135 100644 --- a/dashboard/app/lib/registry-query.ts +++ b/dashboard/app/lib/registry-query.ts @@ -89,6 +89,16 @@ export type RegistryCategoryFilter = 'all' | ProtocolCategory; export const DEFAULT_CATEGORY: RegistryCategoryFilter = 'all'; +export const REGISTRY_EXPORT_FORMATS = ['csv', 'json'] as const; +export type RegistryExportFormat = (typeof REGISTRY_EXPORT_FORMATS)[number]; + +export interface RegistryExportLink { + format: RegistryExportFormat; + label: string; + href: string; + ariaLabel: string; +} + /** * How a category is written where a reader sees it — a section heading above a * ranked block, or an option in the filter. @@ -182,6 +192,27 @@ export function registryHref(params: Partial): string { return qs ? `/registry?${qs}` : '/registry'; } +export function registryExportHref(format: RegistryExportFormat): string { + return `/api/v1/protocols/export?format=${format}`; +} + +export function registryExportLinks(): RegistryExportLink[] { + return [ + { + format: 'csv', + label: 'CSV', + href: registryExportHref('csv'), + ariaLabel: 'Download current registry snapshot as CSV', + }, + { + format: 'json', + label: 'JSON', + href: registryExportHref('json'), + ariaLabel: 'Download current registry snapshot as JSON', + }, + ]; +} + /** * Fold a string to something two spellings of the same name can be compared on: * lowercase, diacritics stripped, every run of non-alphanumerics collapsed to a diff --git a/dashboard/components/registry-controls.tsx b/dashboard/components/registry-controls.tsx index 06b7a2b..1d7a2ef 100644 --- a/dashboard/components/registry-controls.tsx +++ b/dashboard/components/registry-controls.tsx @@ -22,11 +22,12 @@ import { useEffect, useRef, useState, useTransition } from 'react'; import { useRouter } from 'next/navigation'; -import { Loader2, Search, X } from 'lucide-react'; +import { Download, Loader2, Search, X } from 'lucide-react'; import { cn } from '../app/lib/cn'; import { REGISTRY_SORTS, + registryExportLinks, registryHref, type RegistryCategoryFilter, type RegistryParams, @@ -120,6 +121,7 @@ export function RegistryControls({ router.replace(registryHref({ ...params, q, ...next }), { scroll: false }); }); }; + const exportLinks = registryExportLinks(); return (
-
+
{/* One slot, two icons: the magnifier idle, a spinner in flight. Same position and size, so nothing shifts as it swaps. */} {isPending ? ( @@ -180,7 +182,7 @@ export function RegistryControls({ )}
-
+