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
44 changes: 44 additions & 0 deletions dashboard/app/lib/registry-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
groupRankedByCategory,
matchesQuery,
parseRegistryParams,
registryExportHref,
registryExportLinks,
partitionScored,
registryHref,
sortRanked,
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions dashboard/app/lib/registry-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -182,6 +192,27 @@ export function registryHref(params: Partial<RegistryParams>): 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
Expand Down
29 changes: 25 additions & 4 deletions dashboard/components/registry-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -120,6 +121,7 @@ export function RegistryControls({
router.replace(registryHref({ ...params, q, ...next }), { scroll: false });
});
};
const exportLinks = registryExportLinks();

return (
<form
Expand All @@ -131,10 +133,10 @@ export function RegistryControls({
e.preventDefault();
go({ q });
}}
className={cn('flex flex-col gap-3 sm:flex-row sm:items-center', className)}
className={cn('flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center', className)}
role="search"
>
<div className="relative flex-1">
<div className="relative flex-1 sm:min-w-64">
{/* One slot, two icons: the magnifier idle, a spinner in flight. Same
position and size, so nothing shifts as it swaps. */}
{isPending ? (
Expand Down Expand Up @@ -180,7 +182,7 @@ export function RegistryControls({
)}
</div>

<div className="flex gap-3">
<div className="flex flex-wrap gap-3">
<Select
id="registry-status"
name="status"
Expand Down Expand Up @@ -236,6 +238,25 @@ export function RegistryControls({
</Select>
</div>

<div
className="flex items-center gap-1.5 rounded-lg border border-line bg-surface px-2 py-1.5"
role="group"
aria-label="Export current registry snapshot"
>
<Download className="h-4 w-4 text-faint" aria-hidden="true" />
<span className="text-sm text-muted">Export</span>
{exportLinks.map((link) => (
<a
key={link.format}
href={link.href}
aria-label={link.ariaLabel}
className="rounded px-2 py-1 text-sm font-medium text-accent-ink transition-colors hover:bg-accent/10 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{link.label}
</a>
))}
</div>

{/* The no-JS submit. Hidden from pointer users once the enhancement is
live — but only visually, and only via CSS that requires JS to have
run, so a browser without it still shows a working button. */}
Expand Down
Loading