From de735770ae275765eb131866359fe2c8a79a68c4 Mon Sep 17 00:00:00 2001 From: Panthevm Date: Mon, 18 May 2026 17:40:07 +0300 Subject: [PATCH] Analytics: recents list page - New unified Recents list on /analytics, /analytics/views, /analytics/queries with fuzzy search (URL-synced via ?q=...), highlighted matches, kind label, description (line-clamp-1), and hashtag tags for resource type and related artifacts. - Sticky search input + Create button (dropdown on /analytics, direct on filtered routes). Delete action uses an AlertDialog confirm. - ResourcePicker (SQL Query Builder depends-on) reuses the same item layout, is sorted by createdAt, and has a wider popover. - DataLineage sidebar shows skeleton rows on subsequent loads using a count cached in localStorage. - Query accent color switched to text-text-warning-primary (Lineage nodes, detail panel, list, picker). - Fix unsaved-changes blocker firing on resource delete: both ResourceEditor paths now dispatch an aidbox-resource-deleted event; SQLQueryBuilder latches its dirty flag off in response. - Edit routes drop unused page/pageSize search params. --- src/components/DataLineage/sidebar.tsx | 160 +++-- src/components/ResourceEditor/action.tsx | 1 + src/components/ResourceEditor/page.tsx | 2 + .../SQLQueryBuilder/lineage/detail-panel.tsx | 2 +- .../SQLQueryBuilder/lineage/nodes.tsx | 4 +- src/components/SQLQueryBuilder/page.tsx | 12 +- .../SQLQueryBuilder/resource-picker.tsx | 174 +++-- src/routes/analytics.index.tsx | 607 +++++++++++++++++- src/routes/analytics.queries.edit.$id.tsx | 2 +- src/routes/analytics.queries.index.tsx | 21 +- src/routes/analytics.views.edit.$id.tsx | 2 +- src/routes/analytics.views.index.tsx | 21 +- 12 files changed, 899 insertions(+), 109 deletions(-) diff --git a/src/components/DataLineage/sidebar.tsx b/src/components/DataLineage/sidebar.tsx index 3e78786..77d5abf 100644 --- a/src/components/DataLineage/sidebar.tsx +++ b/src/components/DataLineage/sidebar.tsx @@ -42,6 +42,40 @@ function pickLabel(r: { id?: string; name?: string; title?: string }): string { return r.title || r.name || r.id || "(unnamed)"; } +const SKELETON_WIDTHS = [ + "w-32", + "w-40", + "w-28", + "w-44", + "w-36", + "w-24", + "w-48", + "w-32", +]; + +function SkeletonRows({ count }: { count: number }) { + const rows = React.useMemo( + () => + Array.from({ length: count }, () => ({ + id: crypto.randomUUID(), + width: + SKELETON_WIDTHS[Math.floor(Math.random() * SKELETON_WIDTHS.length)], + })), + [count], + ); + return ( + <> + {rows.map((r) => ( + +
+ +
+
+ ))} + + ); +} + function useViewItems() { const client = useAidboxClient(); return useQuery({ @@ -133,12 +167,16 @@ function ViewsSection({ currentPath, currentTab, items, + loading, + skeletonCount, }: { open: boolean; onOpenChange: (v: boolean) => void; currentPath: string; currentTab: string | undefined; items: SidebarItem[]; + loading: boolean; + skeletonCount: number; }) { const editSearch = { tab: mapToViewTab(currentTab), @@ -180,35 +218,39 @@ function ViewsSection({ - {items.length === 0 && ( + {loading && skeletonCount > 0 && ( + + )} + {!loading && items.length === 0 && (
No views
)} - {items.map((it) => { - const active = currentPath === `/analytics/views/edit/${it.id}`; - return ( - - - - { + const active = currentPath === `/analytics/views/edit/${it.id}`; + return ( + + + - {it.label} - - - - - ); - })} + + {it.label} + + + + + ); + })}
@@ -222,12 +264,16 @@ function QueriesSection({ currentPath, currentTab, items, + loading, + skeletonCount, }: { open: boolean; onOpenChange: (v: boolean) => void; currentPath: string; currentTab: string | undefined; items: SidebarItem[]; + loading: boolean; + skeletonCount: number; }) { const editSearch = { tab: mapToQueryTab(currentTab), @@ -269,35 +315,40 @@ function QueriesSection({ - {items.length === 0 && ( + {loading && skeletonCount > 0 && ( + + )} + {!loading && items.length === 0 && (
No queries
)} - {items.map((it) => { - const active = currentPath === `/analytics/queries/edit/${it.id}`; - return ( - - - - { + const active = + currentPath === `/analytics/queries/edit/${it.id}`; + return ( + + + - {it.label} - - - - - ); - })} + + {it.label} + + + + + ); + })}
@@ -323,8 +374,25 @@ export function DataLineageSidebar() { defaultValue: true, getInitialValueInEffect: false, }); + const [viewsCount, setViewsCount] = useLocalStorage({ + key: "data-lineage-sidebar:views-count", + defaultValue: null, + getInitialValueInEffect: false, + }); + const [queriesCount, setQueriesCount] = useLocalStorage({ + key: "data-lineage-sidebar:queries-count", + defaultValue: null, + getInitialValueInEffect: false, + }); const [searchQ, setSearchQ] = React.useState(""); + React.useEffect(() => { + if (views.data) setViewsCount(views.data.length); + }, [views.data, setViewsCount]); + React.useEffect(() => { + if (queries.data) setQueriesCount(queries.data.length); + }, [queries.data, setQueriesCount]); + const filteredViews = filterItems(views.data ?? [], searchQ); const filteredQueries = filterItems(queries.data ?? [], searchQ); @@ -346,6 +414,8 @@ export function DataLineageSidebar() { currentPath={currentPath} currentTab={currentTab} items={filteredViews} + loading={views.isLoading} + skeletonCount={viewsCount ?? 0} /> diff --git a/src/components/ResourceEditor/action.tsx b/src/components/ResourceEditor/action.tsx index 323b317..aa8781e 100644 --- a/src/components/ResourceEditor/action.tsx +++ b/src/components/ResourceEditor/action.tsx @@ -135,6 +135,7 @@ export const DeleteButton = ({ onSuccess: (_resource, _variables, _onMutateResult, _context) => { HSComp.toast.success("Saved", defaultToastPlacement); invalidateDataLineageSidebar(queryClient, resourceType); + window.dispatchEvent(new Event("aidbox-resource-deleted")); if (onDeleted) { onDeleted(); return; diff --git a/src/components/ResourceEditor/page.tsx b/src/components/ResourceEditor/page.tsx index 06f84d6..d4948b8 100644 --- a/src/components/ResourceEditor/page.tsx +++ b/src/components/ResourceEditor/page.tsx @@ -326,6 +326,8 @@ export const ResourceEditorPage = ({ }; try { await deleteResource(client, resourceType, id); + setEditDirty(false); + window.dispatchEvent(new Event("aidbox-resource-deleted")); if (onDeleted) { onDeleted(); } else { diff --git a/src/components/SQLQueryBuilder/lineage/detail-panel.tsx b/src/components/SQLQueryBuilder/lineage/detail-panel.tsx index 2ef1b03..1bab5e3 100644 --- a/src/components/SQLQueryBuilder/lineage/detail-panel.tsx +++ b/src/components/SQLQueryBuilder/lineage/detail-panel.tsx @@ -461,7 +461,7 @@ export function LineageDetailPanel({ } label="Query" - color="text-text-brand-primary" + color="text-text-warning-primary" onClose={onClose} />
diff --git a/src/components/SQLQueryBuilder/lineage/nodes.tsx b/src/components/SQLQueryBuilder/lineage/nodes.tsx index dc95aae..fe0800d 100644 --- a/src/components/SQLQueryBuilder/lineage/nodes.tsx +++ b/src/components/SQLQueryBuilder/lineage/nodes.tsx @@ -257,8 +257,8 @@ export function SQLQueryNode({ id, data, selected }: AnyNodeProps) { const headerInner = ( <>
- - + + Query
diff --git a/src/components/SQLQueryBuilder/page.tsx b/src/components/SQLQueryBuilder/page.tsx index 21a25de..83d0953 100644 --- a/src/components/SQLQueryBuilder/page.tsx +++ b/src/components/SQLQueryBuilder/page.tsx @@ -45,7 +45,9 @@ export function SQLQueryProvider({ const [baselineHash, setBaselineHash] = React.useState(() => computeLibraryHash(initialResource as SQLLibrary), ); - const isDirty = computeLibraryHash(library) !== baselineHash; + const isDeletedRef = React.useRef(false); + const isDirty = + !isDeletedRef.current && computeLibraryHash(library) !== baselineHash; const isDirtyRef = React.useRef(false); isDirtyRef.current = isDirty; @@ -55,6 +57,14 @@ export function SQLQueryProvider({ isDirtyRef.current = false; } }, []); + React.useEffect(() => { + const handler = () => { + isDeletedRef.current = true; + isDirtyRef.current = false; + }; + window.addEventListener("aidbox-resource-deleted", handler); + return () => window.removeEventListener("aidbox-resource-deleted", handler); + }, []); const [runResult, setRunResult] = React.useState(null); const [runError, setRunError] = React.useState(null); diff --git a/src/components/SQLQueryBuilder/resource-picker.tsx b/src/components/SQLQueryBuilder/resource-picker.tsx index 48d90b4..af335c9 100644 --- a/src/components/SQLQueryBuilder/resource-picker.tsx +++ b/src/components/SQLQueryBuilder/resource-picker.tsx @@ -1,13 +1,15 @@ import type { Bundle } from "@aidbox-ui/fhir-types/hl7-fhir-r5-core"; import * as HSComp from "@health-samurai/react-components"; import { useQuery } from "@tanstack/react-query"; -import { ChevronDown, Info } from "lucide-react"; +import { ChevronDown, FileCode2, Info, Table } from "lucide-react"; import * as React from "react"; import { useAidboxClient } from "../../AidboxClient"; import { SQL_QUERY_TYPE_CODE, SQL_QUERY_TYPE_SYSTEM } from "./types"; type CandidateKind = "ViewDefinition" | "SQLQuery"; +type RelatedArtifactRef = { url: string; label?: string }; + type CandidateOption = { url: string; kind: CandidateKind; @@ -15,6 +17,9 @@ type CandidateOption = { name?: string; title?: string; description?: string; + resource?: string; + relatedArtifacts?: RelatedArtifactRef[]; + createdAt?: string; }; type RawCandidate = { @@ -23,8 +28,37 @@ type RawCandidate = { name?: string; title?: string; description?: string; + resource?: string; + relatedArtifact?: Array<{ + type?: string; + label?: string; + resource?: string; + }>; + meta?: { + lastUpdated?: string; + extension?: Array<{ url?: string; valueInstant?: string }>; + }; }; +function extractCreatedAt(r: RawCandidate): string | undefined { + const ext = r.meta?.extension; + if (!ext) return undefined; + for (const e of ext) { + if (e.valueInstant) return e.valueInstant; + } + return undefined; +} + +function Badge({ text, accentClass }: { text: string; accentClass: string }) { + return ( + + #{text} + + ); +} + const SQL_QUERY_TYPE_TOKEN = `${SQL_QUERY_TYPE_SYSTEM}|${SQL_QUERY_TYPE_CODE}`; function useDebouncedValue(value: T, delay: number): T { @@ -36,15 +70,17 @@ function useDebouncedValue(value: T, delay: number): T { return v; } -function useCandidates(search: string) { +function useCandidates(search: string, enabled = true) { const client = useAidboxClient(); const debouncedSearch = useDebouncedValue(search, 200); return useQuery({ queryKey: ["sqlquery-depends-on-candidates", debouncedSearch], + enabled, queryFn: async () => { const baseParams: Array<[string, string]> = [ - ["_count", "30"], + ["_count", "1000"], ["url:missing", "false"], + ["_sort", "-_createdAt"], ]; if (debouncedSearch) baseParams.push(["_ilike", debouncedSearch]); const [vd, lib] = await Promise.all([ @@ -70,12 +106,17 @@ function useCandidates(search: string) { name: r.name, title: r.title, description: r.description, + resource: r.resource, + createdAt: extractCreatedAt(r), }); } } if (lib.isOk()) { for (const entry of lib.value.resource.entry ?? []) { const r = entry.resource as unknown as RawCandidate; + const relatedArtifacts = (r.relatedArtifact ?? []).flatMap((ra) => + ra.resource ? [{ url: ra.resource, label: ra.label }] : [], + ); out.push({ url: r.url, kind: "SQLQuery", @@ -83,9 +124,17 @@ function useCandidates(search: string) { name: r.name, title: r.title, description: r.description, + relatedArtifacts: + relatedArtifacts.length > 0 ? relatedArtifacts : undefined, + createdAt: extractCreatedAt(r), }); } } + out.sort((a, b) => { + const da = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const db = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return db - da; + }); return out; }, }); @@ -102,8 +151,15 @@ export function ResourcePicker({ }) { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(""); - const { data: candidates = [] } = useCandidates(open ? search : ""); + const { data: candidates = [] } = useCandidates(search, open); + const { data: allCandidates = [] } = useCandidates("", open); const commandRef = React.useRef(null); + const lookupByUrl = React.useMemo(() => { + const map = new Map(); + for (const c of allCandidates) map.set(c.url, c); + for (const c of candidates) map.set(c.url, c); + return (url: string) => map.get(url); + }, [allCandidates, candidates]); React.useEffect(() => { if (!open) return; @@ -131,7 +187,7 @@ export function ResourcePicker({ - +
- + -
- +
+ No matches -
- +
+ Only ViewDefinitions and SQLQueries with a defined{" "} - url{" "} + url{" "} are listed — references rely on canonical URLs.
-
- {candidates.map((c) => { - const label = c.title || c.name || c.id; - const secondary = c.description || c.url; - return ( - { - onChange(c.url); - setOpen(false); - }} - > -
- - {c.kind} - - {label} - - {secondary} - -
-
+ {candidates.map((c) => { + const label = c.title || c.name || c.id; + const isView = c.kind === "ViewDefinition"; + const Icon = isView ? Table : FileCode2; + const kindLabel = isView ? "View" : "Query"; + const accentClass = isView + ? "text-text-info-primary" + : "text-text-warning-primary"; + const badges: React.ReactNode[] = []; + if (isView && c.resource) { + badges.push( + , ); - })} -
+ } + if (!isView && c.relatedArtifacts) { + for (const ra of c.relatedArtifacts) { + const linked = lookupByUrl(ra.url); + const isLinkedView = + linked?.kind === "ViewDefinition" || + ra.url.includes("/ViewDefinition/"); + badges.push( + , + ); + } + } + return ( + { + onChange(c.url); + setOpen(false); + }} + > +
+
+ + {kindLabel} +
+
+ {label} +
+ {c.description && ( +
+ {c.description} +
+ )} + {badges.length > 0 && ( +
+ {badges} +
+ )} +
+
+ ); + })}
diff --git a/src/routes/analytics.index.tsx b/src/routes/analytics.index.tsx index 73c0137..ac8c01c 100644 --- a/src/routes/analytics.index.tsx +++ b/src/routes/analytics.index.tsx @@ -1,15 +1,608 @@ -import { createFileRoute } from "@tanstack/react-router"; +import type { Bundle } from "@aidbox-ui/fhir-types/hl7-fhir-r5-core"; +import type { ViewDefinition } from "@aidbox-ui/fhir-types/org-sql-on-fhir-ig"; +import * as HSComp from "@health-samurai/react-components"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import Fuse from "fuse.js"; +import { + EllipsisVertical, + FileCode2, + GitBranch, + Plus, + Search, + Table, + Trash2, +} from "lucide-react"; +import * as React from "react"; +import { useAidboxClient } from "../AidboxClient"; import { EmptyState } from "../components/empty-state"; -function DataLineageIndex() { +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"; + +type LibraryResource = { + resourceType: "Library"; + id?: string; + name?: string; + title?: string; + description?: string; + url?: string; + meta?: { lastUpdated?: string }; + relatedArtifact?: Array<{ + type?: string; + label?: string; + resource?: string; + }>; +}; + +type RelatedArtifactRef = { + url: string; + label?: string; +}; + +type RecentItem = { + kind: "view" | "query"; + id: string; + url?: string; + resource?: string; + relatedArtifacts?: RelatedArtifactRef[]; + label: string; + description?: string; + lastUpdated?: string; + labelMatches?: readonly MatchRange[]; + descriptionMatches?: readonly MatchRange[]; +}; + +const VIEW_EDIT_SEARCH = { + tab: "builder" as const, + mode: "json" as const, + builderTab: "form" as const, +}; +const QUERY_EDIT_SEARCH = { + tab: "sqlquery" as const, + mode: "json" as const, + builderTab: "form" as const, +}; + +function pickLabel(r: { id?: string; name?: string; title?: string }): string { + return r.title || r.name || r.id || "(unnamed)"; +} + +function useRecentViews() { + const client = useAidboxClient(); + return useQuery({ + queryKey: ["analytics-recent-views"], + queryFn: async () => { + const r = await client.request({ + method: "GET", + url: "/fhir/ViewDefinition", + params: [ + ["_count", "1000"], + ["_sort", "-_lastUpdated"], + ], + }); + if (r.isErr()) return []; + return (r.value.resource.entry ?? []).flatMap((e) => { + const vd = e.resource as + | (ViewDefinition & { + description?: string; + url?: string; + meta?: { lastUpdated?: string }; + }) + | undefined; + if (!vd?.id) return []; + return [ + { + kind: "view", + id: vd.id, + url: vd.url, + resource: vd.resource, + label: pickLabel(vd), + description: vd.description, + lastUpdated: vd.meta?.lastUpdated, + } satisfies RecentItem, + ]; + }); + }, + }); +} + +function useRecentQueries() { + const client = useAidboxClient(); + return useQuery({ + queryKey: ["analytics-recent-queries"], + queryFn: async () => { + const r = await client.request({ + method: "GET", + url: "/fhir/Library", + params: [ + ["_count", "1000"], + ["_sort", "-_lastUpdated"], + ["type", SQL_QUERY_TYPE_TOKEN], + ], + }); + if (r.isErr()) return []; + return (r.value.resource.entry ?? []).flatMap((e) => { + const lib = e.resource as LibraryResource | undefined; + if (!lib?.id) return []; + const relatedArtifacts = (lib.relatedArtifact ?? []).flatMap((ra) => + ra.resource ? [{ url: ra.resource, label: ra.label }] : [], + ); + return [ + { + kind: "query", + id: lib.id, + url: lib.url, + relatedArtifacts: + relatedArtifacts.length > 0 ? relatedArtifacts : undefined, + label: pickLabel(lib), + description: lib.description, + lastUpdated: lib.meta?.lastUpdated, + } satisfies RecentItem, + ]; + }); + }, + }); +} + +type LookupByUrl = (url: string) => RecentItem | undefined; + +function Badge({ text, accentClass }: { text: string; accentClass: string }) { + return ( + + #{text} + + ); +} + +function ItemRow({ + item, + lookup, + showKindLabel = true, +}: { + item: RecentItem; + lookup: LookupByUrl; + showKindLabel?: boolean; +}) { + const isView = item.kind === "view"; + const Icon = isView ? Table : FileCode2; + const kindLabel = isView ? "View" : "Query"; + const accentClass = isView + ? "text-text-info-primary" + : "text-text-warning-primary"; + const badges: React.ReactNode[] = []; + if (isView && item.resource) { + badges.push( + , + ); + } + if (!isView && item.relatedArtifacts) { + for (const ra of item.relatedArtifacts) { + const linked = lookup(ra.url); + const isLinkedView = + linked?.kind === "view" || ra.url.includes("/ViewDefinition/"); + badges.push( + , + ); + } + } return ( - +
+ {showKindLabel && ( +
+ + {kindLabel} +
+ )} +
+ {highlight(item.label, item.labelMatches)} +
+ {item.description && ( +
+ {highlight(item.description, item.descriptionMatches)} +
+ )} + {badges.length > 0 && ( +
{badges}
+ )} +
); } +export type AnalyticsListKind = "view" | "query"; + +const VIEW_CREATE_SEARCH = { + tab: "builder" as const, + mode: "json" as const, + builderTab: "form" as const, +}; +const QUERY_CREATE_SEARCH = { + tab: "sqlquery" as const, + mode: "json" as const, + builderTab: "form" as const, +}; + +export function AnalyticsListPage({ + kind, + searchQ, + setSearchQ, +}: { + kind?: AnalyticsListKind; + searchQ: string; + setSearchQ: (next: string) => void; +}) { + const views = useRecentViews(); + const queries = useRecentQueries(); + const client = useAidboxClient(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const deleteMutation = useMutation({ + mutationFn: async (item: RecentItem) => { + const url = + item.kind === "view" + ? `/fhir/ViewDefinition/${item.id}` + : `/fhir/Library/${item.id}`; + const r = await client.request({ method: "DELETE", url }); + if (r.isErr()) throw new Error("Delete failed"); + return r.value; + }, + onSuccess: (_data, item) => { + queryClient.invalidateQueries({ + queryKey: [ + item.kind === "view" + ? "analytics-recent-views" + : "analytics-recent-queries", + ], + }); + HSComp.toast.success( + `${item.kind === "view" ? "View" : "Query"} deleted`, + ); + }, + onError: () => { + HSComp.toast.error("Failed to delete"); + }, + }); + const [openMenuKey, setOpenMenuKey] = React.useState(null); + const [confirmingDelete, setConfirmingDelete] = + React.useState(null); + const openLineage = (item: RecentItem) => { + if (item.kind === "view") { + navigate({ + to: "/analytics/views/edit/$id", + params: { id: item.id }, + search: { tab: "lineage", mode: "json", builderTab: "form" }, + }); + } else { + navigate({ + to: "/analytics/queries/edit/$id", + params: { id: item.id }, + search: { tab: "lineage", mode: "json", builderTab: "form" }, + }); + } + }; + const didFocus = React.useRef(false); + const setSearchInputRef = React.useCallback((el: HTMLInputElement | null) => { + if (el && !didFocus.current) { + el.focus(); + didFocus.current = true; + } + }, []); + + const loading = + kind === "view" + ? views.isLoading + : kind === "query" + ? queries.isLoading + : views.isLoading || queries.isLoading; + const combined: RecentItem[] = [ + ...(views.data ?? []), + ...(queries.data ?? []), + ]; + const allItems: RecentItem[] = ( + kind === "view" + ? (views.data ?? []) + : kind === "query" + ? (queries.data ?? []) + : combined + ) + .slice() + .sort((a, b) => { + const da = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0; + const db = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0; + return db - da; + }); + const lookup: LookupByUrl = React.useMemo(() => { + const map = new Map(); + for (const it of [...(views.data ?? []), ...(queries.data ?? [])]) { + if (it.url) map.set(it.url, it); + } + return (url: string) => map.get(url); + }, [views.data, queries.data]); + const needle = searchQ.trim(); + const fuse = React.useMemo( + () => + new Fuse(allItems, { + keys: ["label", "description"], + includeMatches: true, + threshold: 0.3, + ignoreLocation: true, + minMatchCharLength: 1, + }), + [allItems], + ); + 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( + labelMatch?.indices as readonly MatchRange[] | undefined, + ), + descriptionMatches: filterRanges( + descriptionMatch?.indices as readonly MatchRange[] | undefined, + ), + }; + }) + : allItems; + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (allItems.length === 0 && !searchQ) { + const noun = + kind === "view" ? "view" : kind === "query" ? "query" : "view or query"; + return ( + + ); + } + + const placeholder = + kind === "view" + ? "Search views by name or description…" + : kind === "query" + ? "Search queries by name or description…" + : "Search by name or description…"; + const createView = () => + navigate({ to: "/analytics/views/create", search: VIEW_CREATE_SEARCH }); + const createQuery = () => + navigate({ to: "/analytics/queries/create", search: QUERY_CREATE_SEARCH }); + + return ( +
+
+
+
+ + setSearchQ(e.target.value)} + placeholder={placeholder} + className="flex-1 bg-transparent outline-none typo-body text-text-primary placeholder:text-text-tertiary" + /> +
+ {kind === "view" ? ( + + + Create + + ) : kind === "query" ? ( + + + Create + + ) : ( + + + + + Create + + + + + + View + + + + Query + + + + )} + + + {items.length === 0 ? ( +
+ Nothing matches “{searchQ}”. +
+ ) : ( +
    + {items.map((it) => { + const itemKey = `${it.kind}-${it.id}`; + const isMenuOpen = openMenuKey === itemKey; + return ( +
  • + {it.kind === "view" ? ( + + + + ) : ( + + + + )} +
    + setOpenMenuKey(o ? itemKey : null)} + > + + + + + openLineage(it)} + > + + Lineage + + setConfirmingDelete(it)} + > + + Delete + + + +
    +
  • + ); + })} +
+ )} + { + if (!o) setConfirmingDelete(null); + }} + > + + + + Delete {confirmingDelete?.kind === "view" ? "view" : "query"} + + + + Are you sure you want to delete{" "} + + {confirmingDelete?.label} + + ? This action cannot be undone. + + + Cancel + { + if (confirmingDelete) deleteMutation.mutate(confirmingDelete); + setConfirmingDelete(null); + }} + > + Delete + + + + + + ); +} + +export const validateAnalyticsSearch = (search: { + q?: unknown; +}): { q?: string } => + typeof search.q === "string" && search.q.length > 0 ? { q: search.q } : {}; + +function AnalyticsHomeRoute() { + const { q: searchQ = "" } = Route.useSearch(); + const navigate = useNavigate({ from: "/analytics/" }); + const setSearchQ = (next: string) => + navigate({ + search: (prev) => ({ ...prev, q: next || undefined }), + replace: true, + }); + return ; +} + export const Route = createFileRoute("/analytics/")({ - component: DataLineageIndex, + component: AnalyticsHomeRoute, + validateSearch: validateAnalyticsSearch, }); diff --git a/src/routes/analytics.queries.edit.$id.tsx b/src/routes/analytics.queries.edit.$id.tsx index 86ad4f9..8859039 100644 --- a/src/routes/analytics.queries.edit.$id.tsx +++ b/src/routes/analytics.queries.edit.$id.tsx @@ -22,7 +22,7 @@ const PageComponent = () => { onDeleted={() => navigate({ to: "/analytics/queries", - search: { q: undefined, page: undefined, pageSize: undefined }, + search: { q: undefined }, }) } /> diff --git a/src/routes/analytics.queries.index.tsx b/src/routes/analytics.queries.index.tsx index ad8807b..b3f9e5e 100644 --- a/src/routes/analytics.queries.index.tsx +++ b/src/routes/analytics.queries.index.tsx @@ -1,16 +1,21 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { EmptyState } from "../components/empty-state"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { AnalyticsListPage, validateAnalyticsSearch } from "./analytics.index"; -function QueriesPlaceholder() { +function QueriesRoute() { + const { q: searchQ = "" } = Route.useSearch(); + const navigate = useNavigate({ from: "/analytics/queries/" }); + const setSearchQ = (next: string) => + navigate({ + search: (prev) => ({ ...prev, q: next || undefined }), + replace: true, + }); return ( - + ); } export const Route = createFileRoute("/analytics/queries/")({ staticData: { title: "Queries" }, - component: QueriesPlaceholder, + component: QueriesRoute, + validateSearch: validateAnalyticsSearch, }); diff --git a/src/routes/analytics.views.edit.$id.tsx b/src/routes/analytics.views.edit.$id.tsx index c31d069..eaa9ba9 100644 --- a/src/routes/analytics.views.edit.$id.tsx +++ b/src/routes/analytics.views.edit.$id.tsx @@ -22,7 +22,7 @@ const PageComponent = () => { onDeleted={() => navigate({ to: "/analytics/views", - search: { q: undefined, page: undefined, pageSize: undefined }, + search: { q: undefined }, }) } /> diff --git a/src/routes/analytics.views.index.tsx b/src/routes/analytics.views.index.tsx index 7e76e26..3daa7c9 100644 --- a/src/routes/analytics.views.index.tsx +++ b/src/routes/analytics.views.index.tsx @@ -1,16 +1,21 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { EmptyState } from "../components/empty-state"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { AnalyticsListPage, validateAnalyticsSearch } from "./analytics.index"; -function ViewsPlaceholder() { +function ViewsRoute() { + const { q: searchQ = "" } = Route.useSearch(); + const navigate = useNavigate({ from: "/analytics/views/" }); + const setSearchQ = (next: string) => + navigate({ + search: (prev) => ({ ...prev, q: next || undefined }), + replace: true, + }); return ( - + ); } export const Route = createFileRoute("/analytics/views/")({ staticData: { title: "Views" }, - component: ViewsPlaceholder, + component: ViewsRoute, + validateSearch: validateAnalyticsSearch, });