blob: a92fa06529987a0f022efef3f15655907f65ec51 [file] [log] [blame]
import { Fragment, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
import {
Bars3Icon,
XMarkIcon,
QuestionMarkCircleIcon,
Squares2X2Icon,
ArrowRightOnRectangleIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/24/outline";
import { NAV_HELP, navKeys, resolveNav } from "./nav";
import { GoTriangleDown, GoTriangleRight } from "react-icons/go";
import { useAppSelector } from "../../state/hooks";
import {
Link,
Outlet,
useLocation,
useNavigate,
useOutletContext,
} from "react-router-dom";
import { Loading } from "../common/Loading";
import { useAuthData } from "../../state/authSlice";
import { VscOrganization } from "react-icons/vsc";
import { useLogoutFunction } from "@propelauth/react";
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { IoKeyOutline } from "react-icons/io5";
import { HelpVideos } from "../tutorial/HelpVideo";
import { WithHelpIcon } from "../common/WithHelpIcon";
import { TbDelta } from "react-icons/tb";
import {
DAGTemplateWithData,
DAGTemplateWithoutData,
ProjectWithData,
useDAGTemplatesByID,
useLatestDAGTemplates,
useProjectByID,
} from "../../state/api/friendlyApi";
import { useURLParams } from "../../state/urlState";
import { NavBreadCrumb } from "./NavBreadCrumb";
import { skipToken } from "@reduxjs/toolkit/dist/query";
import { SearchBar } from "./Search/search";
import { ErrorPage } from "../common/Error";
import { IconType } from "react-icons";
import { localMode } from "../../App";
const useProcessAwareLogout = () => {
if (process.env.REACT_APP_AUTH_MODE === "local") {
return null;
}
// eslint-disable-next-line
return useLogoutFunction();
};
function classNames(...classes: string[]) {
return classes.filter(Boolean).join(" ");
}
const SubMenu = (props: {
name: string;
navigate: (href: string) => void;
items: { href: string; name: string; current: boolean; icon: IconType }[];
disable: boolean;
}) => {
const { name, items, navigate } = props;
const anyCurrent = items.some((item) => item.current);
const [isExpanded, setExpanded] = useState(false || anyCurrent);
const ExpandIcon = isExpanded ? GoTriangleDown : GoTriangleRight;
const out = (
<>
<div
onClick={() => setExpanded(!isExpanded)}
className={classNames(
`${
props.disable
? "text-gray-400"
: "text-gray-300 hover:bg-gray-700 hover:text-white"
}`,
"group flex justify-between items-center px-2 py-2 text-base font-medium rounded-md"
)}
>
{name}
{props.disable ? <></> : <ExpandIcon className="cursor-pointer" />}
</div>
{isExpanded &&
items.map((item) => {
return (
<div
key={item.name}
onClick={() => navigate(item.href)}
className={classNames(
item?.current
? "bg-gray-900 text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-white",
"group flex items-center px-2 py-2 text-base font-medium rounded-md"
)}
>
<item.icon
className={classNames(
item?.current
? "text-gray-300"
: "text-gray-400 group-hover:text-gray-300",
"mr-4 flex-shrink-0 h-6 w-6"
)}
aria-hidden="true"
/>
{item.name}
</div>
);
})}
</>
);
return (
<WithHelpIcon
whichIcon={
NAV_HELP[name as keyof typeof NAV_HELP] as keyof typeof HelpVideos
}
translate="-translate-x-2 translate-y-3"
>
{out}
</WithHelpIcon>
);
};
const TopLevelMenu = (props: {
navigate: (href: string) => void;
item: { href: string; name: string; current: boolean; icon: IconType };
disable: boolean;
}) => {
const { navigate } = props;
const out = (
<>
<div
key={props.item.name}
onClick={() => navigate(props.item.href)}
className={classNames(
props.item?.current
? "bg-gray-900 text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-white",
"group flex items-center px-2 py-2 text-base font-medium rounded-md"
)}
>
<props.item.icon
className={classNames(
props.item?.current
? "text-gray-300"
: "text-gray-400 group-hover:text-gray-300",
"mr-4 flex-shrink-0 h-6 w-6"
)}
aria-hidden="true"
/>
{props.item.name}
</div>
</>
);
const hasHelpVideo =
NAV_HELP[props.item.name as keyof typeof NAV_HELP] !== undefined;
if (hasHelpVideo) {
return (
<WithHelpIcon
whichIcon={
NAV_HELP[
props.item.name as keyof typeof NAV_HELP
] as keyof typeof HelpVideos
}
translate="-translate-x-2 translate-y-3"
>
{out}
</WithHelpIcon>
);
}
return out;
};
export const API_KEY_ICON = IoKeyOutline;
const MiniSideBar = (props: {
toggleMinimize: () => void;
setSearchBarOpen: () => void;
logout: ((redirect: boolean) => void) | null;
}) => {
const navigate = useNavigate();
return (
<div className="w-20 h-full border-r-gray-700 border-r-2">
<div className="flex flex-col mt-6 h-full items-center gap-3">
<img
src="/logo.png"
className={classNames("hover:cursor-pointer", " h-10 w-10")}
aria-hidden="true"
title="Open/Close Side Bar"
onClick={() => props.toggleMinimize()}
/>
<WithHelpIcon
whichIcon="PROJECT_SELECTOR"
translate="-translate-x-3 translate-y-3"
>
<Squares2X2Icon
onClick={() => navigate("/dashboard/projects")}
className={classNames(
"text-gray-300 hover:bg-gray-700 hover:text-white hover:cursor-pointer rounded-md border-0 border-transparent",
" h-10 w-10"
)}
aria-hidden="true"
title="Projects"
/>
</WithHelpIcon>
<MagnifyingGlassIcon
onClick={() => props.setSearchBarOpen()}
className={classNames(
"text-gray-300 hover:bg-gray-700 hover:text-white hover:cursor-pointer rounded-md border-0 border-transparent",
" h-10 w-10"
)}
aria-hidden="true"
title="Search"
/>
<a href="https://hamilton.dagworks.io/en/latest/concepts/ui">
<QuestionMarkCircleIcon
className={classNames(
"text-gray-300 hover:bg-gray-700 hover:text-white hover:cursor-pointer rounded-md",
"h-10 w-10"
)}
aria-hidden="true"
title="Getting Started Guide"
/>
</a>
<div>
{!localMode && (
<WithHelpIcon
whichIcon="API_KEYS"
translate="-translate-x-3 translate-y-3"
>
{
<API_KEY_ICON
onClick={() => navigate("/dashboard/settings")}
className={classNames(
"text-gray-300 hover:bg-gray-700 hover:text-white hover:cursor-pointer rounded-md border-0 border-transparent",
" h-10 w-10"
)}
aria-hidden="true"
title="API Keys"
/>
}
</WithHelpIcon>
)}
</div>
<div>
<Link to="/dashboard/account">
<VscOrganization
className={classNames(
"text-gray-300 hover:bg-gray-700 hover:text-white hover:cursor-pointer rounded-md border-0 border-transparent",
" h-10 w-10"
)}
title="Account Settings"
/>
</Link>
</div>
<div
onClick={() =>
(props.logout !== null ? props.logout : () => void 0)(true)
}
>
{!localMode && (
<ArrowRightOnRectangleIcon
className={classNames(
"text-gray-300 hover:bg-gray-700 hover:text-white hover:cursor-pointer rounded-md",
" h-10 w-10"
)}
aria-hidden="true"
title="Logout"
/>
)}
</div>
</div>
</div>
);
};
const MinimizeButton = (props: {
toggleMinimize: () => void;
color: "white" | "grey";
kind: "open" | "close";
}) => {
const Icon = props.kind === "close" ? ChevronLeftIcon : ChevronRightIcon;
return (
<button
type="button"
className={`ml-1 flex h-10 w-10 items-center justify-center rounded-full
focus:outline-none focus:ring-2 focus:ring-inset mb-2 ${
props.color == "white"
? "focus:ring-white"
: "focus:ring-dwdarkblue/40"
}`}
onClick={() => props.toggleMinimize()}
>
<span className="sr-only">Close sidebar</span>
<Icon
className={`h-6 w-6 ${
props.color == "white" ? "text-white" : "text-dwdarkblue/40"
}`}
aria-hidden="true"
/>
</button>
);
};
const ProjectAwareSidebar = (props: {
setMinimized: () => void;
projectName: string;
dagTemplates: DAGTemplateWithoutData[];
projectId: number | undefined;
navSubMenus: {
header: string;
menus: {
href: string;
name: string;
current: boolean;
icon: IconType;
under: string;
}[];
}[];
topLevelNavs: {
href: string;
name: string;
current: boolean;
icon: IconType;
}[];
navigate: (path: string) => void;
userName: string;
userOrg: string;
}) => {
const navKeys = props.navSubMenus.map((item) => item.header);
// const navKeys = [...props.navSubMenus.map((item) => item.header)];
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-1 flex-col overflow-y-auto pt-4 pb-4">
<div className="border-b-2 border-b-gray-700 px-3 pb-2">
<Link to={`/dashboard/project/${props.projectId}`}>
<div className="text-gray-300 hover:underline">
{props.projectName}
</div>
</Link>
{props.dagTemplates.length > 0 ? (
<div className="text-gray-300 font-semibold flex flex-row gap-1 items-center">
<span>v. </span>
{" "}
{props.dagTemplates.flatMap((v, i) => {
const isLast = i === props.dagTemplates.length - 1;
const vNumber = (
<Link
className=" hover:text-white hover:underline"
to={`/dashboard/project/${props.projectId}/version/${v.id}/visualize`}
key={v.id}
>
{v.id}
</Link>
);
const joined = <TbDelta className="" />;
return isLast ? [vNumber] : [vNumber, joined];
})}
</div>
) : null}
</div>
<nav className="flex-1 space-y-1 mx-2 mt-1">
{props.topLevelNavs.map((item, i) => (
<TopLevelMenu
key={i}
item={item}
navigate={props.navigate}
disable={false}
/>
))}
{navKeys.map((key, i) => {
return (
<SubMenu
name={key as string}
key={i}
items={props.navSubMenus[i].menus}
navigate={props.navigate}
disable={props.navSubMenus[i].menus.length === 0}
/>
);
})}
</nav>
</div>
<div className="flex flex-shrink-0 py-3 p-3">
{/* <a href="#" className="group block flex-shrink-0"> */}
<div className="flex items-center">
<Link to="/dashboard/account">
<VscOrganization className="text-white text-3xl hover:scale-125" />
</Link>
<div className="ml-3">
<p className="text-base font-medium text-white truncate w-48">
{props.userName}
</p>
<p className="font-medium text-gray-400 group-hover:text-gray-300">
{props.userOrg}
</p>
</div>
</div>
{/* </a> */}
</div>
</div>
);
};
const SideBar = (props: {
allowMinimization: boolean;
full: boolean;
navSubMenus: {
header: string;
menus: {
href: string;
name: string;
current: boolean;
icon: IconType;
under: string;
}[];
}[];
topLevelNavs: {
href: string;
name: string;
current: boolean;
icon: IconType;
}[];
userName: string;
userOrg: string;
navigate: (path: string) => void;
setSearchBarOpen: () => void;
projectName: string;
dagTemplates: DAGTemplateWithoutData[];
projectId: number | undefined;
}) => {
const {
navSubMenus,
topLevelNavs,
userName,
userOrg,
navigate,
setSearchBarOpen,
projectName,
allowMinimization,
} = props;
const logoutBase = useProcessAwareLogout();
const logout = logoutBase
? (redirectOnLogin: boolean) => {
logoutBase(redirectOnLogin).then(() => {
// Quick way to reset state
localStorage.clear();
});
}
: null;
const [displayProjectSidebar, setDisplayProjectSidebar] = useState(true);
return (
<>
{allowMinimization && (
<div className="absolute bottom-2 left-3 z-50">
<MinimizeButton
kind={displayProjectSidebar ? "close" : "open"}
toggleMinimize={() =>
setDisplayProjectSidebar(!displayProjectSidebar)
}
color={"white"}
/>
</div>
)}
<div className="flex flex-row h-full bg-gray-800">
{/* General project nav -- this toggles between project select view and team view */}
<MiniSideBar
setSearchBarOpen={setSearchBarOpen}
logout={logout}
toggleMinimize={() =>
setDisplayProjectSidebar(!displayProjectSidebar)
}
/>
{props.full && displayProjectSidebar && (
<ProjectAwareSidebar
setMinimized={() => setDisplayProjectSidebar(false)}
projectName={projectName}
dagTemplates={props.dagTemplates}
navSubMenus={navSubMenus}
topLevelNavs={topLevelNavs}
navigate={navigate}
userName={userName}
userOrg={userOrg}
projectId={props.projectId}
/>
)}
</div>
</>
);
};
type ContextType = {
dagTemplates: DAGTemplateWithData[];
project: ProjectWithData;
};
export const useData = () => {
return useOutletContext<ContextType>();
};
const Dashboard = () => {
/**
* State for the dashboard.
* Most of this is delegated to the subcomponents.
*/
const [sidebarOpenSmallScreen, setSidebarOpenSmallScreen] = useState(false);
const [searchBarOpen, setSearchBarOpen] = useState(false);
// const { projectVersion, project, projectVersionIds, projectId } =
// useProjectGlobals();
const { versionIds, projectId } = useURLParams();
const navigate = useNavigate();
const whereAmI = useLocation();
const authData = useAppSelector(useAuthData);
/**
* Navigation/outlets.
* We redirect to the project selection page if the user has not chosen a project.
* This is actually just... this page without the sidebar.
*
* Outlets are configured in the router in App.tsx.
*/
const hasChosenProject = projectId !== undefined;
/**
* If we have not chosen a project, we have no business being here.
*/
const project = useProjectByID(
projectId !== undefined
? {
projectId: projectId,
attributeTypes: ["documentation_loom"].join(","),
}
: skipToken
);
const dagTemplateVersions = useDAGTemplatesByID(
versionIds !== undefined
? {
dagTemplateIds: versionIds.join(","),
}
: skipToken
);
// These are a little funky -- we get the latest version just so we have somewhere
// to get in the nav, in case the user wants to click "code" or "visualize"
const latestProjectVersion = useLatestDAGTemplates(
versionIds === undefined && projectId !== undefined
? {
limit: 1,
projectId: projectId,
}
: skipToken
);
const latestProjectVersionFull = useDAGTemplatesByID(
latestProjectVersion?.data !== undefined
? {
dagTemplateIds: latestProjectVersion?.data.map((i) => i.id).join(","),
}
: skipToken
);
// if (!hasChosenProject) {
// return <Navigate to="/dashboard/projects" />;
// }
// if (!hasChosenDAGTemplateVersions) {
// return <Navigate to={`/dashboard/project/${projectId}/versions`} />;
// }
const getElement = () => {
const loading =
authData?.loading ||
project.isLoading ||
project.isFetching ||
dagTemplateVersions.isLoading ||
dagTemplateVersions.isFetching;
if (loading) {
return <Loading />;
}
const error = project.error || dagTemplateVersions.error;
if (error) {
return <ErrorPage message={"Failed to load data"} />;
}
return (
<Outlet
context={{
dagTemplates: dagTemplateVersions.data,
project: project.data as ProjectWithData,
}}
/>
);
};
// Quick way to tell where we are/whether we should display the full sidebar or just the mini one
const canDisplayFullSidebar = // TODO: break the app into three levels.
whereAmI.pathname.startsWith("/dashboard/project/");
const { userName, userOrg } =
authData == null || authData.loading
? { userName: "", userOrg: "" }
: {
userName: authData.user?.email,
userOrg: authData.orgHelper?.getOrgs()[0]?.orgName || "",
};
/**
* Menus/sidebar lists -- everything is configured from nav.ts
*/
const navResolved = resolveNav(
project.data?.id?.toString(),
dagTemplateVersions?.data || latestProjectVersionFull?.data
);
const topLevelNavs = navResolved
.filter((item) => item.under === null)
.map((navMenu) => {
return {
...navMenu,
current: navMenu.href === whereAmI.pathname,
};
});
const navSubMenus = navKeys.map((key) => {
return {
header: key,
menus: navResolved
.filter((item) => item.under === key)
.map((item) => {
return {
...item,
current: item.href === whereAmI.pathname,
under: key,
};
}),
};
});
return (
<>
<SearchBar open={searchBarOpen} setOpen={setSearchBarOpen} />
<div className="flex flex-row overflow-y-clip overscroll-none">
<Transition.Root show={sidebarOpenSmallScreen} as={Fragment}>
<Dialog
as="div"
className="relative z-40 md:hidden"
onClose={setSidebarOpenSmallScreen}
>
<Transition.Child
as={Fragment}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
</Transition.Child>
<div className="fixed inset-0 z-40 flex">
<Transition.Child
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<Dialog.Panel className="flex w-full max-w-xs flex-1 flex-col bg-gray-800 z-[1000]">
<Transition.Child
as={Fragment}
enter="ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute top-0 right-0 -mr-12 pt-2">
<button
type="button"
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
onClick={() => setSidebarOpenSmallScreen(false)}
>
<span className="sr-only">Close sidebar</span>
<XMarkIcon
className="h-6 w-6 text-white"
aria-hidden="true"
/>
</button>
</div>
</Transition.Child>
{hasChosenProject && (
<SideBar
allowMinimization={canDisplayFullSidebar}
full={canDisplayFullSidebar}
topLevelNavs={topLevelNavs}
navSubMenus={navSubMenus}
userName={userName || ""}
userOrg={userOrg || ""}
navigate={navigate}
setSearchBarOpen={() => setSearchBarOpen(true)}
projectName={project.data?.name || ""}
dagTemplates={dagTemplateVersions?.data || []}
projectId={projectId}
/>
)}
</Dialog.Panel>
</Transition.Child>
<div className="w-14 flex-shrink-0">
{/* Force sidebar to shrink to fit close icon */}
</div>
</div>
</Dialog>
</Transition.Root>
{/* Static sidebar for desktop. Overflow-clip is probably not the right one, but it solves the problem of it scrolling unecessarily for now... */}
<div className="hidden md:flex md:flex-col z-50 sticky top-0 h-screen overflow-y-clip">
{/* Sidebar component, swap this element with another sidebar if you like */}
<SideBar
allowMinimization={canDisplayFullSidebar}
full={canDisplayFullSidebar}
navSubMenus={navSubMenus}
topLevelNavs={topLevelNavs}
userName={userName || ""}
userOrg={userOrg || ""}
navigate={navigate}
setSearchBarOpen={() => setSearchBarOpen(true)}
projectName={project.data?.name || ""}
dagTemplates={dagTemplateVersions?.data || []}
projectId={projectId}
/>
</div>
<div className="flex flex-1 flex-col">
<div className="sticky top-0 z-10 bg-gray-100 pl-1 sm:pl-3 sm:pt-3 md:hidden flex flex-row items-center text-gray-600">
<button
type="button"
className="inline-flex h-12 w-12 items-center justify-center rounded-md text-gray-500 hover:text-gray-900 focus:outline-none
focus:ring-2 focus:ring-inset focus:ring-indigo-500"
onClick={() => setSidebarOpenSmallScreen(true)}
>
<span className="sr-only">Open sidebar</span>
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
<main className="flex-1 px-4">
{dagTemplateVersions.isSuccess &&
project.isSuccess &&
project.data !== null && // TODO -- determine if this could be null?
hasChosenProject && (
<div className="fixed top-0 px-4 sm:px-6 md:px-8 py-4 bg-white w-full z-0 hidden md:block text-gray-600">
<NavBreadCrumb
project={project.data}
dagTemplates={dagTemplateVersions.data}
/>
</div>
)}
<div className="mx-auto max-w-10xl sm:px-6 md:px-8">
<div className="">{getElement()}</div>
</div>
</main>
</div>
</div>
</>
);
};
export default Dashboard;