Skip to content

Commit

Permalink
from feature/PRD-109-breadcrumbs (#605)
Browse files Browse the repository at this point in the history
* breadcrumbs routing

* tests

* hover link

* fixes PR - env

* added tooltip
  • Loading branch information
atzin-escandia authored Jun 5, 2024
1 parent 9b310a4 commit eb4ee2b
Show file tree
Hide file tree
Showing 14 changed files with 308 additions and 15 deletions.
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
VITE_API_BASE_URL=
VITE_FRONTEND_VERSION=
VITE_FEATURE_OS_KEY=
VITE_FEATURE_OS_KEY=
8 changes: 8 additions & 0 deletions src/app/pipelines/[namespace]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { useParams } from "react-router-dom";
import { Header } from "./Header";
import { PipelineRunsTable } from "./RunsTable";
import { useEffect } from "react";
import { useBreadcrumbsContext } from "@/layouts/AuthenticatedLayout/BreadcrumbsContext";

export default function PipelineNamespacePage() {
const { namespace } = useParams() as { namespace: string };
const { setCurrentBreadcrumbData } = useBreadcrumbsContext();

useEffect(() => {
namespace &&
setCurrentBreadcrumbData({ segment: "pipeline_detail", data: { name: namespace } });
}, [namespace]);

return (
<div>
Expand Down
7 changes: 7 additions & 0 deletions src/app/pipelines/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { Button, DataTable, Skeleton } from "@zenml-io/react-component-library";
import { getPipelineColumns } from "./columns";
import { usePipelineOverviewSearchParams } from "./service";
import Pagination from "@/components/Pagination";
import { useBreadcrumbsContext } from "@/layouts/AuthenticatedLayout/BreadcrumbsContext";
import { useEffect } from "react";

export default function PipelinesPage() {
const { setCurrentBreadcrumbData } = useBreadcrumbsContext();
const queryParams = usePipelineOverviewSearchParams();

const { data, refetch } = useAllPipelineNamespaces(
Expand All @@ -20,6 +23,10 @@ export default function PipelinesPage() {
{ throwOnError: true }
);

useEffect(() => {
setCurrentBreadcrumbData({ segment: "pipelines", data: null });
}, []);

return (
<div>
<PageHeader>
Expand Down
7 changes: 7 additions & 0 deletions src/app/runs/[id]/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@ import RunIcon from "@/assets/icons/terminal.svg?react";
import { ExecutionStatusIcon, getExecutionStatusColor } from "@/components/ExecutionStatus";
import { PageHeader } from "@/components/PageHeader";
import { usePipelineRun } from "@/data/pipeline-runs/pipeline-run-detail-query";
import { useBreadcrumbsContext } from "@/layouts/AuthenticatedLayout/BreadcrumbsContext";
import { Skeleton } from "@zenml-io/react-component-library";
import { useEffect } from "react";
import { useParams } from "react-router-dom";

export function RunsDetailHeader() {
const { runId } = useParams() as { runId: string };
const { setCurrentBreadcrumbData } = useBreadcrumbsContext();

const { data, isSuccess } = usePipelineRun({ runId }, { throwOnError: true });

useEffect(() => {
data && setCurrentBreadcrumbData({ segment: "runs", data: data });
}, [data]);

return (
<PageHeader>
<div className="flex items-center gap-1">
Expand Down
18 changes: 16 additions & 2 deletions src/app/runs/[id]/_Tabs/Overview/Details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CopyButton } from "@/components/CopyButton";
import { DisplayDate } from "@/components/DisplayDate";
import { ExecutionStatusIcon, getExecutionStatusTagColor } from "@/components/ExecutionStatus";
import { Key, Value } from "@/components/KeyValue";

import { usePipelineRun } from "@/data/pipeline-runs/pipeline-run-detail-query";
import {
CollapsibleContent,
Expand All @@ -13,15 +14,28 @@ import {
Skeleton,
Tag
} from "@zenml-io/react-component-library";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useEffect, useState } from "react";
import { useParams, useSearchParams, useNavigate } from "react-router-dom";

export function Details() {
const { runId } = useParams() as { runId: string };
const [open, setOpen] = useState(true);
const [searchParams] = useSearchParams();
const navigate = useNavigate();

const { data, isError, isPending } = usePipelineRun({ runId: runId }, { throwOnError: true });

useEffect(() => {
// To set current tab in URL
const tabParam = searchParams.get("tab");
if (!tabParam) {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set("tab", "overview");
const newPath = `${newUrl.pathname}${newUrl.search}`;
navigate(newPath, { replace: true });
}
}, [searchParams, navigate]);

if (isError) return null;
if (isPending) return <Skeleton className="h-[200px] w-full" />;

Expand Down
4 changes: 4 additions & 0 deletions src/assets/icons/slash-divider.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/icons/tool-02.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
110 changes: 110 additions & 0 deletions src/components/breadcrumbs/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import Divider from "@/assets/icons/slash-divider.svg?react";
import { useEffect, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import {
matchSegmentWithPages,
matchSegmentWithRequest,
matchSegmentWithTab,
matchSegmentWithURL
} from "./SegmentsBreadcrumbs";
import { useBreadcrumbsContext } from "@/layouts/AuthenticatedLayout/BreadcrumbsContext";
import { formatIdToTitleCase, transformToEllipsis } from "@/lib/strings";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@zenml-io/react-component-library";

type BreadcrumbData = { [key: string]: { id?: string; name?: string } };

export function Breadcrumbs() {
const { currentBreadcrumbData: data } = useBreadcrumbsContext();
const [currentData, setCurrentData] = useState<BreadcrumbData | null>(null);
const [searchParams] = useSearchParams();
const { pathname } = useLocation();

useEffect(() => {
let matchedData: BreadcrumbData = {};

const pathSegments = pathname.split("/").filter((segment: string) => segment !== "");
const segmentsToCheck: string[] = ["pipelines", "runs"];
const mainPaths = segmentsToCheck.some((segment) => pathSegments.includes(segment));

if (!mainPaths) {
const currentSegment =
pathSegments.length === 0
? "overview"
: pathSegments.includes("settings")
? pathSegments[1]
: pathSegments[0];

matchedData = matchSegmentWithPages(currentSegment);
setCurrentData(matchedData);
} else {
if (data && data.segment) {
const tabParam = searchParams.get("tab");
matchedData = matchSegmentWithRequest(data) as BreadcrumbData;

const newMatchedData = {
...matchedData,
...(tabParam && { tab: { id: tabParam, name: tabParam } })
};
setCurrentData(newMatchedData);
}
}
}, [data, searchParams, pathname]);

const totalEntries = currentData ? Object.entries(currentData).length : 0;

return (
<div className="flex">
{currentData &&
Object.entries(currentData).map(([segment, value], index: number) => {
const isLastOne = index === totalEntries - 1;

return (
<div className="flex items-center" key={index}>
{index !== 0 && <Divider className="h-4 w-4 flex-shrink-0 fill-neutral-200" />}
{segment === "tab" ? (
<div className="align-center ml-1 flex items-center">
<div>{matchSegmentWithTab(value?.name as string)}</div>
<span
className={`
${isLastOne ? "pointer-events-none text-theme-text-primary" : "text-theme-text-secondary"}
ml-1 flex items-center text-text-md font-semibold capitalize`}
>
{formatIdToTitleCase(value?.name as string)}
</span>
</div>
) : (
<Link
className={`${isLastOne || segment === "settings" ? "pointer-events-none" : ""}
${isLastOne ? "font-semibold text-theme-text-primary" : "text-theme-text-secondary"}
rounded-sm p-0.5 px-1 text-text-md capitalize hover:text-purple-900 hover:underline`}
to={matchSegmentWithURL(segment, value?.id as string)}
>
{typeof value?.name === "string" ? (
value?.name.length > 20 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="hover:text-theme-text-brand hover:underline">
{transformToEllipsis(value?.name, 20)}
</TooltipTrigger>
<TooltipContent>{value?.name}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
value?.name
)
) : (
value?.name
)}
</Link>
)}
</div>
);
})}
</div>
);
}
80 changes: 80 additions & 0 deletions src/components/breadcrumbs/SegmentsBreadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Info from "@/assets/icons/info.svg?react";
import Tools from "@/assets/icons/tool-02.svg?react";
import { routes } from "@/router/routes";

export const matchSegmentWithRequest = ({ segment, data }: { segment: string; data?: any }) => {
const routeMap: { [key: string]: { [key: string]: { id?: string | null; name?: string } } } = {
// Pipelines
pipelines: {
pipelines: { id: data?.body?.pipeline?.id, name: "pipelines" }
},
pipeline_detail: {
pipelines: { id: data?.body?.pipeline?.id, name: "pipelines" },
pipeline_detail: { id: data?.name, name: data?.name }
},
runs: {
pipelines: { id: data?.body?.pipeline?.id, name: "pipelines" },
pipeline_detail: {
id: data?.body?.pipeline?.name,
name: data?.body?.pipeline?.name
},
runs: { id: data?.id, name: data?.name }
}
};

return routeMap[segment];
};

export const matchSegmentWithPages = (segment: string): any => {
const generateRouteMap = (segments: string[], withSettings: boolean = false) => {
return segments.reduce(
(acc, name) => {
acc[name] = withSettings
? { settings: { name: "settings" }, [name]: { name } }
: { [name]: { name } };
return acc;
},
{} as { [key: string]: any }
);
};

const routeMap = {
...generateRouteMap(["onboarding", "overview", "stacks", "models", "artifacts"]),
...generateRouteMap(
[
"general",
"members",
"roles",
"updates",
"repositories",
"connectors",
"secrets",
"notifications",
"profile"
],
true
)
};

return routeMap[segment];
};

export const matchSegmentWithURL = (segment: string, id: string) => {
const routeMap: { [key: string]: string } = {
// Pipelines
pipelines: routes.pipelines.overview,
pipeline_detail: routes.pipelines.namespace(id),
runs: routes.runs.detail(id)
};

return routeMap[segment] || "#";
};

export const matchSegmentWithTab = (segment: string) => {
const routeMap: { [key: string]: JSX.Element } = {
overview: <Info className="h-5 w-5 fill-theme-text-tertiary" />,
configuration: <Tools className="h-5 w-5 fill-theme-text-tertiary" />
};

return routeMap[segment] || <Info className="h-5 w-5 fill-theme-text-tertiary" />;
};
2 changes: 2 additions & 0 deletions src/layouts/AuthenticatedLayout/AuthenticatedHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ZenMLIcon from "@/assets/icons/zenml-icon.svg?react";
import { routes } from "@/router/routes";
import { Link } from "react-router-dom";
import { UserDropdown } from "./UserDropdown";
import { Breadcrumbs } from "@/components/breadcrumbs/Breadcrumbs";

export function AuthenticatedHeader() {
return (
Expand All @@ -14,6 +15,7 @@ export function AuthenticatedHeader() {
>
<ZenMLIcon className="h-6 w-6 fill-theme-text-brand" />
</Link>
<Breadcrumbs />
<div className="ml-auto pl-3 pr-4">
<UserDropdown />
</div>
Expand Down
32 changes: 32 additions & 0 deletions src/layouts/AuthenticatedLayout/BreadcrumbsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { PropsWithChildren, createContext, useContext, useState } from "react";

type BreadcrumbsContextProps = {
currentBreadcrumbData: any;
setCurrentBreadcrumbData: any;
};

const BreadcrumbsContext = createContext<BreadcrumbsContextProps | null>(null);

export function BreadcrumbsContextProvider({
children
}: PropsWithChildren<BreadcrumbsContextProps>) {
const [currentBreadcrumbData, setCurrentBreadcrumbData] = useState<any>(null);

return (
<BreadcrumbsContext.Provider
value={{
currentBreadcrumbData,
setCurrentBreadcrumbData
}}
>
{children}
</BreadcrumbsContext.Provider>
);
}

export function useBreadcrumbsContext() {
const context = useContext(BreadcrumbsContext);
if (!context)
throw new Error("useBreadcrumbsContext must be used within a BreadcrumbsContextProvider");
return context;
}
Loading

0 comments on commit eb4ee2b

Please sign in to comment.