diff --git a/src/components/ResourceBrowser/browser.tsx b/src/components/ResourceBrowser/browser.tsx
index b6a5f0a..4c5de45 100644
--- a/src/components/ResourceBrowser/browser.tsx
+++ b/src/components/ResourceBrowser/browser.tsx
@@ -2,18 +2,17 @@ import { useLocalStorage } from "@aidbox-ui/hooks/useLocalStorage";
import * as HSComp from "@health-samurai/react-components";
import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
-import {
- ArrowDownAZ,
- ArrowDownZA,
- ArrowUpDown,
- Pin,
- Search,
- X,
-} from "lucide-react";
+import Fuse from "fuse.js";
+import { Pin, Search, X } from "lucide-react";
import React, { useMemo, useRef } from "react";
import { type AidboxClientR5, useAidboxClient } from "../../AidboxClient";
import { createFuzzySearch } from "../../utils/fuzzy-search";
-import { buildQuery, parseQuery, tagSlug } from "../../utils/tag-search";
+import {
+ filterHighlightRanges,
+ highlight,
+ type MatchRange,
+} from "../../utils/highlight";
+import { parseQuery, tagSlug } from "../../utils/tag-search";
import { useWebMCPResourceBrowser } from "../../webmcp/resource-browser";
import type { ResourceBrowserActions } from "../../webmcp/resource-browser-context";
import { EmptyState } from "../empty-state";
@@ -28,6 +27,7 @@ const STATUS_VALUES = new Set([
"trial-use",
"draft",
"informative",
+ "deprecated",
]);
type SDExtension = {
@@ -44,8 +44,20 @@ type SDItem = {
categoryTop?: string;
categorySub?: string;
standardsStatus?: string;
+ origin?: string;
+ nameMatches?: readonly MatchRange[];
+ descriptionMatches?: readonly MatchRange[];
};
+const AIDBOX_PUBLISHER = "Health Samurai";
+const FHIR_PUBLISHER_PREFIX = "Health Level Seven";
+
+function publisherToOrigin(publisher: string | undefined): string | undefined {
+ if (publisher === AIDBOX_PUBLISHER) return "Aidbox";
+ if (publisher?.startsWith(FHIR_PUBLISHER_PREFIX)) return "FHIR";
+ return undefined;
+}
+
function decodeAmp(s: string): string {
return s.replace(/&/g, "&");
}
@@ -71,6 +83,7 @@ function parseStandardsStatus(
function itemTagSlugs(item: SDItem): string[] {
const slugs: string[] = [tagSlug(item.resourceType)];
+ if (item.origin) slugs.push(tagSlug(item.origin));
if (item.categoryTop) slugs.push(tagSlug(item.categoryTop));
if (item.categorySub) slugs.push(tagSlug(item.categorySub));
if (item.standardsStatus) slugs.push(tagSlug(item.standardsStatus));
@@ -85,7 +98,10 @@ function filterByTags(items: SDItem[], tagTokens: string[]): SDItem[] {
});
}
-function chipStyleFor(slug: string): string {
+function chipStyleFor(text: string): string {
+ const slug = tagSlug(text);
+ if (slug === "aidbox") return "bg-bg-brand-secondary text-text-brand-primary";
+ if (slug === "fhir") return "bg-green-50 text-text-success-primary";
if (STATUS_VALUES.has(slug)) return "bg-yellow-50 text-text-warning-primary";
return "bg-blue-50 text-text-info-primary";
}
@@ -96,6 +112,7 @@ type StructureDefinitionResource = {
name?: string;
url?: string;
description?: string;
+ publisher?: string;
extension?: SDExtension[];
};
@@ -110,7 +127,7 @@ function useStructureDefinitions(client: AidboxClientR5) {
queryFn: async () => {
const response = await client.rawRequest({
method: "GET",
- url: "/fhir/StructureDefinition?kind=resource&derivation=specialization&_count=1000&_elements=type,name,url,description,extension",
+ url: "/fhir/StructureDefinition?kind=resource&derivation=specialization&_count=1000&_elements=type,name,url,description,extension,publisher",
});
const bundle: StructureDefinitionBundle = await response.response.json();
return (bundle.entry ?? []).flatMap((entry) => {
@@ -127,6 +144,7 @@ function useStructureDefinitions(client: AidboxClientR5) {
categoryTop: cat.top,
categorySub: cat.sub,
standardsStatus: parseStandardsStatus(r.extension),
+ origin: publisherToOrigin(r.publisher),
} satisfies SDItem,
];
});
@@ -134,10 +152,6 @@ function useStructureDefinitions(client: AidboxClientR5) {
});
}
-type SortColumn = "name" | "url";
-type SortDirection = "asc" | "desc";
-type SortState = { column: SortColumn; direction: SortDirection };
-
function Badge({ text, onClick }: { text: string; onClick: () => void }) {
return (
@@ -181,16 +194,22 @@ function ItemCard({
className="block"
>
-
- {item.name}
+
+ {highlight(item.name, item.nameMatches)}
{item.description && (
-
- {item.description}
+
+ {highlight(item.description, item.descriptionMatches)}
)}
- {(item.categoryTop || item.standardsStatus) && (
+ {(item.origin || item.categoryTop || item.standardsStatus) && (
+ {item.origin && (
+
onTagClick(item.origin ?? "")}
+ />
+ )}
{item.categoryTop && (
{chips.map((chip) => (
- onRemoveChip(chip)}
+ className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] leading-4 whitespace-nowrap cursor-pointer ${chipStyleFor(chip)}`}
>
#{chip}
-
-
+
+
))}
void;
-}) {
- const label =
- sort.column === "name"
- ? sort.direction === "asc"
- ? "Name A→Z"
- : "Name Z→A"
- : sort.direction === "asc"
- ? "URL A→Z"
- : "URL Z→A";
- const Icon = sort.direction === "asc" ? ArrowDownAZ : ArrowDownZA;
- return (
-
-
-
-
- {label}
-
-
-
- onChange({ column: "name", direction: "asc" })}
- >
-
- Name A→Z
-
- onChange({ column: "name", direction: "desc" })}
- >
-
- Name Z→A
-
- onChange({ column: "url", direction: "asc" })}
- >
-
- URL A→Z
-
- onChange({ column: "url", direction: "desc" })}
- >
-
- URL Z→A
-
-
-
- );
-}
-
-function sortItems(items: SDItem[], sort: SortState): SDItem[] {
- const sorted = [...items];
- sorted.sort((a, b) => {
- const av = sort.column === "name" ? a.name : a.url;
- const bv = sort.column === "name" ? b.name : b.url;
- const cmp = av.localeCompare(bv);
- return sort.direction === "asc" ? cmp : -cmp;
- });
- return sorted;
+function sortByName(items: SDItem[]): SDItem[] {
+ return [...items].sort((a, b) => a.name.localeCompare(b.name));
}
function applyFavorites(
@@ -380,17 +330,26 @@ function applyFavorites(
export function Browser() {
const client = useAidboxClient();
- const { q } = useSearch({ from: "/resource/" });
+ const search = useSearch({ from: "/resource/" });
const navigate = useNavigate();
- const searchQ = q ?? "";
- const { chips, text: textPart } = parseQuery(searchQ);
- const tagTokens = chips.map((c) => c.toLowerCase());
+ const text = search.q ?? "";
+ const chips = search.tags ?? [];
+ const tagTokens = chips.map(tagSlug);
+ const hasQuery = chips.length > 0 || Boolean(text);
- const setSearchQ = (value: string) => {
+ const setText = (next: string) => {
+ navigate({
+ from: "/resource/",
+ search: (prev) => ({ ...prev, q: next || undefined }),
+ replace: true,
+ });
+ };
+ const setTags = (next: string[]) => {
navigate({
from: "/resource/",
- search: (prev) => ({ ...prev, q: value || undefined }),
+ search: (prev) => ({ ...prev, tags: next.length > 0 ? next : undefined }),
+ replace: true,
});
};
@@ -400,48 +359,75 @@ export function Browser() {
});
const favorites = useMemo(() => new Set(favoritesArray), [favoritesArray]);
- const [sort, setSort] = React.useState({
- column: "name",
- direction: "asc",
- });
-
const { data, isLoading } = useStructureDefinitions(client);
const allItems = data ?? [];
const tagFiltered = filterByTags(allItems, tagTokens);
- const fuzzy = useMemo(
+ const fuse = useMemo(
() =>
- createFuzzySearch(tagFiltered, {
+ new Fuse(tagFiltered, {
keys: [
- { name: "name", weight: 2 },
+ { name: "name", weight: 10 },
{ name: "description", weight: 1 },
],
+ includeMatches: true,
+ ignoreLocation: true,
+ useExtendedSearch: true,
minMatchCharLength: 1,
threshold: 0.3,
}),
[tagFiltered],
);
- const textFiltered = textPart ? fuzzy(textPart) : tagFiltered;
- const sorted = sortItems(textFiltered, sort);
- const items = applyFavorites(sorted, favorites, Boolean(searchQ));
+ const textFiltered: SDItem[] = text
+ ? fuse.search(text).map((r) => {
+ const nameMatch = r.matches?.find((m) => m.key === "name");
+ const descMatch = r.matches?.find((m) => m.key === "description");
+ return {
+ ...r.item,
+ nameMatches: filterHighlightRanges(
+ text,
+ nameMatch?.indices as readonly MatchRange[] | undefined,
+ ),
+ descriptionMatches: filterHighlightRanges(
+ text,
+ descMatch?.indices as readonly MatchRange[] | undefined,
+ ),
+ };
+ })
+ : sortByName(tagFiltered);
+ const items = applyFavorites(textFiltered, favorites, hasQuery);
const handleTagClick = (tagText: string) => {
const slug = tagSlug(tagText);
- if (chips.includes(slug)) return;
- setSearchQ(buildQuery([...chips, slug], textPart));
+ if (chips.some((c) => tagSlug(c) === slug)) return;
+ setTags([...chips, tagText]);
};
- const removeChip = (slug: string) => {
- setSearchQ(
- buildQuery(
- chips.filter((c) => c !== slug),
- textPart,
- ),
- );
+ const removeChip = (tag: string) => {
+ setTags(chips.filter((c) => c !== tag));
};
const updateTextPart = (next: string) => {
- setSearchQ(buildQuery(chips, next));
+ const parsed = parseQuery(next);
+ if (parsed.chips.length > 0) {
+ const seen = new Set(chips.map(tagSlug));
+ const extra: string[] = [];
+ for (const c of parsed.chips) {
+ const s = tagSlug(c);
+ if (!seen.has(s)) {
+ extra.push(c);
+ seen.add(s);
+ }
+ }
+ if (extra.length > 0) setTags([...chips, ...extra]);
+ setText(parsed.text);
+ } else {
+ setText(next);
+ }
+ };
+ const onClear = () => {
+ setTags([]);
+ setText("");
};
const toggleFavorite = (resourceType: string) => {
@@ -468,8 +454,9 @@ export function Browser() {
}, [focusedIndex]);
React.useEffect(() => {
- setFocusedIndex(-1);
- }, []);
+ if (text && items.length > 0) setFocusedIndex(0);
+ else setFocusedIndex(-1);
+ }, [text, items.length]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) {
@@ -478,11 +465,8 @@ export function Browser() {
} else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) {
e.preventDefault();
setFocusedIndex((p) => Math.max(p - 1, -1));
- } else if (
- e.key === "Enter" &&
- focusedIndex >= 0 &&
- focusedIndex < items.length
- ) {
+ } else if (e.key === "Enter") {
+ if (focusedIndex < 0) return;
const it = items[focusedIndex];
if (!it) return;
e.preventDefault();
@@ -497,12 +481,20 @@ export function Browser() {
useWebMCPResourceBrowser(actionsRef);
actionsRef.current = {
listResourceTypes: (filter) => {
- if (filter !== undefined) setSearchQ(filter);
- const q2 = filter ?? searchQ;
- const parsed = parseQuery(q2);
- const tags = parsed.chips.map((c) => c.toLowerCase());
- const filtered = filterByTags(allItems, tags);
- const t = parsed.text
+ let resolvedTags: string[];
+ let resolvedText: string;
+ if (filter !== undefined) {
+ const parsed = parseQuery(filter);
+ setTags(parsed.chips);
+ setText(parsed.text);
+ resolvedTags = parsed.chips.map(tagSlug);
+ resolvedText = parsed.text;
+ } else {
+ resolvedTags = tagTokens;
+ resolvedText = text;
+ }
+ const filtered = filterByTags(allItems, resolvedTags);
+ const t = resolvedText
? createFuzzySearch(filtered, {
keys: [
{ name: "name", weight: 2 },
@@ -510,10 +502,13 @@ export function Browser() {
],
minMatchCharLength: 1,
threshold: 0.3,
- })(parsed.text)
- : filtered;
- const sortedRes = sortItems(t, sort);
- const finalList = applyFavorites(sortedRes, favorites, Boolean(q2));
+ })(resolvedText)
+ : sortByName(filtered);
+ const finalList = applyFavorites(
+ t,
+ favorites,
+ Boolean(resolvedText) || resolvedTags.length > 0,
+ );
return finalList.map((row) => ({
resourceType: row.resourceType,
url: row.url,
@@ -533,17 +528,16 @@ export function Browser() {
return (
-
+
setSearchQ("")}
+ onClear={onClear}
onInputKeyDown={handleKeyDown}
/>
-
{!isLoading && items.length === 0 ? (
diff --git a/src/routes/analytics.index.tsx b/src/routes/analytics.index.tsx
index f6f65bb..8c20f85 100644
--- a/src/routes/analytics.index.tsx
+++ b/src/routes/analytics.index.tsx
@@ -17,29 +17,13 @@ import {
import * as React from "react";
import { useAidboxClient } from "../AidboxClient";
import { EmptyState } from "../components/empty-state";
+import {
+ filterHighlightRanges,
+ highlight,
+ type MatchRange,
+} from "../utils/highlight";
import { parseQuery, tagSlug } from "../utils/tag-search";
-type MatchRange = readonly [number, number];
-
-function highlight(text: string, ranges: readonly MatchRange[] | undefined) {
- if (!ranges || ranges.length === 0) return text;
- const sorted = ranges.slice().sort((a, b) => a[0] - b[0]);
- const parts: React.ReactNode[] = [];
- let cursor = 0;
- sorted.forEach(([start, end]) => {
- if (start < cursor) return;
- if (start > cursor) parts.push(text.slice(cursor, start));
- parts.push(
-
- {text.slice(start, end + 1)}
- ,
- );
- cursor = end + 1;
- });
- if (cursor < text.length) parts.push(text.slice(cursor));
- return <>{parts}>;
-}
-
const SQL_QUERY_TYPE_TOKEN =
"https://sql-on-fhir.org/ig/CodeSystem/LibraryTypesCodes|sql-query";
@@ -338,6 +322,7 @@ function SearchBar({
onTextChange,
onRemoveChip,
onClear,
+ onInputKeyDown,
}: {
chips: string[];
textPart: string;
@@ -347,6 +332,7 @@ function SearchBar({
onTextChange: (next: string) => void;
onRemoveChip: (tag: string) => void;
onClear: () => void;
+ onInputKeyDown?: (e: React.KeyboardEvent
) => void;
}) {
return (
@@ -372,7 +358,9 @@ function SearchBar({
if (e.key === "Backspace" && textPart === "" && last) {
e.preventDefault();
onRemoveChip(last);
+ return;
}
+ onInputKeyDown?.(e);
}}
placeholder={chips.length === 0 ? placeholder : ""}
className="flex-1 min-w-[80px] bg-transparent outline-none typo-body text-text-primary placeholder:text-text-tertiary"
@@ -389,7 +377,6 @@ function SearchBar({
);
}
-// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: list page combines search/filter/grouping logic
export function AnalyticsListPage({
kind,
tags,
@@ -502,23 +489,20 @@ export function AnalyticsListPage({
[tagFiltered],
);
const needle = textQuery;
- const minHighlight = Math.min(Math.max(needle.length, 1), 3);
const items: RecentItem[] = needle
? fuse.search(needle).map((r) => {
const labelMatch = r.matches?.find((m) => m.key === "label");
const descriptionMatch = r.matches?.find(
(m) => m.key === "description",
);
- const filterRanges = (
- indices?: readonly MatchRange[],
- ): readonly MatchRange[] | undefined =>
- indices?.filter(([s, e]) => e - s + 1 >= minHighlight);
return {
...r.item,
- labelMatches: filterRanges(
+ labelMatches: filterHighlightRanges(
+ needle,
labelMatch?.indices as readonly MatchRange[] | undefined,
),
- descriptionMatches: filterRanges(
+ descriptionMatches: filterHighlightRanges(
+ needle,
descriptionMatch?.indices as readonly MatchRange[] | undefined,
),
};
@@ -570,6 +554,51 @@ export function AnalyticsListPage({
setText("");
};
+ const [focusedIndex, setFocusedIndex] = React.useState(-1);
+ const focusedRowRef = React.useRef
(null);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: focusedIndex triggers scroll
+ React.useEffect(() => {
+ focusedRowRef.current?.scrollIntoView({ block: "nearest" });
+ }, [focusedIndex]);
+
+ React.useEffect(() => {
+ if (text && items.length > 0) setFocusedIndex(0);
+ else setFocusedIndex(-1);
+ }, [text, items.length]);
+
+ const openItem = (it: RecentItem) => {
+ if (it.kind === "view") {
+ navigate({
+ to: "/analytics/views/edit/$id",
+ params: { id: it.id },
+ search: VIEW_EDIT_SEARCH,
+ });
+ } else {
+ navigate({
+ to: "/analytics/queries/edit/$id",
+ params: { id: it.id },
+ search: QUERY_EDIT_SEARCH,
+ });
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) {
+ e.preventDefault();
+ setFocusedIndex((p) => Math.min(p + 1, items.length - 1));
+ } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) {
+ e.preventDefault();
+ setFocusedIndex((p) => Math.max(p - 1, -1));
+ } else if (e.key === "Enter") {
+ if (focusedIndex < 0) return;
+ const it = items[focusedIndex];
+ if (!it) return;
+ e.preventDefault();
+ openItem(it);
+ }
+ };
+
return (
@@ -583,6 +612,7 @@ export function AnalyticsListPage({
onTextChange={updateTextPart}
onRemoveChip={removeChip}
onClear={onClear}
+ onInputKeyDown={handleKeyDown}
/>
{kind === "view" ? (
@@ -673,13 +703,15 @@ export function AnalyticsListPage({
) : (
- {items.map((it) => {
+ {items.map((it, index) => {
const itemKey = `${it.kind}-${it.id}`;
const isMenuOpen = openMenuKey === itemKey;
+ const focused = index === focusedIndex;
return (
-
{it.kind === "view" ? (
({
- q: typeof search.q === "string" && search.q ? search.q : undefined,
- }),
+ validateSearch: (search: {
+ q?: unknown;
+ tags?: unknown;
+ }): { q?: string; tags?: string[] } => {
+ const out: { q?: string; tags?: string[] } = {};
+ if (typeof search.q === "string" && search.q.length > 0) out.q = search.q;
+ if (Array.isArray(search.tags)) {
+ const tags = search.tags.filter(
+ (t): t is string => typeof t === "string" && t.length > 0,
+ );
+ if (tags.length > 0) out.tags = tags;
+ } else if (typeof search.tags === "string" && search.tags.length > 0) {
+ out.tags = [search.tags];
+ }
+ return out;
+ },
});
function RouteComponent() {
diff --git a/src/utils/highlight.tsx b/src/utils/highlight.tsx
new file mode 100644
index 0000000..fa150ba
--- /dev/null
+++ b/src/utils/highlight.tsx
@@ -0,0 +1,34 @@
+import type * as React from "react";
+
+export type MatchRange = readonly [number, number];
+
+export function highlight(
+ text: string,
+ ranges: readonly MatchRange[] | undefined,
+): React.ReactNode {
+ if (!ranges || ranges.length === 0) return text;
+ const sorted = ranges.slice().sort((a, b) => a[0] - b[0]);
+ const parts: React.ReactNode[] = [];
+ let cursor = 0;
+ sorted.forEach(([start, end]) => {
+ if (start < cursor) return;
+ if (start > cursor) parts.push(text.slice(cursor, start));
+ parts.push(
+
+ {text.slice(start, end + 1)}
+ ,
+ );
+ cursor = end + 1;
+ });
+ if (cursor < text.length) parts.push(text.slice(cursor));
+ return <>{parts}>;
+}
+
+export function filterHighlightRanges(
+ needle: string,
+ ranges: readonly MatchRange[] | undefined,
+): readonly MatchRange[] | undefined {
+ if (!ranges) return ranges;
+ const min = Math.min(Math.max(needle.length, 1), 3);
+ return ranges.filter(([s, e]) => e - s + 1 >= min);
+}