+
{highlight(item.description, item.descriptionMatches)}
)}
@@ -261,14 +329,79 @@ 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: (tag: string) => void;
+ onClear: () => void;
+}) {
+ return (
+
+
+ {chips.map((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) && (
+ }
+ />
+ )}
+
+ );
+}
+
+// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: list page combines search/filter/grouping logic
export function AnalyticsListPage({
kind,
- searchQ,
- setSearchQ,
+ tags,
+ text,
+ setTags,
+ setText,
}: {
kind?: AnalyticsListKind;
- searchQ: string;
- setSearchQ: (next: string) => void;
+ tags: string[];
+ text: string;
+ setTags: (next: string[]) => void;
+ setText: (next: string) => void;
}) {
const views = useRecentViews();
const queries = useRecentQueries();
@@ -357,18 +490,24 @@ export function AnalyticsListPage({
}
return (url: string) => map.get(url);
}, [views.data, queries.data]);
- const needle = searchQ.trim();
+ const tagTokens = tags.map(tagSlug);
+ const textQuery = text;
+
+ 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 +529,7 @@ export function AnalyticsListPage({
),
};
})
- : allItems;
+ : tagFiltered;
if (loading) {
return (
@@ -402,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"
@@ -423,21 +555,51 @@ 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 (tags.some((t) => tagSlug(t) === slug)) return;
+ setTags([...tags, tagText]);
+ };
+ const removeChip = (tag: string) => {
+ setTags(tags.filter((t) => t !== tag));
+ };
+ const updateTextPart = (next: string) => {
+ 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(e.target.value)}
- placeholder={placeholder}
- className="flex-1 bg-transparent outline-none typo-body text-text-primary placeholder:text-text-tertiary"
- />
-
+
+
+
+
{kind === "view" ? (
@@ -476,79 +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
+
+
+
+
+
+ );
+ })}
+
+ )}
+
{
@@ -588,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 (
-
+
);
}
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;
+}
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
}