Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/components/Home/RunSection/RunSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,18 @@ interface RunSectionProps {
onEmptyList?: () => void;
/** When true, hides the built-in filter UI (used when new filter bar is enabled) */
hideFilters?: boolean;
/** When provided, overrides the URL filter param (e.g. "created_by:me") */
forcedFilter?: string;
/** When provided, limits the number of rows shown (pagination still works per backend page) */
maxItems?: number;
}

export const RunSection = ({ onEmptyList, hideFilters }: RunSectionProps) => {
export const RunSection = ({
onEmptyList,
hideFilters,
forcedFilter,
maxItems,
}: RunSectionProps) => {
const { backendUrl, configured, available, ready } = useBackend();
const navigate = useNavigate();
const { pathname } = useLocation();
Expand All @@ -54,7 +63,7 @@ export const RunSection = ({ onEmptyList, hideFilters }: RunSectionProps) => {
const dataVersion = useRef(0);

// Supports both JSON (new) and key:value (legacy) URL formats
const filters = parseFilterParam(search.filter);
const filters = parseFilterParam(forcedFilter ?? search.filter);
const createdByValue = filters.created_by;

const apiFilterQuery = filtersToFilterQuery(filters);
Expand Down Expand Up @@ -281,7 +290,10 @@ export const RunSection = ({ onEmptyList, hideFilters }: RunSectionProps) => {
</TableRow>
</TableHeader>
<TableBody>
{data.pipeline_runs?.map((run) => (
{(maxItems
? data.pipeline_runs?.slice(0, maxItems)
: data.pipeline_runs
)?.map((run) => (
<RunRow key={run.id} run={run} />
))}
</TableBody>
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useRecentlyViewed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useCallback, useSyncExternalStore } from "react";
import { getStorage } from "@/utils/typedStorage";

const RECENTLY_VIEWED_KEY = "Home/recently_viewed";
const MAX_ITEMS = 10;
const MAX_ITEMS = 100;

type RecentlyViewedType = "pipeline" | "run" | "component";

Expand Down
231 changes: 231 additions & 0 deletions src/routes/Dashboard/DashboardHomeView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// TODO: Remove fake announcement data before shipping
if (!window.__TANGLE_ANNOUNCEMENTS__) {
window.__TANGLE_ANNOUNCEMENTS__ = [
{
id: "dashboard-beta-welcome",
title: "Welcome to the new Dashboard (Beta)",
body: "We're rolling out new features — favorites, recently viewed, and more. Expect changes as we iterate.",
variant: "info",
dismissible: true,
},
];
}

import { Link } from "@tanstack/react-router";
import { GitBranch, Play } from "lucide-react";

import { RunSection } from "@/components/Home/RunSection/RunSection";
import { AnnouncementBanners } from "@/components/shared/AnnouncementBanners";
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Paragraph, Text } from "@/components/ui/typography";
import { type FavoriteItem, useFavorites } from "@/hooks/useFavorites";
import {
type RecentlyViewedItem,
useRecentlyViewed,
} from "@/hooks/useRecentlyViewed";
import { APP_ROUTES, EDITOR_PATH, RUNS_BASE_PATH } from "@/routes/router";

const PREVIEW_COUNT = 5;

function formatRelativeTime(viewedAt: number): string {
const diff = Date.now() - viewedAt;
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}

function getRecentlyViewedUrl(item: RecentlyViewedItem): string {
if (item.type === "pipeline") return `${EDITOR_PATH}/${item.id}`;
if (item.type === "run") return `${RUNS_BASE_PATH}/${item.id}`;
return "/";
}

function getFavoriteUrl(item: FavoriteItem): string {
if (item.type === "pipeline") return `${EDITOR_PATH}/${item.id}`;
return `${RUNS_BASE_PATH}/${item.id}`;
}

const TypePill = ({ type }: { type: "pipeline" | "run" | "component" }) => (
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs font-semibold shrink-0 ${
type === "pipeline"
? "bg-violet-100 text-violet-700"
: "bg-emerald-100 text-emerald-700"
}`}
>
{type === "pipeline" ? (
<GitBranch className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
{type === "pipeline" ? "Pipeline" : type === "run" ? "Run" : "Component"}
</span>
);

const SectionHeader = ({
title,
viewAllTo,
viewAllLabel = "View all",
}: {
title: string;
viewAllTo: string;
viewAllLabel?: string;
}) => (
<InlineStack gap="3" blockAlign="center">
<Text as="h2" size="lg" weight="semibold">
{title}
</Text>
<Link
to={viewAllTo}
className="text-xs text-muted-foreground hover:text-foreground"
>
{viewAllLabel} →
</Link>
</InlineStack>
);

// ─── Favorites ─────────────────────────────────────────────────────────────────

const FavoritesPreview = () => {
const { favorites, removeFavorite } = useFavorites();
const preview = favorites.slice(-PREVIEW_COUNT).reverse();

return (
<div className="flex flex-col gap-3 min-w-0">
<SectionHeader
title="Favorites"
viewAllTo={APP_ROUTES.DASHBOARD_FAVORITES}
/>
<div className="border border-border rounded-lg overflow-hidden">
{preview.length === 0 ? (
<div className="px-4 py-3">
<Paragraph tone="subdued" size="sm">
No favorites yet. Star a pipeline or run to pin it here.
</Paragraph>
</div>
) : (
preview.map((item, i) => (
<Link
key={`${item.type}-${item.id}`}
to={getFavoriteUrl(item)}
className={`group flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 no-underline ${
i < preview.length - 1 ? "border-b border-border" : ""
}`}
>
<TypePill type={item.type} />
<Tooltip>
<TooltipTrigger asChild>
<Text size="sm" className="flex-1 min-w-0 truncate">
{item.name}
</Text>
</TooltipTrigger>
<TooltipContent>{item.name}</TooltipContent>
</Tooltip>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeFavorite(item.type, item.id);
}}
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer"
aria-label="Remove from favorites"
>
<Icon name="X" size="sm" />
</button>
</Link>
))
)}
</div>
</div>
);
};

// ─── Recently Viewed ───────────────────────────────────────────────────────────

const RecentlyViewedPreview = () => {
const { recentlyViewed } = useRecentlyViewed();
const preview = recentlyViewed.slice(0, PREVIEW_COUNT);

return (
<div className="flex flex-col gap-3 min-w-0">
<SectionHeader
title="Recently Viewed"
viewAllTo={APP_ROUTES.DASHBOARD_RECENTLY_VIEWED}
/>
<div className="border border-border rounded-lg overflow-hidden">
{preview.length === 0 ? (
<div className="px-4 py-3">
<Paragraph tone="subdued" size="sm">
Nothing viewed yet. Open a pipeline or run to see it here.
</Paragraph>
</div>
) : (
preview.map((item, i) => (
<Link
key={`${item.type}-${item.id}`}
to={getRecentlyViewedUrl(item)}
className={`flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 no-underline ${
i < preview.length - 1 ? "border-b border-border" : ""
}`}
>
<TypePill type={item.type} />
<Tooltip>
<TooltipTrigger asChild>
<Text size="sm" className="flex-1 min-w-0 truncate">
{item.name}
</Text>
</TooltipTrigger>
<TooltipContent>{item.name}</TooltipContent>
</Tooltip>
<Text size="xs" className="text-muted-foreground shrink-0">
{formatRelativeTime(item.viewedAt)}
</Text>
</Link>
))
)}
</div>
</div>
);
};

// ─── My Dashboard ──────────────────────────────────────────────────────────────

export function DashboardHomeView() {
return (
<BlockStack gap="6">
<AnnouncementBanners />

{/* Favorites + Recently Viewed + (future) side by side */}
<div className="grid grid-cols-3 gap-6">
<FavoritesPreview />
<RecentlyViewedPreview />
<div />
</div>

{/* My Runs — full table with created_by:me filter */}
<BlockStack gap="3">
<SectionHeader
title="My Runs"
viewAllTo={APP_ROUTES.DASHBOARD_RUNS}
viewAllLabel="View all runs"
/>
{/*
Fetching 10 records because the API does not yet support a custom page_size.
Once TangleML/tangle#188 lands, reduce this to match the visible row count.
Tracked in TangleML/tangle-ui#2016.
*/}
<RunSection hideFilters forcedFilter="created_by:me" maxItems={10} />
</BlockStack>
</BlockStack>
);
}
12 changes: 10 additions & 2 deletions src/routes/Dashboard/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,18 @@ interface SidebarItem {
to: string;
label: string;
icon: IconName;
exact?: boolean;
}

const SIDEBAR_ITEMS: SidebarItem[] = [
{ to: "/dashboard/runs", label: "Runs", icon: "Play" },
{ to: "/dashboard/pipelines", label: "Pipelines", icon: "GitBranch" },
{
to: "/dashboard",
label: "My Dashboard",
icon: "LayoutDashboard",
exact: true,
},
{ to: "/dashboard/pipelines", label: "My Pipelines", icon: "GitBranch" },
{ to: "/dashboard/runs", label: "All Runs", icon: "Play" },
{ to: "/dashboard/components", label: "Components", icon: "Package" },
{ to: "/dashboard/favorites", label: "Favorites", icon: "Star" },
{ to: "/dashboard/recently-viewed", label: "Recently Viewed", icon: "Clock" },
Expand Down Expand Up @@ -71,6 +78,7 @@ export function DashboardLayout() {
to={item.to}
className="w-full"
activeProps={{ className: "is-active" }}
activeOptions={item.exact ? { exact: true } : undefined}
>
{({ isActive }) => (
<div className={navItemClass(isActive)}>
Expand Down
52 changes: 46 additions & 6 deletions src/routes/Dashboard/DashboardRecentlyViewedView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Link } from "@tanstack/react-router";
import { GitBranch, Play } from "lucide-react";
import { ChevronLeft, ChevronRight, GitBranch, Play } from "lucide-react";
import { useState } from "react";

import { Button } from "@/components/ui/button";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Paragraph, Text } from "@/components/ui/typography";
import {
Expand All @@ -9,6 +11,8 @@ import {
} from "@/hooks/useRecentlyViewed";
import { EDITOR_PATH, RUNS_BASE_PATH } from "@/routes/router";

const PAGE_SIZE = 20;

function getRecentlyViewedUrl(item: RecentlyViewedItem): string {
if (item.type === "pipeline") return `${EDITOR_PATH}/${item.id}`;
if (item.type === "run") return `${RUNS_BASE_PATH}/${item.id}`;
Expand Down Expand Up @@ -70,6 +74,14 @@ const RecentlyViewedCard = ({ item }: { item: RecentlyViewedItem }) => {

export function DashboardRecentlyViewedView() {
const { recentlyViewed } = useRecentlyViewed();
const [page, setPage] = useState(0);

const totalPages = Math.ceil(recentlyViewed.length / PAGE_SIZE);
const safePage = Math.min(page, Math.max(0, totalPages - 1));
const paginated = recentlyViewed.slice(
safePage * PAGE_SIZE,
(safePage + 1) * PAGE_SIZE,
);

return (
<BlockStack gap="4">
Expand All @@ -82,11 +94,39 @@ export function DashboardRecentlyViewedView() {
Nothing viewed yet. Open a pipeline or run to see it here.
</Paragraph>
) : (
<div className="grid grid-cols-4 gap-3">
{recentlyViewed.map((item) => (
<RecentlyViewedCard key={`${item.type}-${item.id}`} item={item} />
))}
</div>
<BlockStack gap="4">
<div className="grid grid-cols-4 gap-3">
{paginated.map((item) => (
<RecentlyViewedCard key={`${item.type}-${item.id}`} item={item} />
))}
</div>

{totalPages > 1 && (
<InlineStack blockAlign="center" gap="2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={safePage === 0}
onClick={() => setPage(safePage - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Text size="sm" className="text-muted-foreground">
{safePage + 1} / {totalPages}
</Text>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={safePage >= totalPages - 1}
onClick={() => setPage(safePage + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</InlineStack>
)}
</BlockStack>
)}
</BlockStack>
);
Expand Down
Loading
Loading