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}”.
+
+ ) : (
+
+ )}
+
{
+ 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,
});