diff --git a/app-next/src/components/dataset/data-detail-section.tsx b/app-next/src/components/dataset/data-detail-section.tsx index 4ab91647..7b59c487 100644 --- a/app-next/src/components/dataset/data-detail-section.tsx +++ b/app-next/src/components/dataset/data-detail-section.tsx @@ -1,6 +1,7 @@ "use client"; import { Database, FileText, Download, ExternalLink } from "lucide-react"; +import { APP_CONFIG } from "@/lib/config"; import { entityTailwindColors } from "@/constants/entityColors"; import { Button } from "@/components/ui/button"; import { CollapsibleSection } from "@/components/ui/collapsible-section"; @@ -21,7 +22,7 @@ interface DataDetailSectionProps { * - Data preview link */ export function DataDetailSection({ dataset }: DataDetailSectionProps) { - const apiUrl = process.env.NEXT_PUBLIC_OPENML_URL || "https://www.openml.org"; + const apiUrl = APP_CONFIG.openmlApiUrl || "https://www.openml.org"; return ( ([]); const [loading, setLoading] = useState(false); + const [notAvailable, setNotAvailable] = useState(false); useEffect(() => { async function loadRuns() { @@ -69,6 +70,10 @@ export function RunsSection({ dataset, runCount }: RunsSectionProps) { `/api/dataset/${dataset.data_id}/runs?limit=10`, ); if (!response.ok) { + if (response.status === 404) { + setNotAvailable(true); + return; + } throw new Error("Failed to fetch runs"); } const data = await response.json(); @@ -186,6 +191,10 @@ export function RunsSection({ dataset, runCount }: RunsSectionProps) { ))} + ) : notAvailable ? ( +
+ Runs are not available for this dataset on the current server. +
) : (
No runs found for this dataset diff --git a/app-next/src/components/dataset/tasks-section.tsx b/app-next/src/components/dataset/tasks-section.tsx index f0d96e54..1567bb3f 100644 --- a/app-next/src/components/dataset/tasks-section.tsx +++ b/app-next/src/components/dataset/tasks-section.tsx @@ -47,6 +47,7 @@ interface Task { export function TasksSection({ dataset, taskCount }: TasksSectionProps) { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(false); + const [notAvailable, setNotAvailable] = useState(false); useEffect(() => { async function loadTasks() { @@ -56,6 +57,10 @@ export function TasksSection({ dataset, taskCount }: TasksSectionProps) { `/api/dataset/${dataset.data_id}/tasks?limit=10`, ); if (!response.ok) { + if (response.status === 404) { + setNotAvailable(true); + return; + } throw new Error("Failed to fetch tasks"); } const data = await response.json(); @@ -152,6 +157,10 @@ export function TasksSection({ dataset, taskCount }: TasksSectionProps) { ))}
+ ) : notAvailable ? ( +
+ Tasks are not available for this dataset on the current server. +
) : (
No tasks found for this dataset diff --git a/app-next/src/components/header/search-bar.tsx b/app-next/src/components/header/search-bar.tsx index f7276937..065575cc 100644 --- a/app-next/src/components/header/search-bar.tsx +++ b/app-next/src/components/header/search-bar.tsx @@ -58,6 +58,24 @@ export function SearchBar() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [urlQuery]); + // When on a search sub-tab (e.g. /datasets/runs), preserve that sub-path so + // searching stays on the same tab. Detail pages (e.g. /datasets/47155) always + // fall back to the root search route. + const getRoute = useCallback( + (indexKey: string): string => { + const index = searchIndices.find((i) => i.key === indexKey); + if (!index) return "/"; + if (!effectivePath.startsWith(index.route)) return index.route; + // Only preserve sub-paths that look like named tabs (no numeric/slug IDs). + // e.g. /datasets/runs ✅ — /datasets/47155 ❌ — /measures/wall-clock-time ❌ + const subPath = effectivePath.slice(index.route.length); + const nextSegment = subPath.split("/").filter(Boolean)[0] ?? ""; + const isDetailPage = nextSegment !== "" && !/^[a-z]+$/.test(nextSegment); + return isDetailPage ? index.route : effectivePath; + }, + [effectivePath], + ); + // Debounced navigation - centralized and predictable const debouncedNavigate = useDebouncedCallback( (value: string, route: string) => { @@ -65,7 +83,7 @@ export function SearchBar() { // Use replace to avoid polluting history while typing router.replace(`${route}?q=${encodeURIComponent(value)}`); }, - 300, + 700, ); // Handle input change - update local state immediately, navigate after debounce @@ -73,14 +91,11 @@ export function SearchBar() { (e: React.ChangeEvent) => { const newValue = e.target.value; setInputValue(newValue); - - // Get current route for navigation - const currentIndex = searchIndices.find((i) => i.key === selectedIndex); - if (currentIndex && newValue.trim()) { - debouncedNavigate(newValue, currentIndex.route); + if (newValue.trim()) { + debouncedNavigate(newValue, getRoute(selectedIndex)); } }, - [selectedIndex, debouncedNavigate], + [selectedIndex, getRoute, debouncedNavigate], ); // Handle form submit (Enter key) - navigate immediately @@ -88,30 +103,23 @@ export function SearchBar() { (e: React.FormEvent) => { e.preventDefault(); if (inputValue.trim()) { - const currentIndex = searchIndices.find((i) => i.key === selectedIndex); - if (currentIndex) { - router.push( - `${currentIndex.route}?q=${encodeURIComponent(inputValue)}`, - ); - } + router.push(`${getRoute(selectedIndex)}?q=${encodeURIComponent(inputValue)}`); } }, - [inputValue, selectedIndex, router], + [inputValue, selectedIndex, getRoute, router], ); // Handle index (entity type) change const handleIndexChange = useCallback( (value: string) => { - const newIndex = searchIndices.find((i) => i.key === value); - if (newIndex) { - if (inputValue.trim()) { - router.push(`${newIndex.route}?q=${encodeURIComponent(inputValue)}`); - } else { - router.push(newIndex.route); - } + const route = getRoute(value); + if (inputValue.trim()) { + router.push(`${route}?q=${encodeURIComponent(inputValue)}`); + } else { + router.push(route); } }, - [inputValue, router], + [inputValue, getRoute, router], ); return ( diff --git a/app-next/src/components/layout/sidebar.tsx b/app-next/src/components/layout/sidebar.tsx index 1653308a..98f98100 100644 --- a/app-next/src/components/layout/sidebar.tsx +++ b/app-next/src/components/layout/sidebar.tsx @@ -18,6 +18,7 @@ import { } from "lucide-react"; import { useSession, signOut } from "next-auth/react"; import { useState, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { navItems, type NavItem } from "@/constants"; @@ -42,7 +43,6 @@ export function Sidebar() { const isHomePage = pathname === "/"; const { isCollapsed, setIsCollapsed, homeMenuOpen, setHomeMenuOpen } = useSidebar(); - const [counts, setCounts] = useState>({}); const { data: session, status } = useSession(); const [user, setUser] = useState<{ name: string; @@ -61,7 +61,7 @@ export function Sidebar() { const name = session.user.name || `${firstName} ${lastName}`.trim() || - (session.user as any).username || + session.user.username || session.user.email?.split("@")[0] || "User"; const email = session.user.email || ""; @@ -88,42 +88,36 @@ export function Sidebar() { } }, [status, session]); - // Fetch entity counts on mount - useEffect(() => { - fetch("/api/count") - .then((response) => { - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - return response.json(); - }) - .then((data) => { - if (Array.isArray(data)) { - const countsMap = data.reduce( - ( - acc: Record, - item: { index: string; count: number }, - ) => { - acc[item.index] = item.count; - return acc; - }, - {}, - ); - setCounts(countsMap); - } else { - console.warn( - "⚠️ API returned non-array data, counts unavailable:", - data, - ); - } - }) - .catch((error) => { + // Fetch entity counts with React Query (deduplicates requests, caches results) + const { data: counts = {} } = useQuery>({ + queryKey: ["entity-counts"], + queryFn: async () => { + const response = await fetch("/api/count"); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const data = await response.json(); + if (!Array.isArray(data)) { console.warn( - "⚠️ Could not fetch entity counts (sidebar will work without them):", - error.message, + "⚠️ API returned non-array data, counts unavailable:", + data, ); - }); - }, []); + return {}; + } + return data.reduce( + ( + acc: Record, + item: { index: string; count: number }, + ) => { + acc[item.index] = item.count; + return acc; + }, + {}, + ); + }, + staleTime: 5 * 60 * 1000, // Cache counts for 5 minutes + retry: 1, + }); // Render mobile/tablet unified menu (< 1024px for all pages, all sizes for homepage) const renderUnifiedMenu = () => ( @@ -131,8 +125,11 @@ export function Sidebar() { {/* Overlay when menu is open */} {homeMenuOpen && (
setHomeMenuOpen(false)} + style={{ + filter: "contrast(1.75)", + }} /> )} @@ -151,17 +148,17 @@ export function Sidebar() { > {/* Logo Header - Only show when menu is open */} {homeMenuOpen && ( -
+
setHomeMenuOpen(false)} > OpenML Logo + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} ) => ( + // FontAwesomeIcon accepts HTML attributes, not full SVG props + +); + export function BenchmarksSearchPage({ studyType, title, description, }: BenchmarksSearchPageProps) { - const searchParams = useSearchParams(); - const initialQuery = searchParams.get("q") || ""; - const config = createBenchmarkConfig(studyType); - return ( - -
- {/* Page Header */} -
-
-
-
-
-
- - {/* Reuse collection search container with benchmark path */} -
- -
-
-
+ ); } diff --git a/app-next/src/components/search/collections/collection-search-config.ts b/app-next/src/components/search/collections/collection-search-config.ts index 50a0a76c..c89a2cd3 100644 --- a/app-next/src/components/search/collections/collection-search-config.ts +++ b/app-next/src/components/search/collections/collection-search-config.ts @@ -33,9 +33,10 @@ export function createCollectionConfig(studyType: "task" | "run") { flows_included: { raw: {} }, runs_included: { raw: {} }, }, - disjunctiveFacets: ["uploader.keyword"], + disjunctiveFacets: ["uploader.keyword", "visibility"], facets: { "uploader.keyword": { type: "value", size: 20 }, + "visibility": { type: "value", size: 5 }, }, }, initialState: { diff --git a/app-next/src/components/search/collections/collections-search-container.tsx b/app-next/src/components/search/collections/collections-search-container.tsx index 97ddcd8b..97e42ba8 100644 --- a/app-next/src/components/search/collections/collections-search-container.tsx +++ b/app-next/src/components/search/collections/collections-search-container.tsx @@ -3,10 +3,11 @@ import { useState } from "react"; import Link from "next/link"; import { WithSearch, Paging } from "@elastic/react-search-ui"; -import { FilterBar } from "../shared/filter-bar"; import { ControlsBar } from "../shared/controls-bar"; import { Badge } from "@/components/ui/badge"; import { entityColors } from "@/constants/entityColors"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ENTITY_ICONS } from "@/constants/entityIcons"; import { truncateName } from "@/lib/utils"; import { Layers, @@ -14,8 +15,6 @@ import { User, Database, Flag, - Cog, - FlaskConical, Hash, Clock, ArrowRight, @@ -36,10 +35,6 @@ interface StudyResult { [key: string]: unknown; } -const searchFacets = [ - { label: "Uploader", field: "uploader.keyword" }, -]; - const sortOptions = [ { name: "Relevance", value: [], id: "relevance" }, { @@ -124,8 +119,8 @@ export function CollectionsSearchContainer({
)} - {/* Search Results Header */} - {searchTerm && ( + {/* Search Results Header — only shown when a search query is active */} + {!isLoading && !!searchTerm && (
@@ -143,7 +138,6 @@ export function CollectionsSearchContainer({
)} -
- ({ results })}> - {({ results }) => ( + ({ + results, + isLoading, + })} + > + {({ results, isLoading }) => ( <> {view === "list" && (
- {results && results.length > 0 ? ( - results.map( - (result: StudyResult, index: number) => { - const studyId = result.study_id?.raw; - const name = truncateName( - result.name?.raw || "Untitled Collection", - ); - const description = - result.description?.snippet || - result.description?.raw || - ""; - const datasetsCount = Number( - result.datasets_included?.raw || 0, - ); - const tasksCount = Number( - result.tasks_included?.raw || 0, - ); - const flowsCount = Number( - result.flows_included?.raw || 0, - ); - const runsCount = Number( - result.runs_included?.raw || 0, - ); - - return ( -
-
-
- -
-

- {name} -

-
-
- - {description && ( -

- )} - - {/* Entity counts with correct colors */} -

- {datasetsCount > 0 && ( - - - {datasetsCount.toLocaleString()} - - )} - {tasksCount > 0 && ( - - - {tasksCount.toLocaleString()} - - )} - {flowsCount > 0 && ( - - - {flowsCount.toLocaleString()} - - )} - {runsCount > 0 && ( - - - {runsCount.toLocaleString()} - - )} - {result.date?.raw && ( - - - {new Date( - result.date.raw, - ).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - })} - - )} -
-
- - - - {studyId} - - - - - View {name} - - + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, j) => ( +
+ ))}
- ); - }, - ) - ) : ( -
- No results found -
- )} -
- )} - - {view === "grid" && ( -
- {results && results.length > 0 ? ( - results.map( - (result: StudyResult, index: number) => { - const studyId = result.study_id?.raw; - const name = truncateName( - result.name?.raw || "Untitled Collection", - ); - const description = - result.description?.snippet || - result.description?.raw || - ""; - const datasetsCount = Number( - result.datasets_included?.raw || 0, - ); - const tasksCount = Number( - result.tasks_included?.raw || 0, - ); - const flowsCount = Number( - result.flows_included?.raw || 0, - ); - const runsCount = Number( - result.runs_included?.raw || 0, - ); +
+
+ )) + ) : results && results.length > 0 ? ( + results.map((result: StudyResult, index: number) => { + const studyId = result.study_id?.raw; + const name = truncateName( + result.name?.raw || "Untitled Collection", + ); + const description = + result.description?.snippet || + result.description?.raw || + ""; + const datasetsCount = Number( + result.datasets_included?.raw || 0, + ); + const tasksCount = Number( + result.tasks_included?.raw || 0, + ); + const flowsCount = Number( + result.flows_included?.raw || 0, + ); + const runsCount = Number( + result.runs_included?.raw || 0, + ); - return ( - -
+ return ( +
+
+
- - - {studyId} - +
+

+ {name} +

+
-

- {name} -

+ {description && (

)} -

+ + {/* Entity counts with correct colors */} +
{datasetsCount > 0 && ( - - + + {datasetsCount.toLocaleString()} )} {tasksCount > 0 && ( - - + + {tasksCount.toLocaleString()} )} {flowsCount > 0 && ( - - + + {flowsCount.toLocaleString()} )} {runsCount > 0 && ( - - + + {runsCount.toLocaleString()} )} + {result.date?.raw && ( + + + {new Date( + result.date.raw, + ).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + })} + + )}
- {result.uploader?.raw && ( -

- {result.uploader.raw} -

- )} +
+ + + + {studyId} + + + + View {name} - ); - }, - ) +
+ ); + }) + ) : ( +
+ No results found +
+ )} +
+ )} + + {view === "grid" && ( +
+ {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+ )) + ) : results && results.length > 0 ? ( + results.map((result: StudyResult, index: number) => { + const studyId = result.study_id?.raw; + const name = truncateName( + result.name?.raw || "Untitled Collection", + ); + const description = + result.description?.snippet || + result.description?.raw || + ""; + const datasetsCount = Number( + result.datasets_included?.raw || 0, + ); + const tasksCount = Number( + result.tasks_included?.raw || 0, + ); + const flowsCount = Number( + result.flows_included?.raw || 0, + ); + const runsCount = Number( + result.runs_included?.raw || 0, + ); + + return ( + +
+ + + + {studyId} + +
+

+ {name} +

+ {description && ( +

+ )} +

+ {datasetsCount > 0 && ( + + + {datasetsCount.toLocaleString()} + + )} + {tasksCount > 0 && ( + + + {tasksCount.toLocaleString()} + + )} + {flowsCount > 0 && ( + + + {flowsCount.toLocaleString()} + + )} + {runsCount > 0 && ( + + + {runsCount.toLocaleString()} + + )} +
+ {result.uploader?.raw && ( +

+ {result.uploader.raw} +

+ )} + + ); + }) ) : (
No results found @@ -409,15 +459,31 @@ export function CollectionsSearchContainer({
- - - - - - - - - + + + + + + + + + @@ -444,27 +510,39 @@ export function CollectionsSearchContainer({ href={`${basePath}/${studyId}`} className="hover:underline" > - {truncateName(result.name?.raw || "Untitled")} + {truncateName( + result.name?.raw || "Untitled", + )} - - - @@ -489,7 +570,21 @@ export function CollectionsSearchContainer({ {view === "split" && (
- {results && results.length > 0 ? ( + {isLoading ? ( + Array.from({ length: 6 }).map((_, i) => ( +
+
+
+ {Array.from({ length: 3 }).map((_, j) => ( +
+ ))} +
+
+ )) + ) : results && results.length > 0 ? ( (() => { const currentIds = results.map( (r: StudyResult) => r.study_id?.raw, @@ -515,9 +610,7 @@ export function CollectionsSearchContainer({ href={`${basePath}/${studyId}`} onClick={(e) => { e.preventDefault(); - setSelectedStudy( - result as StudyResult, - ); + setSelectedStudy(result as StudyResult); }} className={`hover:bg-accent block cursor-pointer border-b p-3 transition-colors dark:hover:bg-slate-700 ${ isSelected @@ -531,26 +624,57 @@ export function CollectionsSearchContainer({ style={{ color: entityColor }} />

- {truncateName(result.name?.raw || "Untitled")} + {truncateName( + result.name?.raw || "Untitled", + )}

- {Number(result.datasets_included?.raw || 0) > 0 && ( - - - {Number(result.datasets_included?.raw || 0).toLocaleString()} + {Number( + result.datasets_included?.raw || 0, + ) > 0 && ( + + + {Number( + result.datasets_included?.raw || 0, + ).toLocaleString()} )} - {Number(result.tasks_included?.raw || 0) > 0 && ( - - - {Number(result.tasks_included?.raw || 0).toLocaleString()} + {Number(result.tasks_included?.raw || 0) > + 0 && ( + + + {Number( + result.tasks_included?.raw || 0, + ).toLocaleString()} )} - {Number(result.runs_included?.raw || 0) > 0 && ( - - - {Number(result.runs_included?.raw || 0).toLocaleString()} + {Number(result.runs_included?.raw || 0) > + 0 && ( + + + {Number( + result.runs_included?.raw || 0, + ).toLocaleString()} )}
@@ -576,7 +700,9 @@ export function CollectionsSearchContainer({ style={{ color: entityColor }} />

- {truncateName(selectedStudy.name?.raw || "Untitled")} + {truncateName( + selectedStudy.name?.raw || "Untitled", + )}

)}
- {Number(selectedStudy.datasets_included?.raw || 0) > 0 && ( + {Number( + selectedStudy.datasets_included?.raw || 0, + ) > 0 && ( - - {Number(selectedStudy.datasets_included?.raw || 0).toLocaleString()} datasets + + {Number( + selectedStudy.datasets_included?.raw || 0, + ).toLocaleString()}{" "} + datasets )} - {Number(selectedStudy.tasks_included?.raw || 0) > 0 && ( + {Number(selectedStudy.tasks_included?.raw || 0) > + 0 && ( - - {Number(selectedStudy.tasks_included?.raw || 0).toLocaleString()} tasks + + {Number( + selectedStudy.tasks_included?.raw || 0, + ).toLocaleString()}{" "} + tasks )} - {Number(selectedStudy.flows_included?.raw || 0) > 0 && ( + {Number(selectedStudy.flows_included?.raw || 0) > + 0 && ( - - {Number(selectedStudy.flows_included?.raw || 0).toLocaleString()} flows + + {Number( + selectedStudy.flows_included?.raw || 0, + ).toLocaleString()}{" "} + flows )} - {Number(selectedStudy.runs_included?.raw || 0) > 0 && ( + {Number(selectedStudy.runs_included?.raw || 0) > + 0 && ( - - {Number(selectedStudy.runs_included?.raw || 0).toLocaleString()} runs + + {Number( + selectedStudy.runs_included?.raw || 0, + ).toLocaleString()}{" "} + runs )}
@@ -628,7 +785,9 @@ export function CollectionsSearchContainer({ {selectedStudy.date?.raw && (

- {new Date(selectedStudy.date.raw).toLocaleDateString("en-US", { + {new Date( + selectedStudy.date.raw, + ).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", @@ -723,9 +882,7 @@ export function CollectionsSearchContainer({ return ( +

+
+ )} +
- ({ results })}> - {({ results }) => ( + ({ + results, + isLoading, + })} + > + {({ results, isLoading }) => ( <> {view === "table" && } {view === "list" && (
- {results && results.length > 0 ? ( + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, j) => ( +
+ ))} +
+
+
+
+ )) + ) : results && results.length > 0 ? ( results.map((result: SearchResult, index: number) => { const did = result.data_id?.raw || result.id?.raw; return ( @@ -182,7 +243,9 @@ export function SearchContainer() {

- {truncateName(result.name?.raw || "Untitled")} + {truncateName( + result.name?.raw || "Untitled", + )}

v.{result.version?.raw || 1} ✓ @@ -202,8 +265,14 @@ export function SearchContainer() { - - {result.runs?.raw?.toLocaleString() || 0} + + {Number( + result.runs?.raw || 0, + ).toLocaleString()} Runs @@ -238,7 +307,9 @@ export function SearchContainer() { ?.raw || "N/A"} - Dimensions (rows x columns) + + Dimensions (rows x columns) + @@ -290,7 +361,18 @@ export function SearchContainer() { )} {view === "grid" && (
- {results && results.length > 0 ? ( + {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+ )) + ) : results && results.length > 0 ? ( results.map((result: SearchResult, index: number) => (
- {results && results.length > 0 ? ( + {isLoading ? ( + Array.from({ length: 6 }).map((_, i) => ( +
+
+
+ {Array.from({ length: 3 }).map((_, j) => ( +
+ ))} +
+
+ )) + ) : results && results.length > 0 ? ( (() => { // Auto-select first dataset if none selected or if selected dataset is not in current results const currentIds = results.map( @@ -362,7 +458,9 @@ export function SearchContainer() { />

- {truncateName(result.name?.raw || "Untitled")} + {truncateName( + result.name?.raw || "Untitled", + )}

{/* Stats + Metadata badges on same line */} @@ -371,9 +469,14 @@ export function SearchContainer() { className="flex items-center gap-1" title="runs" > - - {result.runs?.raw?.toLocaleString() || - 0} + + {Number( + result.runs?.raw || 0, + ).toLocaleString()}

- {truncateName(selectedDataset.name?.raw || "Untitled")} + {truncateName( + selectedDataset.name?.raw || "Untitled", + )}

v.{selectedDataset.version?.raw || 1} ✓ @@ -479,7 +584,11 @@ export function SearchContainer() { {/* Stats Grid */}
- +
Runs diff --git a/app-next/src/components/search/flows/flow-search-config.ts b/app-next/src/components/search/flows/flow-search-config.ts index 664c0d44..11de9f9d 100644 --- a/app-next/src/components/search/flows/flow-search-config.ts +++ b/app-next/src/components/search/flows/flow-search-config.ts @@ -17,7 +17,7 @@ const flowConfig = { name: { weight: 3 }, description: { weight: 2 }, uploader: { weight: 1 }, - // Add other flow-specific search fields + "tags.tag": { weight: 1 }, }, result_fields: { flow_id: { raw: {} }, @@ -28,10 +28,10 @@ const flowConfig = { version: { raw: {} }, date: { raw: {} }, runs: { raw: {} }, + tags: { raw: {} }, nr_of_likes: { raw: {} }, nr_of_downloads: { raw: {} }, status: { raw: {} }, - // Add other fields you want to display }, disjunctiveFacets: ["dependencies.keyword"], facets: { diff --git a/app-next/src/components/search/flows/flows-results-table.tsx b/app-next/src/components/search/flows/flows-results-table.tsx index a826e711..c17b2f7b 100644 --- a/app-next/src/components/search/flows/flows-results-table.tsx +++ b/app-next/src/components/search/flows/flows-results-table.tsx @@ -1,15 +1,17 @@ "use client"; +import { entityColors } from "@/constants/entityColors"; +import { ENTITY_ICONS } from "@/constants/entityIcons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + import Link from "next/link"; import { ArrowUpDown, ArrowUp, ArrowDown, - Cog, GitBranch, Heart, CloudDownload, - FlaskConical, } from "lucide-react"; import { WithSearch } from "@elastic/react-search-ui"; import { truncateName } from "@/lib/utils"; @@ -72,7 +74,13 @@ const tableColumns = [ }, { field: "runs", - label: , + label: ( + + ), tooltip: "Runs", width: "w-16", sortable: true, @@ -171,13 +179,19 @@ function SortableFlowsTable({ results }: { results: FlowResult[] }) { - + - {truncateName(result.name?.snippet || - result.name?.raw || - "Untitled")} + {truncateName( + result.name?.snippet || + result.name?.raw || + "Untitled", + )} diff --git a/app-next/src/components/search/flows/flows-search-container.tsx b/app-next/src/components/search/flows/flows-search-container.tsx index a7159115..f8faa7f5 100644 --- a/app-next/src/components/search/flows/flows-search-container.tsx +++ b/app-next/src/components/search/flows/flows-search-container.tsx @@ -8,11 +8,11 @@ import { FilterBar } from "../shared/filter-bar"; import { ControlsBar } from "../shared/controls-bar"; import { Badge } from "@/components/ui/badge"; import { entityColors } from "@/constants/entityColors"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ENTITY_ICONS } from "@/constants/entityIcons"; import { - Cog, Heart, CloudDownload, - FlaskConical, Calendar, User, Hash, @@ -156,18 +156,46 @@ export function FlowsSearchContainer() { />
- ({ results })}> - {({ results }) => ( + ({ + results, + isLoading, + })} + > + {({ results, isLoading }) => ( <> {view === "table" && ( )} {view === "list" && (
- {results && results.length > 0 ? ( + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, j) => ( +
+ ))} +
+
+
+ )) + ) : results && results.length > 0 ? ( results.map((result: FlowResult, index: number) => { const flowId = result.flow_id?.raw; - const name = truncateName(result.name?.raw || "Untitled Flow"); + const name = truncateName( + result.name?.raw || "Untitled Flow", + ); const description = result.description?.snippet || result.description?.raw || @@ -182,7 +210,11 @@ export function FlowsSearchContainer() { {/* Line 1: Icon, Name, Version, ID Badge */}
- +

{name} @@ -257,7 +289,11 @@ export function FlowsSearchContainer() { className="flex items-center gap-1.5" title="runs" > - + {result.runs?.raw?.toLocaleString() || 0} - {results && results.length > 0 ? ( + {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+ )) + ) : results && results.length > 0 ? ( results.map((result: FlowResult, index: number) => (
- + - {truncateName(result.name?.raw || "Untitled Flow")} + {truncateName( + result.name?.raw || "Untitled Flow", + )}
@@ -354,7 +404,22 @@ export function FlowsSearchContainer() { {view === "split" && (
- {results && results.length > 0 ? ( + {isLoading ? ( + Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ {Array.from({ length: 3 }).map((_, j) => ( +
+ ))} +
+
+ )) + ) : results && results.length > 0 ? ( (() => { // Auto-select first flow if none selected const selectedId = selectedFlow?.flow_id?.raw; @@ -381,9 +446,15 @@ export function FlowsSearchContainer() { }`} >
- +

- {truncateName(result.name?.raw || "Untitled Flow")} + {truncateName( + result.name?.raw || "Untitled Flow", + )}

@@ -417,14 +488,17 @@ export function FlowsSearchContainer() { {selectedFlow ? (

-

- {truncateName(selectedFlow.name?.raw || "Untitled")} + {truncateName( + selectedFlow.name?.raw || "Untitled", + )}

v.{selectedFlow.version?.raw || 1} @@ -460,7 +534,11 @@ export function FlowsSearchContainer() { {/* Stats Grid */}
- +
Runs
diff --git a/app-next/src/components/search/flows/flows-search-page.tsx b/app-next/src/components/search/flows/flows-search-page.tsx index 716af126..436b95cb 100644 --- a/app-next/src/components/search/flows/flows-search-page.tsx +++ b/app-next/src/components/search/flows/flows-search-page.tsx @@ -6,14 +6,23 @@ import { useSearchParams } from "next/navigation"; import flowConfig from "./flow-search-config"; import { ActiveFiltersHeader } from "../shared/active-filters-header"; import { FlowsSearchContainer } from "./flows-search-container"; +import { Tag, X } from "lucide-react"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; export function FlowsSearchPage() { const searchParams = useSearchParams(); const initialQuery = searchParams.get("q") || ""; + const tagFilter = searchParams.get("tag") || ""; + + const initialFilters = tagFilter + ? [{ field: "tags.tag", values: [tagFilter], type: "any" as const }] + : []; // Facet labels for Active Filters const facetLabels: Record = { "dependencies.keyword": "Libraries", + "tags.tag": "Tag", }; return ( @@ -24,6 +33,7 @@ export function FlowsSearchPage() { initialState: { ...flowConfig.initialState, searchTerm: initialQuery, + filters: initialFilters, }, } as SearchDriverOptions } @@ -51,6 +61,24 @@ export function FlowsSearchPage() {

Browse machine learning algorithms and workflows

+ {tagFilter && ( +
+ + + Tag: {tagFilter} + + + + +
+ )}
@@ -59,7 +87,7 @@ export function FlowsSearchPage() {
{/* Search Container */} -
+
diff --git a/app-next/src/components/search/flows/result-card.tsx b/app-next/src/components/search/flows/result-card.tsx index 599fb9f4..7d727af0 100644 --- a/app-next/src/components/search/flows/result-card.tsx +++ b/app-next/src/components/search/flows/result-card.tsx @@ -2,15 +2,9 @@ import Link from "next/link"; import { parseDescription } from "../teaser"; -import { - Cog, - Heart, - CloudDownload, - FlaskConical, - Hash, - Clock, - User, -} from "lucide-react"; +import { Heart, CloudDownload, Hash, Clock, User } from "lucide-react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ENTITY_ICONS, entityColors } from "@/constants"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { truncateName } from "@/lib/utils"; @@ -95,7 +89,11 @@ export function FlowResultCard({ result }: FlowResultCardProps) { {/* Flow Icon - Top Left */}
- +
@@ -143,7 +141,11 @@ export function FlowResultCard({ result }: FlowResultCardProps) {
- + {abbreviateNumber(Number(result.runs?.raw || 0))} diff --git a/app-next/src/components/search/runs/runs-search-container.tsx b/app-next/src/components/search/runs/runs-search-container.tsx index dccca732..c0f5d0e7 100644 --- a/app-next/src/components/search/runs/runs-search-container.tsx +++ b/app-next/src/components/search/runs/runs-search-container.tsx @@ -1,22 +1,22 @@ "use client"; -import { useState, useEffect, useContext } from "react"; -import { useSearchParams } from "next/navigation"; +import { useState, useEffect, useContext, useRef, useCallback } from "react"; +import { APP_CONFIG } from "@/lib/config"; +import { useSearchParams, useRouter } from "next/navigation"; import Link from "next/link"; import { WithSearch, Paging, SearchContext } from "@elastic/react-search-ui"; import { FilterBar } from "../shared/filter-bar"; import { ControlsBar, runSortOptions } from "../shared/controls-bar"; import { Badge } from "@/components/ui/badge"; import { entityColors } from "@/constants/entityColors"; +import { ENTITY_ICONS } from "@/constants/entityIcons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { truncateName } from "@/lib/utils"; import { - FlaskConical, Calendar, User, Hash, Database, - Cog, - Trophy, XCircle, Heart, CloudDownload, @@ -25,6 +25,8 @@ import { ArrowUpDown, ArrowUp, ArrowDown, + GitCompareArrows, + X, } from "lucide-react"; interface Evaluation { @@ -102,8 +104,7 @@ async function fetchFlowName(flowId: number): Promise { } try { - const apiUrl = - process.env.NEXT_PUBLIC_URL_API || "https://www.openml.org/api/v1"; + const apiUrl = APP_CONFIG.urlApi || "https://www.openml.org/api/v1"; const response = await fetch(`${apiUrl}/json/flow/${flowId}`, { headers: { Accept: "application/json" }, }); @@ -128,8 +129,7 @@ async function fetchDatasetName(dataId: number): Promise { } try { - const apiUrl = - process.env.NEXT_PUBLIC_URL_API || "https://www.openml.org/api/v1"; + const apiUrl = APP_CONFIG.urlApi || "https://www.openml.org/api/v1"; const response = await fetch(`${apiUrl}/json/data/${dataId}`, { headers: { Accept: "application/json" }, }); @@ -266,21 +266,24 @@ function EnrichedRunsView({ results, selectedRun, onSelectRun, + selectedIds, + onToggleId, }: { view: string; results: RunResult[]; selectedRun: EnhancedRunResult | null; onSelectRun: (run: EnhancedRunResult) => void; + selectedIds: Set; + onToggleId: (id: string | number) => void; }) { const [enrichedResults, setEnrichedResults] = useState( [], ); - const [lastResultsKey, setLastResultsKey] = useState(""); + const lastResultsKey = useRef(""); useEffect(() => { if (!results || results.length === 0) { - setEnrichedResults([]); - setLastResultsKey(""); + lastResultsKey.current = ""; return; } @@ -288,23 +291,18 @@ function EnrichedRunsView({ const resultsKey = results.map((r) => r.run_id?.raw).join(","); // Don't re-fetch if we already have these results - if (resultsKey === lastResultsKey) { + if (resultsKey === lastResultsKey.current) { return; } - setLastResultsKey(resultsKey); + lastResultsKey.current = resultsKey; - // Set initial results immediately (with IDs as fallback) - const initialEnriched: EnhancedRunResult[] = results.map((r) => ({ ...r })); - setEnrichedResults(initialEnriched); - - // Fetch names in background + // Fetch names in background, then update state once (async — no cascading renders) Promise.all( results.map(async (result) => { const flowId = result["run_flow.flow_id"]?.raw || result.flow_id?.raw; const dataId = result["run_task.source_data.data_id"]?.raw; - // Check cache first before fetching const [flowName, datasetName] = await Promise.all([ flowId ? fetchFlowName(Number(flowId)) : Promise.resolve(undefined), dataId @@ -327,16 +325,48 @@ function EnrichedRunsView({ .catch((error) => { console.error("Failed to enrich runs:", error); }); - }, [results, lastResultsKey]); // Depend on results and lastResultsKey + }, [results]); + + // Fall back to raw results while enrichment is in flight to avoid showing stale data + const enrichedKey = enrichedResults[0]?.run_id?.raw; + const currentKey = results[0]?.run_id?.raw; + const enrichmentIsCurrent = + results.length > 0 && + enrichedResults.length > 0 && + enrichedKey === currentKey; + const displayedResults = + results.length === 0 + ? [] + : enrichmentIsCurrent + ? enrichedResults + : results.map((r) => ({ ...r })); return ( <> - {view === "list" && } - {view === "grid" && } - {view === "table" && } + {view === "list" && ( + + )} + {view === "grid" && ( + + )} + {view === "table" && ( + + )} {view === "split" && ( @@ -350,6 +380,8 @@ export function RunsSearchContainer() { const [selectedRun, setSelectedRun] = useState( null, ); + const [compareIds, setCompareIds] = useState>(new Set()); + const router = useRouter(); const searchParams = useSearchParams(); const context = useContext(SearchContext); const driver = context?.driver; @@ -362,6 +394,25 @@ export function RunsSearchContainer() { driver.getActions().setSearchTerm(query, { shouldClearFilters: false }); }, [query, driver]); + const toggleCompareId = useCallback((id: string | number) => { + setCompareIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else if (next.size < 10) { + next.add(id); + } + return next; + }); + }, []); + + const handleCompare = useCallback(() => { + if (compareIds.size >= 2) { + const ids = Array.from(compareIds).join(","); + router.push(`/runs/compare?ids=${ids}`); + } + }, [compareIds, router]); + return ( ({ @@ -420,6 +471,8 @@ export function RunsSearchContainer() { results={(results || []) as RunResult[]} selectedRun={selectedRun} onSelectRun={setSelectedRun} + selectedIds={compareIds} + onToggleId={toggleCompareId} /> )} @@ -583,13 +636,45 @@ export function RunsSearchContainer() { ); }} + + {/* Floating comparison bar */} + {compareIds.size > 0 && ( +
+ + + {compareIds.size} run{compareIds.size !== 1 ? "s" : ""} selected + + + +
+ )}
)} ); } -function RunListView({ results }: { results: EnhancedRunResult[] }) { +function RunListView({ + results, + selectedIds, + onToggleId, +}: { + results: EnhancedRunResult[]; + selectedIds: Set; + onToggleId: (id: string | number) => void; +}) { if (!results || results.length === 0) return (
No runs found
@@ -625,10 +710,28 @@ function RunListView({ results }: { results: EnhancedRunResult[] }) { key={runId || index} className="relative flex items-start justify-between border-b p-4 transition-colors hover:bg-red-50 dark:hover:bg-red-900/15" > + {/* Compare checkbox */} + {runId && ( + + )}
- +

{flowName}

@@ -672,7 +775,11 @@ function RunListView({ results }: { results: EnhancedRunResult[] }) { className="flex items-center gap-1 hover:underline" onClick={(e) => e.stopPropagation()} > - + Flow #{flowId} )} @@ -692,7 +799,11 @@ function RunListView({ results }: { results: EnhancedRunResult[] }) { className="flex items-center gap-1 hover:underline" onClick={(e) => e.stopPropagation()} > - + Task #{taskId} )} @@ -739,7 +850,15 @@ function RunListView({ results }: { results: EnhancedRunResult[] }) { ); } -function RunGridView({ results }: { results: EnhancedRunResult[] }) { +function RunGridView({ + results, + selectedIds, + onToggleId, +}: { + results: EnhancedRunResult[]; + selectedIds: Set; + onToggleId: (id: string | number) => void; +}) { if (!results || results.length === 0) return (
No runs found
@@ -751,9 +870,7 @@ function RunGridView({ results }: { results: EnhancedRunResult[] }) { const hasError = !!(result.error_message?.raw || result.error?.raw); // Use helper functions to extract data from ES response - const flowId = getFlowId(result); const flowName = truncateName(result._flowName || getFlowName(result)); - const dataId = getDatasetId(result); const datasetName = result._datasetName || getDatasetName(result); const taskId = getTaskId(result); const taskTypeName = getTaskTypeName(result); @@ -768,13 +885,31 @@ function RunGridView({ results }: { results: EnhancedRunResult[] }) { .join(" • "); return ( -
- +
+ {runId && ( + + )} + +
#{runId} @@ -821,7 +956,14 @@ function RunGridView({ results }: { results: EnhancedRunResult[] }) { {result.nr_of_issues?.raw || 0}
- + + View run {runId} + +
); })}
@@ -849,7 +991,15 @@ const runTableColumns = [ }, ]; -function RunTableView({ results }: { results: EnhancedRunResult[] }) { +function RunTableView({ + results, + selectedIds, + onToggleId, +}: { + results: EnhancedRunResult[]; + selectedIds: Set; + onToggleId: (id: string | number) => void; +}) { if (!results || results.length === 0) return (
No runs found
@@ -901,6 +1051,9 @@ function RunTableView({ results }: { results: EnhancedRunResult[] }) {

IDNameUploaderDatasetsTasksFlowsRunsDate
+ ID + + Name + + Uploader + + Datasets + + Tasks + + Flows + + Runs + + Date +
+ {result.uploader?.raw || "-"} - {Number(result.datasets_included?.raw || 0).toLocaleString()} + {Number( + result.datasets_included?.raw || 0, + ).toLocaleString()} - {Number(result.tasks_included?.raw || 0).toLocaleString()} + {Number( + result.tasks_included?.raw || 0, + ).toLocaleString()} - {Number(result.flows_included?.raw || 0).toLocaleString()} + {Number( + result.flows_included?.raw || 0, + ).toLocaleString()} - {Number(result.runs_included?.raw || 0).toLocaleString()} + {Number( + result.runs_included?.raw || 0, + ).toLocaleString()} + {result.date?.raw - ? new Date(result.date.raw).toLocaleDateString("en-US", { + ? new Date( + result.date.raw, + ).toLocaleDateString("en-US", { year: "numeric", month: "short", }) @@ -476,7 +554,10 @@ export function CollectionsSearchContainer({ ) ) : (
+ No results found
+ {runTableColumns.map((column) => ( @@ -774,13 +814,14 @@ function TaskSplitView({ key={taskId || index} onClick={() => onSelectTask(result)} className={`hover:bg-accent block w-full cursor-pointer border-b p-3 text-left transition-colors ${ - isSelected ? "bg-accent" : "" + isSelected ? "bg-accent/10" : "" }`} >
-

{result.tasktype?.raw?.name || @@ -789,11 +830,18 @@ function TaskSplitView({

- on {truncateName(result.source_data?.raw?.name || "Unknown Dataset")} + on{" "} + {truncateName( + result.source_data?.raw?.name || "Unknown Dataset", + )}

- + {result.runs?.raw?.toLocaleString() || 0} @@ -815,7 +863,15 @@ function TaskSplitView({ {selected && (
- +

@@ -853,7 +909,15 @@ function TaskSplitView({

- +
Estimation Procedure @@ -884,7 +948,11 @@ function TaskSplitView({ {/* Stats Grid */}
- +
{selected.runs?.raw?.toLocaleString() || 0}
diff --git a/app-next/src/components/search/tasks/tasks-search-page.tsx b/app-next/src/components/search/tasks/tasks-search-page.tsx index 2f965ef7..f0f2b085 100644 --- a/app-next/src/components/search/tasks/tasks-search-page.tsx +++ b/app-next/src/components/search/tasks/tasks-search-page.tsx @@ -6,32 +6,40 @@ import { useSearchParams } from "next/navigation"; import taskConfig from "./task-search-config"; import { ActiveFiltersHeader } from "../shared/active-filters-header"; import { TaskSearchContainer } from "@/components/search/tasks/task-search-container"; -import { Trophy, Database, X } from "lucide-react"; +import { Database, Tag, X } from "lucide-react"; import Link from "next/link"; import { Badge } from "@/components/ui/badge"; - +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ENTITY_ICONS, entityColors } from "@/constants"; export function TasksSearchPage() { const searchParams = useSearchParams(); const initialQuery = searchParams.get("q") || ""; const dataIdFilter = searchParams.get("data_id"); + const tagFilter = searchParams.get("tag") || ""; // Facet labels for Active Filters const facetLabels: Record = { "tasktype.name.keyword": "Task Type", "estimation_procedure.type.keyword": "Estimation Procedure", "target_feature.keyword": "Target Feature", + "tags.tag": "Tag", }; // Build initial filters based on query params - const initialFilters = dataIdFilter - ? [ - { - field: "source_data.data_id", - values: [dataIdFilter], - type: "any" as const, - }, - ] - : []; + const initialFilters = [ + ...(dataIdFilter + ? [ + { + field: "source_data.data_id", + values: [dataIdFilter], + type: "any" as const, + }, + ] + : []), + ...(tagFilter + ? [{ field: "tags.tag", values: [tagFilter], type: "any" as const }] + : []), + ]; return (
-
{/* Search Container */} -
+
diff --git a/app-next/src/components/search/users/users-search-container.tsx b/app-next/src/components/search/users/users-search-container.tsx index 4b45e380..a0cd7a41 100644 --- a/app-next/src/components/search/users/users-search-container.tsx +++ b/app-next/src/components/search/users/users-search-container.tsx @@ -8,14 +8,9 @@ import { ControlsBar } from "../shared/controls-bar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { entityColors } from "@/constants/entityColors"; -import { - Database, - Cog, - FlaskConical, - Building2, - Calendar, - Search, -} from "lucide-react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ENTITY_ICONS } from "@/constants/entityIcons"; +import { Database, Building2, Calendar, Search } from "lucide-react"; interface UserResult { user_id?: { raw: string | number }; @@ -146,7 +141,7 @@ export function UsersSearchContainer({
- Searching for "{searchTerm}"... + Searching for "{searchTerm}"...
) : ( @@ -157,18 +152,18 @@ export function UsersSearchContainer({ {totalResults} {" "} - {totalResults === 1 ? "user" : "users"} matching " - {searchTerm}" + {totalResults === 1 ? "user" : "users"} matching " + {searchTerm}" ) : (
- No users found matching " + No users found matching " {searchTerm} - " + "
)} @@ -287,7 +282,11 @@ export function UsersSearchContainer({ )} {result.flows_uploaded?.raw !== undefined && ( - + {result.flows_uploaded.raw} @@ -295,7 +294,11 @@ export function UsersSearchContainer({ )} {result.runs_uploaded?.raw !== undefined && ( - + {result.runs_uploaded.raw} @@ -402,7 +405,11 @@ export function UsersSearchContainer({ )} {result.flows_uploaded?.raw !== undefined && ( - + {result.flows_uploaded.raw} @@ -413,7 +420,11 @@ export function UsersSearchContainer({ )} {result.runs_uploaded?.raw !== undefined && ( - + {result.runs_uploaded.raw} @@ -449,11 +460,11 @@ export function UsersSearchContainer({ No users found

- We couldn't find any users matching " + We couldn't find any users matching " {searchTerm} - " + "

Try adjusting your search terms or check for typos @@ -541,7 +552,7 @@ export function UsersSearchContainer({ 1, current - Math.floor(PAGES_TO_SHOW / 2), ); - let endPage = Math.min( + const endPage = Math.min( totalPages, startPage + PAGES_TO_SHOW - 1, ); diff --git a/app-next/src/components/search/users/users-search-page.tsx b/app-next/src/components/search/users/users-search-page.tsx index f97a936f..b3b725c1 100644 --- a/app-next/src/components/search/users/users-search-page.tsx +++ b/app-next/src/components/search/users/users-search-page.tsx @@ -61,7 +61,7 @@ export function UsersSearchPage() {

{/* Search Container */} -
+
& { + buttonVariant?: React.ComponentProps["variant"]; +}) { + const defaultClassNames = getDefaultClassNames(); + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className, + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months, + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav, + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous, + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next, + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption, + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns, + ), + dropdown_root: cn( + "relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50", + defaultClassNames.dropdown_root, + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown, + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label, + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday, + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header, + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number, + ), + day: cn( + "group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" + : "[&:first-child[data-selected=true]_button]:rounded-l-md", + defaultClassNames.day, + ), + range_start: cn( + "rounded-l-md bg-accent", + defaultClassNames.range_start, + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn( + "rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none", + defaultClassNames.today, + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside, + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled, + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ); + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ); + } + + if (orientation === "right") { + return ( + + ); + } + + return ( + + ); + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( +
+ ); + }, + ...components, + }} + {...props} + /> + ); +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( +
+ Compare + + + {runId && ( + onToggleId(runId)} + className="h-4 w-4 cursor-pointer rounded border-gray-300 text-red-500 accent-red-500" + /> + )} + = { @@ -50,6 +58,7 @@ export function RunsSearchPage() { "run_task.tasktype.name.keyword": "Task Type", "run_flow.name.keyword": "Flow", "uploader.keyword": "Uploader", + "tags.tag": "Tag", }; return ( @@ -71,9 +80,14 @@ export function RunsSearchPage() {
-
{/* Search Container */} -
+
diff --git a/app-next/src/components/search/shared/filter-bar.tsx b/app-next/src/components/search/shared/filter-bar.tsx index dc226664..36331354 100644 --- a/app-next/src/components/search/shared/filter-bar.tsx +++ b/app-next/src/components/search/shared/filter-bar.tsx @@ -208,7 +208,7 @@ export function FilterBar({ return ( //
-
+
Filters: diff --git a/app-next/src/components/search/shared/search-tabs.tsx b/app-next/src/components/search/shared/search-tabs.tsx new file mode 100644 index 00000000..f088e441 --- /dev/null +++ b/app-next/src/components/search/shared/search-tabs.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Link from "next/link"; +import { useSearchParams, usePathname } from "next/navigation"; + +export interface SearchTab { + label: string; + /** Absolute path without locale prefix, e.g. "/collections/tasks" */ + path: string; +} + +/** + * Tab strip that switches between sub-pages while preserving the ?q= query. + * Must be rendered inside a boundary when used from a server component. + */ +export function SearchTabs({ + tabs, + accentColor, +}: { + tabs: SearchTab[]; + accentColor: string; +}) { + const searchParams = useSearchParams(); + const pathname = usePathname(); + + // Strip locale prefix so /en/collections/tasks → /collections/tasks + const effectivePath = pathname.replace(/^\/[a-z]{2}\//, "/"); + const q = searchParams.get("q"); + + return ( +
+
+ +
+
+ ); +} diff --git a/app-next/src/components/search/shared/study-search-page.tsx b/app-next/src/components/search/shared/study-search-page.tsx new file mode 100644 index 00000000..058e38d1 --- /dev/null +++ b/app-next/src/components/search/shared/study-search-page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { SearchProvider } from "@elastic/react-search-ui"; +import type { SearchDriverOptions } from "@elastic/search-ui"; +import React, { useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { CollectionsSearchContainer } from "../collections/collections-search-container"; +import { UrlSearchSync } from "./url-search-sync"; +import { SearchTabs, type SearchTab } from "./search-tabs"; + +interface StudySearchPageProps { + studyType: "task" | "run"; + title: string; + description: string; + basePath: string; + icon: React.ElementType; + entityColor: string; + tabs: SearchTab[]; + createConfig: (studyType: "task" | "run") => { + initialState?: Record; + [key: string]: unknown; + }; +} + +/** + * Shared page shell for study-based search pages (collections & benchmarks). + * Handles SearchProvider setup, URL ?q= sync, page header, and tab navigation. + * Use the thin wrappers in collections/ and benchmarks/ to consume this. + */ +export function StudySearchPage({ + studyType, + title, + description, + basePath, + icon: Icon, + entityColor, + tabs, + createConfig, +}: StudySearchPageProps) { + const searchParams = useSearchParams(); + const urlQuery = searchParams.get("q") || ""; + + // Initialised once — prevents SearchProvider re-mount (and focus loss) on URL changes. + const [config] = useState(() => { + const cfg = createConfig(studyType); + return { + ...cfg, + initialState: { + ...cfg.initialState, + searchTerm: urlQuery, + }, + } as SearchDriverOptions; + }); + + return ( + + +
+
+
+
+
+
+
+ + + +
+ +
+
+
+ ); +} diff --git a/app-next/src/components/search/shared/url-search-sync.tsx b/app-next/src/components/search/shared/url-search-sync.tsx new file mode 100644 index 00000000..b3a2c43b --- /dev/null +++ b/app-next/src/components/search/shared/url-search-sync.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { WithSearch } from "@elastic/react-search-ui"; +import { useEffect, useRef } from "react"; + +type SetSearchTermFn = ( + term: string, + opts?: { shouldClearFilters?: boolean }, +) => void; + +function UrlSyncEffect({ + q, + setSearchTerm, +}: { + q: string; + setSearchTerm: SetSearchTermFn; +}) { + const mounted = useRef(false); + useEffect(() => { + if (!mounted.current) { + mounted.current = true; + return; + } + setSearchTerm(q, { shouldClearFilters: false }); + }, [q, setSearchTerm]); + return null; +} + +/** + * Syncs URL ?q= changes into the SearchProvider after initial mount. + * Must be rendered inside a . + * Uses shouldClearFilters: false to preserve filters like study_type. + */ +export function UrlSearchSync({ q }: { q: string }) { + return ( + ({ setSearchTerm })}> + {({ setSearchTerm }: { setSearchTerm?: SetSearchTermFn }) => + setSearchTerm ? ( + + ) : null + } + + ); +} diff --git a/app-next/src/components/search/tasks/task-search-config.ts b/app-next/src/components/search/tasks/task-search-config.ts index fa285808..9bda7fe2 100644 --- a/app-next/src/components/search/tasks/task-search-config.ts +++ b/app-next/src/components/search/tasks/task-search-config.ts @@ -23,6 +23,7 @@ const taskConfig = { "source_data.name": { weight: 3 }, "tasktype.name": { weight: 2 }, "estimation_procedure.type": { weight: 1 }, + "tags.tag": { weight: 1 }, }, result_fields: { task_id: { raw: {} }, @@ -35,6 +36,7 @@ const taskConfig = { target_feature: { raw: {} }, evaluation_measures: { raw: {} }, runs: { raw: {} }, + tags: { raw: {} }, nr_of_likes: { raw: {} }, nr_of_downloads: { raw: {} }, }, diff --git a/app-next/src/components/search/tasks/task-search-container.tsx b/app-next/src/components/search/tasks/task-search-container.tsx index d155a76d..1bcfcdd6 100644 --- a/app-next/src/components/search/tasks/task-search-container.tsx +++ b/app-next/src/components/search/tasks/task-search-container.tsx @@ -8,16 +8,15 @@ import { FilterBar } from "../shared/filter-bar"; import { ControlsBar, taskSortOptions } from "../shared/controls-bar"; import { Badge } from "@/components/ui/badge"; import { entityColors } from "@/constants/entityColors"; +import { ENTITY_ICONS } from "@/constants/entityIcons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { truncateName } from "@/lib/utils"; import { - FlaskConical, Heart, CloudDownload, Hash, - Trophy, Database, Target, - Gauge, ChevronRight, ArrowUpDown, ArrowUp, @@ -334,7 +333,9 @@ function TaskListView({ results }: { results: TaskSearchResult[] }) {
{results.map((result, index) => { const tid = result.task_id?.raw || result.id?.raw; - const datasetName = truncateName(result.source_data?.raw?.name || "Unknown Dataset"); + const datasetName = truncateName( + result.source_data?.raw?.name || "Unknown Dataset", + ); const datasetId = result.source_data?.raw?.data_id; const taskType = result.tasktype?.raw?.name || @@ -354,9 +355,10 @@ function TaskListView({ results }: { results: TaskSearchResult[] }) {
{/* Title row */}
-
- + {result.runs?.raw?.toLocaleString() || 0} +
+ {children} +
+