Skip to content

Commit 8fc6d98

Browse files
committed
Adds new dashboard view
1 parent e5dbbd0 commit 8fc6d98

6 files changed

Lines changed: 284 additions & 15 deletions

File tree

src/components/Home/RunSection/RunSection.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,18 @@ interface RunSectionProps {
4343
onEmptyList?: () => void;
4444
/** When true, hides the built-in filter UI (used when new filter bar is enabled) */
4545
hideFilters?: boolean;
46+
/** When provided, overrides the URL filter param (e.g. "created_by:me") */
47+
forcedFilter?: string;
48+
/** When provided, limits the number of rows shown (pagination still works per backend page) */
49+
maxItems?: number;
4650
}
4751

48-
export const RunSection = ({ onEmptyList, hideFilters }: RunSectionProps) => {
52+
export const RunSection = ({
53+
onEmptyList,
54+
hideFilters,
55+
forcedFilter,
56+
maxItems,
57+
}: RunSectionProps) => {
4958
const { backendUrl, configured, available, ready } = useBackend();
5059
const navigate = useNavigate();
5160
const { pathname } = useLocation();
@@ -54,7 +63,7 @@ export const RunSection = ({ onEmptyList, hideFilters }: RunSectionProps) => {
5463
const dataVersion = useRef(0);
5564

5665
// Supports both JSON (new) and key:value (legacy) URL formats
57-
const filters = parseFilterParam(search.filter);
66+
const filters = parseFilterParam(forcedFilter ?? search.filter);
5867
const createdByValue = filters.created_by;
5968

6069
const apiFilterQuery = filtersToFilterQuery(filters);
@@ -281,7 +290,10 @@ export const RunSection = ({ onEmptyList, hideFilters }: RunSectionProps) => {
281290
</TableRow>
282291
</TableHeader>
283292
<TableBody>
284-
{data.pipeline_runs?.map((run) => (
293+
{(maxItems
294+
? data.pipeline_runs?.slice(0, maxItems)
295+
: data.pipeline_runs
296+
)?.map((run) => (
285297
<RunRow key={run.id} run={run} />
286298
))}
287299
</TableBody>

src/hooks/useRecentlyViewed.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useCallback, useSyncExternalStore } from "react";
33
import { getStorage } from "@/utils/typedStorage";
44

55
const RECENTLY_VIEWED_KEY = "Home/recently_viewed";
6-
const MAX_ITEMS = 10;
6+
const MAX_ITEMS = 100;
77

88
export type RecentlyViewedType = "pipeline" | "run" | "component";
99

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// TODO: Remove fake announcement data before shipping
2+
if (!window.__TANGLE_ANNOUNCEMENTS__) {
3+
window.__TANGLE_ANNOUNCEMENTS__ = [
4+
{
5+
id: "dashboard-beta-welcome",
6+
title: "Welcome to the new Dashboard (Beta)",
7+
body: "We're rolling out new features — favorites, recently viewed, and more. Expect changes as we iterate.",
8+
variant: "info",
9+
dismissible: true,
10+
},
11+
];
12+
}
13+
14+
import { Link } from "@tanstack/react-router";
15+
import { GitBranch, Play } from "lucide-react";
16+
17+
import { RunSection } from "@/components/Home/RunSection/RunSection";
18+
import { AnnouncementBanners } from "@/components/shared/AnnouncementBanners";
19+
import { Icon } from "@/components/ui/icon";
20+
import { BlockStack, InlineStack } from "@/components/ui/layout";
21+
import { Paragraph, Text } from "@/components/ui/typography";
22+
import { type FavoriteItem, useFavorites } from "@/hooks/useFavorites";
23+
import {
24+
type RecentlyViewedItem,
25+
useRecentlyViewed,
26+
} from "@/hooks/useRecentlyViewed";
27+
import { APP_ROUTES, EDITOR_PATH, RUNS_BASE_PATH } from "@/routes/router";
28+
29+
const PREVIEW_COUNT = 5;
30+
31+
function formatRelativeTime(viewedAt: number): string {
32+
const diff = Date.now() - viewedAt;
33+
const minutes = Math.floor(diff / 60_000);
34+
if (minutes < 1) return "just now";
35+
if (minutes < 60) return `${minutes}m ago`;
36+
const hours = Math.floor(minutes / 60);
37+
if (hours < 24) return `${hours}h ago`;
38+
const days = Math.floor(hours / 24);
39+
return `${days}d ago`;
40+
}
41+
42+
function getRecentlyViewedUrl(item: RecentlyViewedItem): string {
43+
if (item.type === "pipeline") return `${EDITOR_PATH}/${item.id}`;
44+
if (item.type === "run") return `${RUNS_BASE_PATH}/${item.id}`;
45+
return "/";
46+
}
47+
48+
function getFavoriteUrl(item: FavoriteItem): string {
49+
if (item.type === "pipeline") return `${EDITOR_PATH}/${item.id}`;
50+
return `${RUNS_BASE_PATH}/${item.id}`;
51+
}
52+
53+
const TypePill = ({ type }: { type: "pipeline" | "run" | "component" }) => (
54+
<span
55+
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs font-semibold shrink-0 ${
56+
type === "pipeline"
57+
? "bg-violet-100 text-violet-700"
58+
: "bg-emerald-100 text-emerald-700"
59+
}`}
60+
>
61+
{type === "pipeline" ? (
62+
<GitBranch className="h-3 w-3" />
63+
) : (
64+
<Play className="h-3 w-3" />
65+
)}
66+
{type === "pipeline" ? "Pipeline" : type === "run" ? "Run" : "Component"}
67+
</span>
68+
);
69+
70+
const SectionHeader = ({
71+
title,
72+
viewAllTo,
73+
viewAllLabel = "View all",
74+
}: {
75+
title: string;
76+
viewAllTo: string;
77+
viewAllLabel?: string;
78+
}) => (
79+
<InlineStack gap="3" blockAlign="center">
80+
<Text as="h2" size="lg" weight="semibold">
81+
{title}
82+
</Text>
83+
<Link
84+
to={viewAllTo}
85+
className="text-xs text-muted-foreground hover:text-foreground"
86+
>
87+
{viewAllLabel}
88+
</Link>
89+
</InlineStack>
90+
);
91+
92+
// ─── Favorites ─────────────────────────────────────────────────────────────────
93+
94+
const FavoritesPreview = () => {
95+
const { favorites, removeFavorite } = useFavorites();
96+
const preview = favorites.slice(-PREVIEW_COUNT).reverse();
97+
98+
return (
99+
<BlockStack gap="3">
100+
<SectionHeader
101+
title="Favorites"
102+
viewAllTo={APP_ROUTES.DASHBOARD_FAVORITES}
103+
/>
104+
<div className="border border-border rounded-lg overflow-hidden">
105+
{preview.length === 0 ? (
106+
<div className="px-4 py-3">
107+
<Paragraph tone="subdued" size="sm">
108+
No favorites yet. Star a pipeline or run to pin it here.
109+
</Paragraph>
110+
</div>
111+
) : (
112+
preview.map((item, i) => (
113+
<Link
114+
key={`${item.type}-${item.id}`}
115+
to={getFavoriteUrl(item)}
116+
className={`group flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 no-underline ${
117+
i < preview.length - 1 ? "border-b border-border" : ""
118+
}`}
119+
>
120+
<TypePill type={item.type} />
121+
<Text size="sm" className="flex-1 truncate">
122+
{item.name}
123+
</Text>
124+
<button
125+
onClick={(e) => {
126+
e.preventDefault();
127+
e.stopPropagation();
128+
removeFavorite(item.type, item.id);
129+
}}
130+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer"
131+
aria-label="Remove from favorites"
132+
>
133+
<Icon name="X" size="sm" />
134+
</button>
135+
</Link>
136+
))
137+
)}
138+
</div>
139+
</BlockStack>
140+
);
141+
};
142+
143+
// ─── Recently Viewed ───────────────────────────────────────────────────────────
144+
145+
const RecentlyViewedPreview = () => {
146+
const { recentlyViewed } = useRecentlyViewed();
147+
const preview = recentlyViewed.slice(0, PREVIEW_COUNT);
148+
149+
return (
150+
<BlockStack gap="3">
151+
<SectionHeader
152+
title="Recently Viewed"
153+
viewAllTo={APP_ROUTES.DASHBOARD_RECENTLY_VIEWED}
154+
/>
155+
<div className="border border-border rounded-lg overflow-hidden">
156+
{preview.length === 0 ? (
157+
<div className="px-4 py-3">
158+
<Paragraph tone="subdued" size="sm">
159+
Nothing viewed yet. Open a pipeline or run to see it here.
160+
</Paragraph>
161+
</div>
162+
) : (
163+
preview.map((item, i) => (
164+
<Link
165+
key={`${item.type}-${item.id}`}
166+
to={getRecentlyViewedUrl(item)}
167+
className={`flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 no-underline ${
168+
i < preview.length - 1 ? "border-b border-border" : ""
169+
}`}
170+
>
171+
<TypePill type={item.type} />
172+
<Text size="sm" className="flex-1 truncate">
173+
{item.name}
174+
</Text>
175+
<Text size="xs" className="text-muted-foreground shrink-0">
176+
{formatRelativeTime(item.viewedAt)}
177+
</Text>
178+
</Link>
179+
))
180+
)}
181+
</div>
182+
</BlockStack>
183+
);
184+
};
185+
186+
// ─── My Dashboard ──────────────────────────────────────────────────────────────
187+
188+
export function DashboardHomeView() {
189+
return (
190+
<BlockStack gap="6">
191+
<AnnouncementBanners />
192+
193+
{/* Favorites + Recently Viewed side by side */}
194+
<div className="grid grid-cols-2 gap-6">
195+
<FavoritesPreview />
196+
<RecentlyViewedPreview />
197+
</div>
198+
199+
{/* My Runs — full table with created_by:me filter */}
200+
<BlockStack gap="3">
201+
<SectionHeader
202+
title="My Runs"
203+
viewAllTo={APP_ROUTES.DASHBOARD_RUNS}
204+
viewAllLabel="View all runs"
205+
/>
206+
<RunSection hideFilters forcedFilter="created_by:me" maxItems={5} />
207+
</BlockStack>
208+
</BlockStack>
209+
);
210+
}

src/routes/Dashboard/DashboardLayout.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,18 @@ interface SidebarItem {
2020
to: string;
2121
label: string;
2222
icon: IconName;
23+
exact?: boolean;
2324
}
2425

2526
const SIDEBAR_ITEMS: SidebarItem[] = [
26-
{ to: "/dashboard/runs", label: "Runs", icon: "Play" },
27-
{ to: "/dashboard/pipelines", label: "Pipelines", icon: "GitBranch" },
27+
{
28+
to: "/dashboard",
29+
label: "My Dashboard",
30+
icon: "LayoutDashboard",
31+
exact: true,
32+
},
33+
{ to: "/dashboard/pipelines", label: "My Pipelines", icon: "GitBranch" },
34+
{ to: "/dashboard/runs", label: "All Runs", icon: "Play" },
2835
{ to: "/dashboard/components", label: "Components", icon: "Package" },
2936
{ to: "/dashboard/favorites", label: "Favorites", icon: "Star" },
3037
{ to: "/dashboard/recently-viewed", label: "Recently Viewed", icon: "Clock" },
@@ -71,6 +78,7 @@ export function DashboardLayout() {
7178
to={item.to}
7279
className="w-full"
7380
activeProps={{ className: "is-active" }}
81+
activeOptions={item.exact ? { exact: true } : undefined}
7482
>
7583
{({ isActive }) => (
7684
<div className={navItemClass(isActive)}>

src/routes/Dashboard/DashboardRecentlyViewedView.tsx

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Link } from "@tanstack/react-router";
2-
import { GitBranch, Play } from "lucide-react";
2+
import { ChevronLeft, ChevronRight, GitBranch, Play } from "lucide-react";
3+
import { useState } from "react";
34

5+
import { Button } from "@/components/ui/button";
46
import { BlockStack, InlineStack } from "@/components/ui/layout";
57
import { Paragraph, Text } from "@/components/ui/typography";
68
import {
@@ -9,6 +11,8 @@ import {
911
} from "@/hooks/useRecentlyViewed";
1012
import { EDITOR_PATH, RUNS_BASE_PATH } from "@/routes/router";
1113

14+
const PAGE_SIZE = 20;
15+
1216
function getRecentlyViewedUrl(item: RecentlyViewedItem): string {
1317
if (item.type === "pipeline") return `${EDITOR_PATH}/${item.id}`;
1418
if (item.type === "run") return `${RUNS_BASE_PATH}/${item.id}`;
@@ -70,6 +74,14 @@ const RecentlyViewedCard = ({ item }: { item: RecentlyViewedItem }) => {
7074

7175
export function DashboardRecentlyViewedView() {
7276
const { recentlyViewed } = useRecentlyViewed();
77+
const [page, setPage] = useState(0);
78+
79+
const totalPages = Math.ceil(recentlyViewed.length / PAGE_SIZE);
80+
const safePage = Math.min(page, Math.max(0, totalPages - 1));
81+
const paginated = recentlyViewed.slice(
82+
safePage * PAGE_SIZE,
83+
(safePage + 1) * PAGE_SIZE,
84+
);
7385

7486
return (
7587
<BlockStack gap="4">
@@ -82,11 +94,39 @@ export function DashboardRecentlyViewedView() {
8294
Nothing viewed yet. Open a pipeline or run to see it here.
8395
</Paragraph>
8496
) : (
85-
<div className="grid grid-cols-4 gap-3">
86-
{recentlyViewed.map((item) => (
87-
<RecentlyViewedCard key={`${item.type}-${item.id}`} item={item} />
88-
))}
89-
</div>
97+
<BlockStack gap="4">
98+
<div className="grid grid-cols-4 gap-3">
99+
{paginated.map((item) => (
100+
<RecentlyViewedCard key={`${item.type}-${item.id}`} item={item} />
101+
))}
102+
</div>
103+
104+
{totalPages > 1 && (
105+
<InlineStack blockAlign="center" gap="2">
106+
<Button
107+
variant="ghost"
108+
size="icon"
109+
className="h-7 w-7"
110+
disabled={safePage === 0}
111+
onClick={() => setPage(safePage - 1)}
112+
>
113+
<ChevronLeft className="h-4 w-4" />
114+
</Button>
115+
<Text size="sm" className="text-muted-foreground">
116+
{safePage + 1} / {totalPages}
117+
</Text>
118+
<Button
119+
variant="ghost"
120+
size="icon"
121+
className="h-7 w-7"
122+
disabled={safePage >= totalPages - 1}
123+
onClick={() => setPage(safePage + 1)}
124+
>
125+
<ChevronRight className="h-4 w-4" />
126+
</Button>
127+
</InlineStack>
128+
)}
129+
</BlockStack>
90130
)}
91131
</BlockStack>
92132
);

src/routes/router.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { BASE_URL, IS_GITHUB_PAGES } from "@/utils/constants";
1818

1919
import RootLayout from "../components/layout/RootLayout";
2020
import { DashboardFavoritesView } from "./Dashboard/DashboardFavoritesView";
21+
import { DashboardHomeView } from "./Dashboard/DashboardHomeView";
2122
import { DashboardLayout } from "./Dashboard/DashboardLayout";
2223
import { DashboardPipelinesView } from "./Dashboard/DashboardPipelinesView";
2324
import { DashboardRecentlyViewedView } from "./Dashboard/DashboardRecentlyViewedView";
@@ -103,9 +104,7 @@ const dashboardRoute = createRoute({
103104
const dashboardIndexRoute = createRoute({
104105
getParentRoute: () => dashboardRoute,
105106
path: "/",
106-
beforeLoad: () => {
107-
throw redirect({ to: APP_ROUTES.DASHBOARD_RUNS });
108-
},
107+
component: DashboardHomeView,
109108
});
110109

111110
// Placeholder component — replaced in subsequent PRs

0 commit comments

Comments
 (0)