From 751c538f647e41b72c9e2bf3785f884246d81d1a Mon Sep 17 00:00:00 2001 From: Panthevm Date: Mon, 18 May 2026 19:31:04 +0300 Subject: [PATCH 1/4] Analytics: clickable tag chips, layout polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tag badges become clickable: clicking adds a `#` token to the search input as a coloured chip (view → blue, query → yellow, resource → green). Chips can only be removed whole via the × button or Backspace at empty text. Duplicate chips are prevented. - Search filter supports `#tag` tokens with exact slug match. An item matches a tag if it has that tag OR is the entity itself (label slug match). Free text is fuzzy-searched against the tag-filtered subset. - Sticky search header switched to shadow-only style (no border-b) and copies the /settings layout. - Clear button (IconButton) on the right of the search input. - List wrapped in `mx-auto max-w-[990px] px-8` container with `divide-y` row separators. - Removed `line-clamp-1` from description; ItemRow left padding aligned with the search magnifier icon. - Sidebar in DataLineagePage temporarily removed: `/analytics/**` pages use full width via plain ``. - Sidebar menu icon for Analytics: FileChartPie → ChartNoAxesCombined. - Refactor: extract SearchBar, tagSlug, parseQuery, buildQuery, getItemTagSlugs, filterByTags, chipStyleFor as module-level helpers to keep AnalyticsListPage's cognitive complexity within limits. --- src/components/DataLineage/page.tsx | 18 +- src/layout/sidebar.tsx | 4 +- src/routes/analytics.index.tsx | 249 ++++++++++++++++++++++++---- 3 files changed, 222 insertions(+), 49 deletions(-) diff --git a/src/components/DataLineage/page.tsx b/src/components/DataLineage/page.tsx index ac86f6f..a1f6648 100644 --- a/src/components/DataLineage/page.tsx +++ b/src/components/DataLineage/page.tsx @@ -1,21 +1,9 @@ -import * as HSComp from "@health-samurai/react-components"; import { Outlet } from "@tanstack/react-router"; -import { DataLineageSidebar } from "./sidebar"; export function DataLineagePage() { return ( - - - - - - - - - +
+ +
); } diff --git a/src/layout/sidebar.tsx b/src/layout/sidebar.tsx index 3e5dfdc..5169997 100644 --- a/src/layout/sidebar.tsx +++ b/src/layout/sidebar.tsx @@ -11,10 +11,10 @@ import { } from "@health-samurai/react-components"; import { Link, useRouterState } from "@tanstack/react-router"; import { + ChartNoAxesCombined, ClipboardList, Columns3Cog, Database, - FileChartPie, Package, PanelLeftClose, PanelLeftOpen, @@ -71,7 +71,7 @@ const mainMenuItems: { title: "Analytics", link: ( - + Analytics ), diff --git a/src/routes/analytics.index.tsx b/src/routes/analytics.index.tsx index ac8c01c..b8fc2bf 100644 --- a/src/routes/analytics.index.tsx +++ b/src/routes/analytics.index.tsx @@ -12,6 +12,7 @@ import { Search, Table, Trash2, + X, } from "lucide-react"; import * as React from "react"; import { useAidboxClient } from "../AidboxClient"; @@ -168,24 +169,107 @@ function useRecentQueries() { type LookupByUrl = (url: string) => RecentItem | undefined; -function Badge({ text, accentClass }: { text: string; accentClass: string }) { - return ( - - #{text} - - ); +function tagSlug(text: string): string { + return text.toLowerCase().replace(/\s+/g, "-"); +} + +function parseQuery(q: string): { chips: string[]; text: string } { + const tokens = q.split(/\s+/).filter(Boolean); + const chips: string[] = []; + const textTokens: string[] = []; + for (const t of tokens) { + if (t.startsWith("#") && t.length > 1) chips.push(t.slice(1)); + else textTokens.push(t); + } + return { chips, text: textTokens.join(" ") }; +} + +function buildQuery(chips: string[], text: string): string { + const chipStr = chips.map((c) => `#${c}`).join(" "); + const trimmedText = text.trim(); + if (chipStr && trimmedText) return `${chipStr} ${trimmedText}`; + return chipStr || trimmedText; +} + +function getItemTagSlugs(item: RecentItem, lookup: LookupByUrl): string[] { + const slugs: string[] = [tagSlug(item.label)]; + if (item.resource) slugs.push(tagSlug(item.resource)); + if (item.relatedArtifacts) { + for (const ra of item.relatedArtifacts) { + const linked = lookup(ra.url); + const text = linked?.label ?? ra.label ?? ra.url; + slugs.push(tagSlug(text)); + } + } + return slugs; +} + +function filterByTags( + items: RecentItem[], + tagTokens: string[], + lookup: LookupByUrl, +): RecentItem[] { + if (tagTokens.length === 0) return items; + return items.filter((item) => { + const slugs = getItemTagSlugs(item, lookup); + return tagTokens.every((tag) => slugs.some((slug) => slug === tag)); + }); +} + +function chipStyleFor(slug: string, items: RecentItem[]): string { + for (const item of items) { + if (tagSlug(item.label) === slug) { + return item.kind === "view" + ? "bg-blue-50 text-text-info-primary" + : "bg-yellow-50 text-text-warning-primary"; + } + } + for (const item of items) { + if (item.resource && tagSlug(item.resource) === slug) { + return "bg-green-50 text-text-success-primary"; + } + } + return "bg-bg-tertiary text-text-primary"; +} + +function Badge({ + text, + accentClass, + onClick, +}: { + text: string; + accentClass: string; + onClick?: () => void; +}) { + const base = `shrink-0 text-[11px] leading-4 normal-case whitespace-nowrap ${accentClass}`; + if (onClick) { + return ( + + ); + } + return #{text}; } function ItemRow({ item, lookup, showKindLabel = true, + onTagClick, }: { item: RecentItem; lookup: LookupByUrl; showKindLabel?: boolean; + onTagClick?: (text: string) => void; }) { const isView = item.kind === "view"; const Icon = isView ? Table : FileCode2; @@ -195,11 +279,13 @@ function ItemRow({ : "text-text-warning-primary"; const badges: React.ReactNode[] = []; if (isView && item.resource) { + const resourceText = item.resource; badges.push( onTagClick(resourceText) : undefined} />, ); } @@ -208,21 +294,23 @@ function ItemRow({ const linked = lookup(ra.url); const isLinkedView = linked?.kind === "view" || ra.url.includes("/ViewDefinition/"); + const text = linked?.label ?? ra.label ?? ra.url; badges.push( onTagClick(text) : undefined} />, ); } } return ( -
+
{showKindLabel && (
{item.description && ( -
+
{highlight(item.description, item.descriptionMatches)}
)} @@ -261,6 +349,70 @@ const QUERY_CREATE_SEARCH = { builderTab: "form" as const, }; +function SearchBar({ + chips, + textPart, + placeholder, + allItems, + inputRef, + onTextChange, + onRemoveChip, + onClear, +}: { + chips: string[]; + textPart: string; + placeholder: string; + allItems: RecentItem[]; + inputRef: (el: HTMLInputElement | null) => void; + onTextChange: (next: string) => void; + onRemoveChip: (slug: string) => void; + onClear: () => void; +}) { + return ( +
+ + {chips.map((chip) => ( + + #{chip} + + + ))} + onTextChange(e.target.value)} + onKeyDown={(e) => { + const last = chips[chips.length - 1]; + if (e.key === "Backspace" && textPart === "" && last) { + e.preventDefault(); + onRemoveChip(last); + } + }} + placeholder={chips.length === 0 ? placeholder : ""} + className="flex-1 min-w-[80px] bg-transparent outline-none typo-body text-text-primary placeholder:text-text-tertiary" + /> + {(chips.length > 0 || textPart.length > 0) && ( + } + /> + )} +
+ ); +} + export function AnalyticsListPage({ kind, searchQ, @@ -357,18 +509,25 @@ export function AnalyticsListPage({ } return (url: string) => map.get(url); }, [views.data, queries.data]); - const needle = searchQ.trim(); + const { chips, text: textPart } = parseQuery(searchQ); + const tagTokens = chips.map((c) => c.toLowerCase()); + const textQuery = textPart; + + const tagFiltered = filterByTags(allItems, tagTokens, lookup); + const fuse = React.useMemo( () => - new Fuse(allItems, { + new Fuse(tagFiltered, { keys: ["label", "description"], includeMatches: true, threshold: 0.3, ignoreLocation: true, minMatchCharLength: 1, }), - [allItems], + // eslint-disable-next-line react-hooks/exhaustive-deps + [tagFiltered], ); + const needle = textQuery; const minHighlight = Math.min(Math.max(needle.length, 1), 3); const items: RecentItem[] = needle ? fuse.search(needle).map((r) => { @@ -390,7 +549,7 @@ export function AnalyticsListPage({ ), }; }) - : allItems; + : tagFiltered; if (loading) { return ( @@ -423,21 +582,37 @@ export function AnalyticsListPage({ navigate({ to: "/analytics/views/create", search: VIEW_CREATE_SEARCH }); const createQuery = () => navigate({ to: "/analytics/queries/create", search: QUERY_CREATE_SEARCH }); + const handleTagClick = (tagText: string) => { + const slug = tagSlug(tagText); + if (chips.includes(slug)) return; + setSearchQ(buildQuery([...chips, slug], textPart)); + }; + const removeChip = (slug: string) => { + setSearchQ( + buildQuery( + chips.filter((c) => c !== slug), + textPart, + ), + ); + }; + const updateTextPart = (next: string) => { + setSearchQ(buildQuery(chips, next)); + }; return (
-
-
-
- - setSearchQ(e.target.value)} - placeholder={placeholder} - className="flex-1 bg-transparent outline-none typo-body text-text-primary placeholder:text-text-tertiary" - /> -
+
+
+ setSearchQ("")} + /> {kind === "view" ? ( @@ -477,11 +652,11 @@ export function AnalyticsListPage({
{items.length === 0 ? ( -
+
Nothing matches “{searchQ}”.
) : ( -
    +
      {items.map((it) => { const itemKey = `${it.kind}-${it.id}`; const isMenuOpen = openMenuKey === itemKey; @@ -497,7 +672,12 @@ export function AnalyticsListPage({ search={VIEW_EDIT_SEARCH} className="block" > - + ) : ( - + )}
      Date: Mon, 18 May 2026 19:51:28 +0300 Subject: [PATCH 2/4] ResourceBrowser: carded list with tag chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the table layout on /u/resource with a card list aligned with the analytics page: sticky search bar with shadow, mx-auto max-w-[990px] centred container, divide-y row separators, name + description, tag chips. - Tags derived from StructureDefinition extensions: - category (.../structuredefinition-category, valueString) split by the first dot into top/sub chips, with HTML `&` decoded. - standards-status (.../structuredefinition-standards-status, valueCode) as a single chip (normative/trial-use/draft/informative). - Search supports inline chips: clicking a tag adds a `#` chip to the input, chips render with blue (category) or yellow (status) colour, and can only be removed whole. Free text fuzzy-matches name and description against the tag-filtered subset. - Sort dropdown replaces the table sort headers (Name/URL × asc/desc). - Favourites kept: pin button appears on hover, pinned items float to the top of the list when no search is active. - Keyboard navigation kept (↑/↓ from the search input, Enter to open). - WebMCP integration preserved (listResourceTypes, toggleFavorite, navigateToResourceType, getFavorites). - Extract tagSlug/parseQuery/buildQuery into src/utils/tag-search.ts shared between analytics and resource browser. --- src/components/ResourceBrowser/browser.tsx | 773 +++++++++++++-------- src/routes/analytics.index.tsx | 23 +- src/utils/tag-search.ts | 21 + 3 files changed, 508 insertions(+), 309 deletions(-) create mode 100644 src/utils/tag-search.ts diff --git a/src/components/ResourceBrowser/browser.tsx b/src/components/ResourceBrowser/browser.tsx index fef921b..b6a5f0a 100644 --- a/src/components/ResourceBrowser/browser.tsx +++ b/src/components/ResourceBrowser/browser.tsx @@ -1,374 +1,573 @@ import { useLocalStorage } from "@aidbox-ui/hooks/useLocalStorage"; import * as HSComp from "@health-samurai/react-components"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useSearch } from "@tanstack/react-router"; -import { X } from "lucide-react"; -import React, { useMemo, useRef, useState } from "react"; +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { + ArrowDownAZ, + ArrowDownZA, + ArrowUpDown, + 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 { useWebMCPResourceBrowser } from "../../webmcp/resource-browser"; import type { ResourceBrowserActions } from "../../webmcp/resource-browser-context"; import { EmptyState } from "../empty-state"; -type ResourceRow = { +const CATEGORY_EXT_URL = + "http://hl7.org/fhir/StructureDefinition/structuredefinition-category"; +const STATUS_EXT_URL = + "http://hl7.org/fhir/StructureDefinition/structuredefinition-standards-status"; + +const STATUS_VALUES = new Set([ + "normative", + "trial-use", + "draft", + "informative", +]); + +type SDExtension = { + url?: string; + valueString?: string; + valueCode?: string; +}; + +type SDItem = { resourceType: string; + name: string; url: string; + description?: string; + categoryTop?: string; + categorySub?: string; + standardsStatus?: string; }; -const skeletonRows = Array.from({ length: 30 }, (_, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton rows - - - - - - - - - -)); +function decodeAmp(s: string): string { + return s.replace(/&/g, "&"); +} -function ResourceList({ - data, - favorites, - onToggleFavorite, - isLoading, - focusedIndex, - sort, - onSort, -}: { - data: ResourceRow[]; - favorites: Set; - onToggleFavorite: (resourceType: string) => void; - isLoading: boolean; - focusedIndex: number; - sort: SortState; - onSort: (column: SortColumn) => void; -}) { - const navigate = useNavigate(); - const focusedRowRef = React.useRef(null); +function parseCategory(extensions: SDExtension[] | undefined): { + top?: string; + sub?: string; +} { + const ext = extensions?.find((e) => e.url === CATEGORY_EXT_URL); + if (!ext?.valueString) return {}; + const raw = decodeAmp(ext.valueString); + const dot = raw.indexOf("."); + if (dot === -1) return { top: raw }; + return { top: raw.slice(0, dot), sub: raw.slice(dot + 1) }; +} - // biome-ignore lint/correctness/useExhaustiveDependencies: focusedIndex triggers scroll - React.useEffect(() => { - focusedRowRef.current?.scrollIntoView({ block: "nearest" }); - }, [focusedIndex]); +function parseStandardsStatus( + extensions: SDExtension[] | undefined, +): string | undefined { + const ext = extensions?.find((e) => e.url === STATUS_EXT_URL); + return ext?.valueCode; +} - const goToResource = (resourceType: string) => - navigate({ - to: "/resource/$resourceType", - params: { resourceType }, - }); +function itemTagSlugs(item: SDItem): string[] { + const slugs: string[] = [tagSlug(item.resourceType)]; + if (item.categoryTop) slugs.push(tagSlug(item.categoryTop)); + if (item.categorySub) slugs.push(tagSlug(item.categorySub)); + if (item.standardsStatus) slugs.push(tagSlug(item.standardsStatus)); + return slugs; +} - return ( - - - - - - - - - onSort("resourceType")} - > - Resource type - - onSort("url")} - > - URL - - - - - {isLoading - ? skeletonRows - : data.length - ? data.map((row, index) => { - const isFavorite = favorites.has(row.resourceType); - const isLastFavorite = - isFavorite && - (index + 1 >= data.length || - !favorites.has(data[index + 1]?.resourceType as string)); - return ( - goToResource(row.resourceType)} - > - { - e.stopPropagation(); - onToggleFavorite(row.resourceType); - }} - > - - - - - - { - e.preventDefault(); - }} - className="text-text-link hover:underline" - > - {row.resourceType} - - - {row.url} - - ); - }) - : null} - - - ); +function filterByTags(items: SDItem[], tagTokens: string[]): SDItem[] { + if (tagTokens.length === 0) return items; + return items.filter((item) => { + const slugs = itemTagSlugs(item); + return tagTokens.every((tag) => slugs.some((slug) => slug === tag)); + }); } -type StructureDefinitionEntry = { - resource: { - type?: string; - name?: string; - url?: string; - }; +function chipStyleFor(slug: string): string { + if (STATUS_VALUES.has(slug)) return "bg-yellow-50 text-text-warning-primary"; + return "bg-blue-50 text-text-info-primary"; +} + +type StructureDefinitionResource = { + resourceType: "StructureDefinition"; + type?: string; + name?: string; + url?: string; + description?: string; + extension?: SDExtension[]; }; type StructureDefinitionBundle = { - entry?: StructureDefinitionEntry[]; + entry?: { resource: StructureDefinitionResource }[]; }; -function useResourceData(client: AidboxClientR5) { - return useQuery({ - queryKey: ["resource-browser-resources"], +function useStructureDefinitions(client: AidboxClientR5) { + return useQuery({ + queryKey: ["resource-browser-sd"], staleTime: 5 * 60 * 1000, queryFn: async () => { const response = await client.rawRequest({ method: "GET", - url: "/fhir/StructureDefinition?kind=resource&derivation=specialization&_count=1000&_elements=type,name,url", + url: "/fhir/StructureDefinition?kind=resource&derivation=specialization&_count=1000&_elements=type,name,url,description,extension", }); const bundle: StructureDefinitionBundle = await response.response.json(); - return (bundle.entry ?? []).map((entry) => ({ - resourceType: entry.resource.type ?? entry.resource.name ?? "", - url: entry.resource.url ?? "", - })); + return (bundle.entry ?? []).flatMap((entry) => { + const r = entry.resource; + const resourceType = r.type ?? r.name; + if (!resourceType) return []; + const cat = parseCategory(r.extension); + return [ + { + resourceType, + name: r.name ?? resourceType, + url: r.url ?? "", + description: r.description, + categoryTop: cat.top, + categorySub: cat.sub, + standardsStatus: parseStandardsStatus(r.extension), + } satisfies SDItem, + ]; + }); }, }); } -type SortColumn = "resourceType" | "url"; +type SortColumn = "name" | "url"; type SortDirection = "asc" | "desc"; type SortState = { column: SortColumn; direction: SortDirection }; +function Badge({ text, onClick }: { text: string; onClick: () => void }) { + return ( + + ); +} + +function ItemCard({ + item, + isFavorite, + onTagClick, + onToggleFavorite, + focused, + rowRef, +}: { + item: SDItem; + isFavorite: boolean; + onTagClick: (text: string) => void; + onToggleFavorite: () => void; + focused: boolean; + rowRef?: React.Ref; +}) { + return ( +
    • + +
      +
      + {item.name} +
      + {item.description && ( +
      + {item.description} +
      + )} + {(item.categoryTop || item.standardsStatus) && ( +
      + {item.categoryTop && ( + onTagClick(item.categoryTop ?? "")} + /> + )} + {item.categorySub && ( + onTagClick(item.categorySub ?? "")} + /> + )} + {item.standardsStatus && ( + onTagClick(item.standardsStatus ?? "")} + /> + )} +
      + )} +
      + + +
    • + ); +} + +function SearchBar({ + chips, + textPart, + inputRef, + onTextChange, + onRemoveChip, + onClear, + onInputKeyDown, +}: { + chips: string[]; + textPart: string; + inputRef: (el: HTMLInputElement | null) => void; + onTextChange: (next: string) => void; + onRemoveChip: (slug: string) => void; + onClear: () => void; + onInputKeyDown?: (e: React.KeyboardEvent) => void; +}) { + return ( +
      + + {chips.map((chip) => ( + + #{chip} + + + ))} + onTextChange(e.target.value)} + onKeyDown={(e) => { + const last = chips[chips.length - 1]; + if (e.key === "Backspace" && textPart === "" && last) { + e.preventDefault(); + onRemoveChip(last); + return; + } + onInputKeyDown?.(e); + }} + placeholder={ + chips.length === 0 ? "Search resources by name or description…" : "" + } + className="flex-1 min-w-[80px] bg-transparent outline-none typo-body text-text-primary placeholder:text-text-tertiary" + /> + {(chips.length > 0 || textPart.length > 0) && ( + } + /> + )} +
      + ); +} + +function SortDropdown({ + sort, + onChange, +}: { + sort: SortState; + onChange: (s: SortState) => 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 applyFavorites( + items: SDItem[], + favorites: Set, + hasQuery: boolean, +): SDItem[] { + if (hasQuery) return items; + return [...items].sort((a, b) => { + const af = favorites.has(a.resourceType); + const bf = favorites.has(b.resourceType); + if (af === bf) return 0; + return af ? -1 : 1; + }); +} + export function Browser() { const client = useAidboxClient(); - const { q } = useSearch({ from: "/resource/" }); - const filterQuery = q ?? ""; + const navigate = useNavigate(); + + const searchQ = q ?? ""; + const { chips, text: textPart } = parseQuery(searchQ); + const tagTokens = chips.map((c) => c.toLowerCase()); + + const setSearchQ = (value: string) => { + navigate({ + from: "/resource/", + search: (prev) => ({ ...prev, q: value || undefined }), + }); + }; const [favoritesArray, setFavoritesArray] = useLocalStorage({ key: "resource-browser-favorites", defaultValue: [], }); - const favorites = useMemo(() => new Set(favoritesArray), [favoritesArray]); - const actionsRef = useRef(null); - useWebMCPResourceBrowser(actionsRef); - - const [sort, setSort] = useState({ - column: "resourceType", + const [sort, setSort] = React.useState({ + column: "name", direction: "asc", }); - const handleSort = (column: SortColumn) => { - setSort((prev) => - prev.column === column - ? { column, direction: prev.direction === "asc" ? "desc" : "asc" } - : { column, direction: "asc" }, - ); - }; + const { data, isLoading } = useStructureDefinitions(client); + const allItems = data ?? []; - const { data, isLoading } = useResourceData(client); + const tagFiltered = filterByTags(allItems, tagTokens); - const fuzzySearch = useMemo( + const fuzzy = useMemo( () => - data - ? createFuzzySearch(data, { - keys: [ - { name: "resourceType", weight: 2 }, - { name: "url", weight: 1 }, - ], - minMatchCharLength: 1, - threshold: 0.3, - }) - : () => [], - [data], + createFuzzySearch(tagFiltered, { + keys: [ + { name: "name", weight: 2 }, + { name: "description", weight: 1 }, + ], + minMatchCharLength: 1, + threshold: 0.3, + }), + [tagFiltered], ); - const filteredData = useMemo(() => { - const results = fuzzySearch(filterQuery); - return [...results].sort((a, b) => { - const aFav = favorites.has(a.resourceType); - const bFav = favorites.has(b.resourceType); - if (aFav !== bFav) return aFav ? -1 : 1; - if (filterQuery) return 0; - const cmp = a[sort.column].localeCompare(b[sort.column]); - return sort.direction === "asc" ? cmp : -cmp; - }); - }, [fuzzySearch, filterQuery, favorites, sort]); + const textFiltered = textPart ? fuzzy(textPart) : tagFiltered; + const sorted = sortItems(textFiltered, sort); + const items = applyFavorites(sorted, favorites, Boolean(searchQ)); - const [focusedIndex, setFocusedIndex] = useState(-1); - const navigate = useNavigate(); - - const setFilterQuery = (value: string) => { - navigate({ - from: "/resource/", - search: (prev) => ({ ...prev, q: value || undefined }), - }); + const handleTagClick = (tagText: string) => { + const slug = tagSlug(tagText); + if (chips.includes(slug)) return; + setSearchQ(buildQuery([...chips, slug], textPart)); + }; + const removeChip = (slug: string) => { + setSearchQ( + buildQuery( + chips.filter((c) => c !== slug), + textPart, + ), + ); + }; + const updateTextPart = (next: string) => { + setSearchQ(buildQuery(chips, next)); }; - actionsRef.current = { - listResourceTypes: (filter) => { - if (filter !== undefined) { - setFilterQuery(filter); - } - const effectiveQuery = filter ?? filterQuery; - const results = fuzzySearch(effectiveQuery); - const sorted = [...results].sort((a, b) => { - const aFav = favorites.has(a.resourceType); - const bFav = favorites.has(b.resourceType); - if (aFav !== bFav) return aFav ? -1 : 1; - if (effectiveQuery) return 0; - const cmp = a[sort.column].localeCompare(b[sort.column]); - return sort.direction === "asc" ? cmp : -cmp; - }); - return sorted.map((row) => ({ - resourceType: row.resourceType, - url: row.url, - isFavorite: favorites.has(row.resourceType), - })); - }, - getFavorites: () => favoritesArray, - toggleFavorite: (resourceType) => { - toggleFavorite(resourceType); - }, - navigateToResourceType: (resourceType) => { - navigate({ - to: "/resource/$resourceType", - params: { resourceType }, - }); - }, + const toggleFavorite = (resourceType: string) => { + setFavoritesArray((prev) => + prev.includes(resourceType) + ? prev.filter((x) => x !== resourceType) + : [...prev, resourceType], + ); }; - // biome-ignore lint/correctness/useExhaustiveDependencies: reset focus on filter change + const [focusedIndex, setFocusedIndex] = React.useState(-1); + const focusedRowRef = useRef(null); + const didFocus = useRef(false); + const setSearchInputRef = React.useCallback((el: HTMLInputElement | null) => { + if (el && !didFocus.current) { + el.focus(); + didFocus.current = true; + } + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: focusedIndex triggers scroll React.useEffect(() => { - setFocusedIndex(-1); - }, [filterQuery]); + focusedRowRef.current?.scrollIntoView({ block: "nearest" }); + }, [focusedIndex]); - const toggleFavorite = useMemo( - () => (resourceType: string) => { - setFavoritesArray((prev) => { - if (prev.includes(resourceType)) { - return prev.filter((item) => item !== resourceType); - } - return [...prev, resourceType]; - }); - }, - [setFavoritesArray], - ); + React.useEffect(() => { + setFocusedIndex(-1); + }, []); - const handleSearchKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { e.preventDefault(); - setFocusedIndex((prev) => Math.min(prev + 1, filteredData.length - 1)); + setFocusedIndex((p) => Math.min(p + 1, items.length - 1)); } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { e.preventDefault(); - setFocusedIndex((prev) => Math.max(prev - 1, -1)); + setFocusedIndex((p) => Math.max(p - 1, -1)); } else if ( e.key === "Enter" && focusedIndex >= 0 && - focusedIndex < filteredData.length + focusedIndex < items.length ) { - const item = filteredData[focusedIndex]; - if (!item) return; + const it = items[focusedIndex]; + if (!it) return; e.preventDefault(); navigate({ to: "/resource/$resourceType", - params: { resourceType: item.resourceType }, + params: { resourceType: it.resourceType }, }); } }; + const actionsRef = useRef(null); + 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 + ? createFuzzySearch(filtered, { + keys: [ + { name: "name", weight: 2 }, + { name: "description", weight: 1 }, + ], + minMatchCharLength: 1, + threshold: 0.3, + })(parsed.text) + : filtered; + const sortedRes = sortItems(t, sort); + const finalList = applyFavorites(sortedRes, favorites, Boolean(q2)); + return finalList.map((row) => ({ + resourceType: row.resourceType, + url: row.url, + isFavorite: favorites.has(row.resourceType), + })); + }, + getFavorites: () => favoritesArray, + toggleFavorite, + navigateToResourceType: (resourceType) => { + navigate({ + to: "/resource/$resourceType", + params: { resourceType }, + }); + }, + }; + return ( -
      -
      - setFilterQuery(e.target.value)} - onKeyDown={handleSearchKeyDown} - rightSlot={ - filterQuery && ( - } - aria-label="Clear" - variant="link" - onClick={() => setFilterQuery("")} - /> - ) - } - /> - - Search - +
      +
      +
      + setSearchQ("")} + onInputKeyDown={handleKeyDown} + /> + +
      -
      - {!isLoading && filteredData.length === 0 ? ( + {!isLoading && items.length === 0 ? ( +
      - ) : ( - - )} -
      +
      + ) : ( +
        + {items.map((it, index) => ( + toggleFavorite(it.resourceType)} + focused={index === focusedIndex} + rowRef={index === focusedIndex ? focusedRowRef : undefined} + /> + ))} +
      + )}
      ); } diff --git a/src/routes/analytics.index.tsx b/src/routes/analytics.index.tsx index b8fc2bf..4de4641 100644 --- a/src/routes/analytics.index.tsx +++ b/src/routes/analytics.index.tsx @@ -17,6 +17,7 @@ import { import * as React from "react"; import { useAidboxClient } from "../AidboxClient"; import { EmptyState } from "../components/empty-state"; +import { buildQuery, parseQuery, tagSlug } from "../utils/tag-search"; type MatchRange = readonly [number, number]; @@ -169,28 +170,6 @@ function useRecentQueries() { type LookupByUrl = (url: string) => RecentItem | undefined; -function tagSlug(text: string): string { - return text.toLowerCase().replace(/\s+/g, "-"); -} - -function parseQuery(q: string): { chips: string[]; text: string } { - const tokens = q.split(/\s+/).filter(Boolean); - const chips: string[] = []; - const textTokens: string[] = []; - for (const t of tokens) { - if (t.startsWith("#") && t.length > 1) chips.push(t.slice(1)); - else textTokens.push(t); - } - return { chips, text: textTokens.join(" ") }; -} - -function buildQuery(chips: string[], text: string): string { - const chipStr = chips.map((c) => `#${c}`).join(" "); - const trimmedText = text.trim(); - if (chipStr && trimmedText) return `${chipStr} ${trimmedText}`; - return chipStr || trimmedText; -} - function getItemTagSlugs(item: RecentItem, lookup: LookupByUrl): string[] { const slugs: string[] = [tagSlug(item.label)]; if (item.resource) slugs.push(tagSlug(item.resource)); diff --git a/src/utils/tag-search.ts b/src/utils/tag-search.ts new file mode 100644 index 0000000..cfaee52 --- /dev/null +++ b/src/utils/tag-search.ts @@ -0,0 +1,21 @@ +export function tagSlug(text: string): string { + return text.toLowerCase().replace(/&/g, "&").replace(/\s+/g, "-"); +} + +export function parseQuery(q: string): { chips: string[]; text: string } { + const tokens = q.split(/\s+/).filter(Boolean); + const chips: string[] = []; + const textTokens: string[] = []; + for (const t of tokens) { + if (t.startsWith("#") && t.length > 1) chips.push(t.slice(1)); + else textTokens.push(t); + } + return { chips, text: textTokens.join(" ") }; +} + +export function buildQuery(chips: string[], text: string): string { + const chipStr = chips.map((c) => `#${c}`).join(" "); + const trimmedText = text.trim(); + if (chipStr && trimmedText) return `${chipStr} ${trimmedText}`; + return chipStr || trimmedText; +} From 73a4844765810c0e7b408ca2fd71a1a7f11c1ce6 Mon Sep 17 00:00:00 2001 From: Panthevm Date: Tue, 19 May 2026 17:09:08 +0300 Subject: [PATCH 3/4] Builder: URL history datalist + section expand/collapse fix - Move url-history.ts to shared utils, key as argument so VD and SQLQuery use separate stores - Add canonical URL datalist for ViewDefinition (matches SQLQuery) - Save URL to history on save success only, not on every input change - Fix expand/collapse: stopPropagation in label button to avoid double-toggle with TreeView's onItemLabelClick --- .../SQLQueryBuilder/builder-content.tsx | 6 +++-- .../SQLQueryBuilder/properties-tree.tsx | 13 +++++----- .../editor-form-tab-content.tsx | 25 ++++++++++++++++++- .../ViewDefinition/editor-panel-content.tsx | 5 ++++ .../SQLQueryBuilder => utils}/url-history.ts | 14 ++++++----- 5 files changed, 47 insertions(+), 16 deletions(-) rename src/{components/SQLQueryBuilder => utils}/url-history.ts (55%) diff --git a/src/components/SQLQueryBuilder/builder-content.tsx b/src/components/SQLQueryBuilder/builder-content.tsx index acd4cf9..949098a 100644 --- a/src/components/SQLQueryBuilder/builder-content.tsx +++ b/src/components/SQLQueryBuilder/builder-content.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { useAidboxClient } from "../../AidboxClient"; import * as Utils from "../../api/utils"; import { useLocalStorage } from "../../hooks"; +import { addUrlToHistory } from "../../utils/url-history"; import { useSQLQueryContext } from "./context"; import { EditorHeaderMenu } from "./header-menu"; import { PropertiesTree } from "./properties-tree"; @@ -12,7 +13,8 @@ import { useResolvedParameterTree } from "./resolve-tree"; import { ResultPanel } from "./result-panel"; import { buildRunPayload, ensureSQLQueryShape } from "./run-payload"; import type { SQLLibrary } from "./types"; -import { addUrlToHistory } from "./url-history"; + +const URL_HISTORY_KEY = "sqlquery-library-url-history"; function toOperationOutcome(err: unknown): HSComp.OperationOutcome { if ( @@ -178,7 +180,7 @@ export function SQLQueryBuilderContent() { }, onSuccess: ({ resource, created }) => { setIsDirty(false); - addUrlToHistory(library.url); + addUrlToHistory(URL_HISTORY_KEY, library.url); HSComp.toast.success("SQLQuery saved successfully", { position: "bottom-right", style: { margin: "1rem" }, diff --git a/src/components/SQLQueryBuilder/properties-tree.tsx b/src/components/SQLQueryBuilder/properties-tree.tsx index 0b3f824..463b2f4 100644 --- a/src/components/SQLQueryBuilder/properties-tree.tsx +++ b/src/components/SQLQueryBuilder/properties-tree.tsx @@ -7,6 +7,7 @@ import { import { AlignLeft, Play, Plus, X } from "lucide-react"; import * as React from "react"; import { format as formatSQL } from "sql-formatter"; +import { readUrlHistory } from "../../utils/url-history"; import { useSQLQueryContext } from "./context"; import { type ResolvedParameter, @@ -20,7 +21,8 @@ import { type SQLDependsOn, type SQLParameter, } from "./types"; -import { addUrlToHistory, readUrlHistory } from "./url-history"; + +const URL_HISTORY_KEY = "sqlquery-library-url-history"; type ItemMeta = { type: @@ -149,8 +151,9 @@ function labelView(item: ItemInstance>) { ? "text-text-info-primary px-1!" : "text-text-info-primary bg-bg-info-primary"; - const onLabelClickFn = () => { + const onLabelClickFn = (e: React.MouseEvent) => { if (!isFolder) return; + e.stopPropagation(); if (item.isExpanded()) item.collapse(); else item.expand(); }; @@ -337,10 +340,6 @@ export function PropertiesTree() { useSQLQueryContext(); const treeContainerRef = React.useRef(null); - React.useEffect(() => { - addUrlToHistory(library.url); - }, [library.url]); - React.useEffect(() => { const el = treeContainerRef.current; if (!el) return; @@ -851,7 +850,7 @@ export function PropertiesTree() { } }; - const urlHistory = readUrlHistory(); + const urlHistory = readUrlHistory(URL_HISTORY_KEY); return (
      diff --git a/src/components/ViewDefinition/editor-form-tab-content.tsx b/src/components/ViewDefinition/editor-form-tab-content.tsx index fa3084c..03c3c4b 100644 --- a/src/components/ViewDefinition/editor-form-tab-content.tsx +++ b/src/components/ViewDefinition/editor-form-tab-content.tsx @@ -44,6 +44,7 @@ import { import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useDebounce, useLocalStorage } from "../../hooks"; import { generateId } from "../../utils"; +import { readUrlHistory } from "../../utils/url-history"; import type { FormTreeSelectItem, ViewDefinitionBuilderActions, @@ -56,6 +57,8 @@ import { } from "./page"; import { ResourceTypeSelect } from "./resource-type-select"; +const URL_HISTORY_KEY = "viewdefinition-url-history"; + // --- De-identification extension support --- const DEIDENT_EXT_URL = @@ -630,11 +633,17 @@ const InputView = ({ className, value, onChange, + name, + autoComplete, + list, }: { placeholder: string; className?: string; value?: string; onChange?: (value: string) => void; + name?: string; + autoComplete?: string; + list?: string; }) => { const [localValue, setLocalValue] = useState(value || ""); @@ -675,6 +684,9 @@ const InputView = ({ `} placeholder={placeholder} value={localValue} + name={name} + autoComplete={autoComplete} + list={list} onChange={(e) => handleChange(e.target.value)} onClick={(e) => e.stopPropagation()} onMouseDown={handleMouseDown} @@ -977,6 +989,8 @@ export const FormTabContent = ({ defaultValue: [], }); + const urlHistory = readUrlHistory(URL_HISTORY_KEY); + const paramPrefix = /^Parameters\.parameter\[\d+\]\.resource\./; const fieldErrors = useMemo( @@ -1884,7 +1898,8 @@ export const FormTabContent = ({ const isAlwaysExpanded = metaType === "constant" || metaType === "where" || metaType === "select"; - const onLabelClickFn = () => { + const onLabelClickFn = (e: React.MouseEvent) => { + e.stopPropagation(); if (isAlwaysExpanded) return; if (item.isExpanded()) { item.collapse(); @@ -1955,6 +1970,9 @@ export const FormTabContent = ({ placeholder="Canonical URL of the ViewDefinition" value={viewDefinition?.url || ""} onChange={(value) => updateUrl(value)} + name="viewdefinition-url" + autoComplete="on" + list={URL_HISTORY_KEY} />
      @@ -2500,6 +2518,11 @@ export const FormTabContent = ({ return ( + + {urlHistory.map((u) => ( + >) => { const metaType = item.getItemData()?.meta?.type; diff --git a/src/components/ViewDefinition/editor-panel-content.tsx b/src/components/ViewDefinition/editor-panel-content.tsx index 763f543..6216419 100644 --- a/src/components/ViewDefinition/editor-panel-content.tsx +++ b/src/components/ViewDefinition/editor-panel-content.tsx @@ -23,6 +23,7 @@ import React from "react"; import { type AidboxClientR5, useAidboxClient } from "../../AidboxClient"; import * as Utils from "../../api/utils"; import { useLocalStorage } from "../../hooks"; +import { addUrlToHistory } from "../../utils/url-history"; import { useWebMCPViewDefinition } from "../../webmcp/view-definition"; import type { ViewDefinitionBuilderActions } from "../../webmcp/view-definition-context"; import { FormTabContent } from "./editor-form-tab-content"; @@ -34,6 +35,8 @@ import { import { ResultPanel } from "./result-panel-content"; import type * as Types from "./types"; +const URL_HISTORY_KEY = "viewdefinition-url-history"; + const cleanEmptyValues = (obj: T): T => { if (Array.isArray(obj)) { const cleanedArray = obj @@ -306,6 +309,7 @@ export const useViewDefinitionActions = ( viewDefinitionContext.setRunError(undefined); viewDefinitionContext.setIsDirty(false); invalidateSidebar(); + addUrlToHistory(URL_HISTORY_KEY, viewDefinitionResource?.url); HSComp.toast.success("ViewDefinition saved successfully", { position: "bottom-right", style: { margin: "1rem" }, @@ -332,6 +336,7 @@ export const useViewDefinitionActions = ( viewDefinitionContext.setRunError(undefined); viewDefinitionContext.setIsDirty(false); invalidateSidebar(); + addUrlToHistory(URL_HISTORY_KEY, result.value.resource.url); const id = result.value.resource.id; if (!id) return Utils.toastError( diff --git a/src/components/SQLQueryBuilder/url-history.ts b/src/utils/url-history.ts similarity index 55% rename from src/components/SQLQueryBuilder/url-history.ts rename to src/utils/url-history.ts index e943519..83d30c3 100644 --- a/src/components/SQLQueryBuilder/url-history.ts +++ b/src/utils/url-history.ts @@ -1,9 +1,8 @@ -const KEY = "sqlquery-library-url-history"; const MAX_ENTRIES = 20; -export function readUrlHistory(): string[] { +export function readUrlHistory(key: string): string[] { try { - const raw = window.localStorage.getItem(KEY); + const raw = window.localStorage.getItem(key); if (!raw) return []; const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { @@ -15,15 +14,18 @@ export function readUrlHistory(): string[] { return []; } -export function addUrlToHistory(url: string | undefined | null): void { +export function addUrlToHistory( + key: string, + url: string | undefined | null, +): void { if (!url) return; try { - const current = readUrlHistory(); + const current = readUrlHistory(key); const next = [url, ...current.filter((u) => u !== url)].slice( 0, MAX_ENTRIES, ); - window.localStorage.setItem(KEY, JSON.stringify(next)); + window.localStorage.setItem(key, JSON.stringify(next)); } catch { // ignore } From 186e035b5a1a40acfa7837ba695c4c9d3caa9be6 Mon Sep 17 00:00:00 2001 From: Panthevm Date: Tue, 19 May 2026 17:09:31 +0300 Subject: [PATCH 4/4] =?UTF-8?q?Analytics:=20list=20polish=20=E2=80=94=20em?= =?UTF-8?q?pty=20state=20CTA,=20tag=20URL=20format,=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EmptyState gets optional action slot; analytics empty state shows a Create button (single button or kind dropdown) and lives inside the list area so the header (search + Create) stays visible - Search refactor: tags moved out of ?q= into ?tags=[...] so original case/spaces are preserved in the URL; chip rendered as-is, slug normalization happens only for matching - Tag chip is now a button — click anywhere removes it (X remains as a visual cue) - Chip color resolution uses the combined views+queries list so badges remain colored on kind-filtered pages - Row padding pl-3 → pl-3.5; first/last row get rounded-t-lg / rounded-b-lg - Fuzzy-match highlight switched from text-text-link to font-medium for a subtler emphasis --- src/components/empty-state.tsx | 3 + src/routes/analytics.index.tsx | 350 +++++++++++++++---------- src/routes/analytics.queries.index.tsx | 19 +- src/routes/analytics.views.index.tsx | 19 +- 4 files changed, 250 insertions(+), 141 deletions(-) diff --git a/src/components/empty-state.tsx b/src/components/empty-state.tsx index 08aec19..8dd1271 100644 --- a/src/components/empty-state.tsx +++ b/src/components/empty-state.tsx @@ -4,10 +4,12 @@ export const EmptyState = ({ title, description, grayscale, + action, }: { title: ReactNode; description?: ReactNode; grayscale?: boolean; + action?: ReactNode; }) => { return (
      @@ -23,6 +25,7 @@ export const EmptyState = ({ {description} )}
      + {action}
      ); diff --git a/src/routes/analytics.index.tsx b/src/routes/analytics.index.tsx index 4de4641..301b7ed 100644 --- a/src/routes/analytics.index.tsx +++ b/src/routes/analytics.index.tsx @@ -17,7 +17,7 @@ import { import * as React from "react"; import { useAidboxClient } from "../AidboxClient"; import { EmptyState } from "../components/empty-state"; -import { buildQuery, parseQuery, tagSlug } from "../utils/tag-search"; +import { parseQuery, tagSlug } from "../utils/tag-search"; type MatchRange = readonly [number, number]; @@ -30,7 +30,7 @@ function highlight(text: string, ranges: readonly MatchRange[] | undefined) { if (start < cursor) return; if (start > cursor) parts.push(text.slice(cursor, start)); parts.push( - + {text.slice(start, end + 1)} , ); @@ -195,7 +195,8 @@ function filterByTags( }); } -function chipStyleFor(slug: string, items: RecentItem[]): string { +function chipStyleFor(tag: string, items: RecentItem[]): string { + const slug = tagSlug(tag); for (const item of items) { if (tagSlug(item.label) === slug) { return item.kind === "view" @@ -289,7 +290,7 @@ function ItemRow({ } } return ( -
      +
      {showKindLabel && (
      void; onTextChange: (next: string) => void; - onRemoveChip: (slug: string) => void; + onRemoveChip: (tag: string) => void; onClear: () => void; }) { return (
      {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, allItems)}`} > #{chip} - - + + ))} void; + tags: string[]; + text: string; + setTags: (next: string[]) => void; + setText: (next: string) => void; }) { const views = useRecentViews(); const queries = useRecentQueries(); @@ -488,9 +490,8 @@ export function AnalyticsListPage({ } return (url: string) => map.get(url); }, [views.data, queries.data]); - const { chips, text: textPart } = parseQuery(searchQ); - const tagTokens = chips.map((c) => c.toLowerCase()); - const textQuery = textPart; + const tagTokens = tags.map(tagSlug); + const textQuery = text; const tagFiltered = filterByTags(allItems, tagTokens, lookup); @@ -540,16 +541,9 @@ export function AnalyticsListPage({ ); } - if (allItems.length === 0 && !searchQ) { - const noun = - kind === "view" ? "view" : kind === "query" ? "query" : "view or query"; - return ( - - ); - } + const noun = + kind === "view" ? "view" : kind === "query" ? "query" : "view or query"; + const isEmpty = allItems.length === 0 && tags.length === 0 && !text; const placeholder = kind === "view" @@ -563,34 +557,48 @@ export function AnalyticsListPage({ navigate({ to: "/analytics/queries/create", search: QUERY_CREATE_SEARCH }); const handleTagClick = (tagText: string) => { const slug = tagSlug(tagText); - if (chips.includes(slug)) return; - setSearchQ(buildQuery([...chips, slug], textPart)); + if (tags.some((t) => tagSlug(t) === slug)) return; + setTags([...tags, tagText]); }; - const removeChip = (slug: string) => { - setSearchQ( - buildQuery( - chips.filter((c) => c !== slug), - textPart, - ), - ); + const removeChip = (tag: string) => { + setTags(tags.filter((t) => t !== tag)); }; const updateTextPart = (next: string) => { - setSearchQ(buildQuery(chips, next)); + const parsed = parseQuery(next); + if (parsed.chips.length > 0) { + const seen = new Set(tags.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([...tags, ...extra]); + setText(parsed.text); + } else { + setText(next); + } + }; + const onClear = () => { + setTags([]); + setText(""); }; return ( -
      -
      +
      +
      setSearchQ("")} + onClear={onClear} /> {kind === "view" ? ( @@ -630,89 +638,135 @@ export function AnalyticsListPage({ )}
      - {items.length === 0 ? ( -
      - Nothing matches “{searchQ}”. -
      - ) : ( -
        - {items.map((it) => { - const itemKey = `${it.kind}-${it.id}`; - const isMenuOpen = openMenuKey === itemKey; - return ( -
      • - {it.kind === "view" ? ( - - - - ) : ( - - - - )} -
        + {isEmpty ? ( + + + Create + + ) : kind === "query" ? ( + + + Create + + ) : ( + + + + + Create + + + + + + View + + + + Query + + + + ) + } + /> + ) : items.length === 0 ? ( +
        + Nothing matches “ + {[...tags.map((t) => `#${t}`), text].filter(Boolean).join(" ")}”. +
        + ) : ( +
          + {items.map((it) => { + const itemKey = `${it.kind}-${it.id}`; + const isMenuOpen = openMenuKey === itemKey; + return ( +
        • - setOpenMenuKey(o ? itemKey : null)} + {it.kind === "view" ? ( + + + + ) : ( + + + + )} +
          - - - - - openLineage(it)} - > - - Lineage - - setConfirmingDelete(it)} - > - - Delete - - - -
          -
        • - ); - })} -
        - )} + setOpenMenuKey(o ? itemKey : null)} + > + + + + + openLineage(it)} + > + + Lineage + + setConfirmingDelete(it)} + > + + Delete + + + + + + ); + })} + + )} + { @@ -752,18 +806,44 @@ export function AnalyticsListPage({ export const validateAnalyticsSearch = (search: { q?: unknown; -}): { q?: string } => - typeof search.q === "string" && search.q.length > 0 ? { q: search.q } : {}; + 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 AnalyticsHomeRoute() { - const { q: searchQ = "" } = Route.useSearch(); + const search = Route.useSearch(); + const text = search.q ?? ""; + const tags = search.tags ?? []; const navigate = useNavigate({ from: "/analytics/" }); - const setSearchQ = (next: string) => + const setText = (next: string) => navigate({ search: (prev) => ({ ...prev, q: next || undefined }), replace: true, }); - return ; + const setTags = (next: string[]) => + navigate({ + search: (prev) => ({ ...prev, tags: next.length > 0 ? next : undefined }), + replace: true, + }); + return ( + + ); } export const Route = createFileRoute("/analytics/")({ diff --git a/src/routes/analytics.queries.index.tsx b/src/routes/analytics.queries.index.tsx index b3f9e5e..8eca6d2 100644 --- a/src/routes/analytics.queries.index.tsx +++ b/src/routes/analytics.queries.index.tsx @@ -2,15 +2,28 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { AnalyticsListPage, validateAnalyticsSearch } from "./analytics.index"; function QueriesRoute() { - const { q: searchQ = "" } = Route.useSearch(); + const search = Route.useSearch(); + const text = search.q ?? ""; + const tags = search.tags ?? []; const navigate = useNavigate({ from: "/analytics/queries/" }); - const setSearchQ = (next: string) => + const setText = (next: string) => navigate({ search: (prev) => ({ ...prev, q: next || undefined }), replace: true, }); + const setTags = (next: string[]) => + navigate({ + search: (prev) => ({ ...prev, tags: next.length > 0 ? next : undefined }), + replace: true, + }); return ( - + ); } diff --git a/src/routes/analytics.views.index.tsx b/src/routes/analytics.views.index.tsx index 3daa7c9..c1173d3 100644 --- a/src/routes/analytics.views.index.tsx +++ b/src/routes/analytics.views.index.tsx @@ -2,15 +2,28 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { AnalyticsListPage, validateAnalyticsSearch } from "./analytics.index"; function ViewsRoute() { - const { q: searchQ = "" } = Route.useSearch(); + const search = Route.useSearch(); + const text = search.q ?? ""; + const tags = search.tags ?? []; const navigate = useNavigate({ from: "/analytics/views/" }); - const setSearchQ = (next: string) => + const setText = (next: string) => navigate({ search: (prev) => ({ ...prev, q: next || undefined }), replace: true, }); + const setTags = (next: string[]) => + navigate({ + search: (prev) => ({ ...prev, tags: next.length > 0 ? next : undefined }), + replace: true, + }); return ( - + ); }