diff --git a/web/frontend/src/pages/Search.tsx b/web/frontend/src/pages/Search.tsx index 7e81baab..8c513e5d 100644 --- a/web/frontend/src/pages/Search.tsx +++ b/web/frontend/src/pages/Search.tsx @@ -1,30 +1,122 @@ import { useMutation } from "@tanstack/react-query"; -import { Search as SearchIcon } from "lucide-react"; +import { + AppWindow, + Code2, + CornerDownLeft, + Database, + Search as SearchIcon, + Settings2, + Table2, + TriangleAlert, + Workflow, +} from "lucide-react"; import { useEffect, useState } from "react"; import { api } from "../api/client"; -import { ErrorBox, PageTitle } from "../components/Empty"; +import { ErrorBox, Loading, PageTitle } from "../components/Empty"; import { DataTable } from "../components/Table"; import { useUIState } from "../state"; +interface SearchResult { + project_alias: string; + /** Raw Storage API type: bucket | table | flow | transformation | configuration | configuration-row. */ + type: string; + name: string; + id: string; + component_id?: string | null; + bucket_id?: string; + description?: string; + /** Table results matched via a column name carry the matching columns. */ + matched_columns?: string[]; +} + interface SearchResp { - results: Array<{ - project_alias: string; - type: string; - name: string; - id: string; - component_id?: string; - bucket_id?: string; - description?: string; - }>; + results: SearchResult[]; + /** Per-project failures (e.g. FEATURE_NOT_ENABLED) -- the fan-out never aborts. */ + errors?: Array<{ project_alias: string; error_code?: string; message: string }>; stats: { results_found: number; projects_searched: number }; } +type SearchMode = "textual" | "config-based"; + +/** Filter values understood by the API's `type` param, in reading order. */ +const TYPE_FILTERS: Array<{ + value: string; + label: string; + icon: React.ComponentType<{ className?: string }>; +}> = [ + { value: "table", label: "tables", icon: Table2 }, + { value: "bucket", label: "buckets", icon: Database }, + { value: "config", label: "configs", icon: Settings2 }, + { value: "flow", label: "flows", icon: Workflow }, + { value: "transformation", label: "transformations", icon: Code2 }, + { value: "data-app", label: "data apps", icon: AppWindow }, +]; + +/** + * Presentation for the RAW result type the API returns. Filter names and + * result types are different vocabularies: a `data-app` filter comes back as + * `configuration` whose component is keboola.data-apps, so that special case + * is resolved in `resultKind` below rather than in this table. + */ +const RESULT_KINDS: Record< + string, + { label: string; icon: React.ComponentType<{ className?: string }> } +> = { + bucket: { label: "bucket", icon: Database }, + table: { label: "table", icon: Table2 }, + flow: { label: "flow", icon: Workflow }, + transformation: { label: "transformation", icon: Code2 }, + configuration: { label: "config", icon: Settings2 }, + "configuration-row": { label: "config row", icon: Settings2 }, +}; + +const DATA_APP_COMPONENT_ID = "keboola.data-apps"; + +function resultKind(r: SearchResult): { label: string; icon: React.ComponentType<{ className?: string }> } { + if (r.type === "configuration" && r.component_id === DATA_APP_COMPONENT_ID) { + return { label: "data app", icon: AppWindow }; + } + return RESULT_KINDS[r.type] ?? { label: r.type, icon: Settings2 }; +} + +/** + * Deep-link target for one result, expressed in the owning page's `?sel=` + * grammar (same contract the command palette uses: the page owns the meaning + * of its selection, this page only picks a target). `null` = not navigable + * (a configuration-row has no stable parent-config link in the result). + */ +function navTarget(r: SearchResult): { page: "storage" | "configs" | "flows"; sel: string | null } | null { + switch (r.type) { + case "bucket": + return { page: "storage", sel: `bucket/${r.id}` }; + case "table": + return { page: "storage", sel: `tables/${r.id}` }; + case "flow": + return { page: "flows", sel: r.id }; + case "transformation": + case "configuration": + return r.component_id ? { page: "configs", sel: `${r.component_id}/${r.id}` } : null; + default: + return null; + } +} + export function SearchPage() { - const { pendingSearchQuery, setPendingSearchQuery } = useUIState(); + const { + pendingSearchQuery, + setPendingSearchQuery, + project, + setProject, + setBranchId, + setPage, + setSel, + } = useUIState(); const [query, setQuery] = useState(""); - const [searchType, setSearchType] = useState<"textual" | "config-based">("textual"); + const [mode, setMode] = useState("textual"); const [types, setTypes] = useState([]); const [result, setResult] = useState(null); + /** The query the visible result set was produced by (for the header/empty copy). */ + const [ranQuery, setRanQuery] = useState(""); // The mutation takes the query as an argument (rather than closing over // `query` state) so the hand-off effect below can fire it with a value @@ -36,11 +128,14 @@ export function SearchPage() { api.get("/search", { query: { query: q, - search_type: searchType, + search_type: mode, type: types.length ? types : undefined, }, }), - onSuccess: (data) => setResult(data), + onSuccess: (data, q) => { + setResult(data); + setRanQuery(q); + }, }); // Hand-off slot from the command palette's "Search '...' across projects" @@ -56,66 +151,253 @@ export function SearchPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [pendingSearchQuery]); + const toggleType = (value: string) => + setTypes((prev) => (prev.includes(value) ? prev.filter((t) => t !== value) : [...prev, value])); + + /** + * Navigate to the result's home page. ORDER MATTERS (same rule as the + * command palette's openStorage): setProject / setBranchId / setPage each + * clear `sel`, so the selection has to be written LAST. + */ + const openResult = (r: SearchResult) => { + const target = navTarget(r); + if (!target) return; + if (r.project_alias !== project) { + setProject(r.project_alias); + // A branch id is only meaningful inside its own project. + setBranchId(null); + } + setPage(target.page); + setSel(target.sel); + }; + + const errors = result?.errors ?? []; + // Group per-project failures by message: with dozens of registered projects + // the same expired-session text repeats for most of them, and one row per + // project would drown the results. One row per DISTINCT message, carrying + // the affected project aliases, stays readable at any fleet size. + const errorGroups = new Map(); + for (const e of errors) { + const group = errorGroups.get(e.message); + if (group) group.push(e.project_alias); + else errorGroups.set(e.message, [e.project_alias]); + } + return (
+
{ e.preventDefault(); - if (query.trim()) mu.mutate(query); + if (query.trim()) mu.mutate(query.trim()); }} > - setQuery(e.target.value)} - /> - - - +
+
+ + setQuery(e.target.value)} + /> + {query.trim() && !mu.isPending ? ( + + ) : null} +
+ +
+ + +
+ + +
+ +
+ Types + + {TYPE_FILTERS.map(({ value, label, icon: Icon }) => ( + + ))} +
+ {mu.error ? : null} - {result ? ( - <> -
- {result.stats.results_found} hit(s) across {result.stats.projects_searched} project(s) + {mu.isPending ? : null} + + {errors.length > 0 ? ( +
+ + + + {errors.length} {errors.length === 1 ? "project" : "projects"} skipped + + — expand for details + +
+ {[...errorGroups.entries()].map(([message, aliases]) => ( +
+
{message}
+
+ {aliases.map((a) => ( + + {a} + + ))} +
+
+ ))} +
+
+ ) : null} + + {result && !mu.isPending ? ( + result.results.length > 0 ? ( + <> +
+ {result.stats.results_found}{" "} + {result.stats.results_found === 1 ? "hit" : "hits"} for{" "} + "{ranQuery}" across{" "} + {result.stats.projects_searched}{" "} + {result.stats.projects_searched === 1 ? "project" : "projects"} +
+ `${r.project_alias}/${r.type}/${r.id}`} + onRowClick={openResult} + columns={[ + { + header: "Type", + width: "10rem", + cell: (r) => { + const kind = resultKind(r); + const Icon = kind.icon; + return ( + + {kind.label} + + ); + }, + }, + { + header: "Name", + cell: (r) => ( +
+
{r.name}
+ {r.matched_columns?.length ? ( +
+ matched columns: {r.matched_columns.join(", ")} +
+ ) : r.description ? ( +
+ {r.description} +
+ ) : null} +
+ ), + }, + { + header: "ID", + cell: (r) => {r.id}, + }, + { + header: "Component", + cell: (r) => {r.component_id ?? ""}, + }, + { + header: "Project", + width: "10rem", + cell: (r) => {r.project_alias}, + }, + ]} + /> + + ) : ( +
+
+ {result.stats.projects_searched === 0 + ? "No project could be searched" + : `No matches for "${ranQuery}"`} +
+
+ {result.stats.projects_searched === 0 + ? "Every registered project was skipped — expand the notice above to see why." + : mode === "textual" + ? "Names are matched as substrings. Try a shorter term, clear the type filter, or switch to config bodies to scan configuration JSON." + : "Config-based search scans configuration bodies. Try a shorter term or clear the type filter."} +
+
+ ) + ) : null} + + {!result && !mu.isPending && !mu.error ? ( +
+ +
+ Search every registered project at once +
+
+ Tables, buckets, configs, flows, transformations and data apps — matched by name. + Results open straight in their home page.
- `${r.project_alias}/${r.type}/${r.id}`} - columns={[ - { header: "Project", cell: (r) => {r.project_alias} }, - { header: "Type", cell: (r) => {r.type} }, - { header: "Name", cell: (r) => {r.name} }, - { header: "ID", cell: (r) => {r.id} }, - { header: "Component", cell: (r) => r.component_id ?? "" }, - ]} - /> - +
) : null}
);