diff --git a/frontend/packages/core/src/AppProvider/index.tsx b/frontend/packages/core/src/AppProvider/index.tsx index d67dca2cf7..28e29c8e27 100644 --- a/frontend/packages/core/src/AppProvider/index.tsx +++ b/frontend/packages/core/src/AppProvider/index.tsx @@ -16,6 +16,7 @@ import type { ClutchError } from "../Network/errors"; import NotFound from "../not-found"; import type { AppConfiguration } from "../Types"; import WorkflowLayout, { LayoutProps } from "../WorkflowLayout"; +import { WorkflowLayoutContextProvider } from "../WorkflowLayout/context"; import { registeredWorkflows } from "./registrar"; import ShortLinkProxy, { ShortLinkBaseRoute } from "./short-link-proxy"; @@ -225,14 +226,14 @@ const ClutchApp = ({ route.layoutProps?.variant !== undefined ? route.layoutProps?.variant : workflow.defaultLayoutProps?.variant, - breadcrumbsOnly: - route.layoutProps?.breadcrumbsOnly ?? - workflow.defaultLayoutProps?.breadcrumbsOnly ?? - false, hideHeader: route.layoutProps?.hideHeader ?? workflow.defaultLayoutProps?.hideHeader ?? false, + usesContext: + route.layoutProps?.usesContext ?? + workflow.defaultLayoutProps?.usesContext ?? + false, }; const workflowRouteComponent = ( @@ -254,9 +255,11 @@ const ClutchApp = ({ path={`${route.path.replace(/^\/+/, "").replace(/\/+$/, "")}`} element={ appConfiguration?.useWorkflowLayout ? ( - - {workflowRouteComponent} - + + + {workflowRouteComponent} + + ) : ( workflowRouteComponent ) diff --git a/frontend/packages/core/src/WorkflowLayout/context.tsx b/frontend/packages/core/src/WorkflowLayout/context.tsx new file mode 100644 index 0000000000..5b3a893270 --- /dev/null +++ b/frontend/packages/core/src/WorkflowLayout/context.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { useLocation } from "react-router-dom"; + +export interface WorkflowLayoutContextProps { + title?: string; + subtitle?: string; + headerContent?: React.ReactNode; + setTitle: (title: string) => void; + setSubtitle: (subtitle: string) => void; + setHeaderContent: (headerContent: React.ReactNode) => void; +} + +const INITIAL_STATE = { + title: null, + subtitle: null, + headerContent: null, + setTitle: () => {}, + setSubtitle: () => {}, + setHeaderContent: () => {}, +}; + +const WorkflowLayoutContext = React.createContext(INITIAL_STATE); + +const workflowLayoutContextReducer = (state, action) => { + switch (action.type) { + case "set_title": + return { ...state, title: action.payload }; + case "set_subtitle": + return { ...state, subtitle: action.payload }; + case "set_content": + return { ...state, headerContent: action.payload }; + default: + throw new Error("Unhandled action type"); + } +}; + +const WorkflowLayoutContextProvider = ({ children }: { children: React.ReactNode }) => { + const [state, dispatch] = React.useReducer(workflowLayoutContextReducer, INITIAL_STATE); + + const providerValue = React.useMemo( + () => ({ + ...state, + setTitle: (title: string) => { + dispatch({ type: "set_title", payload: title }); + }, + setSubtitle: (subtitle: string) => { + dispatch({ type: "set_subtitle", payload: subtitle }); + }, + setHeaderContent: (headerContent: string) => { + dispatch({ type: "set_content", payload: headerContent }); + }, + }), + [state] + ); + + return ( + + {children} + + ); +}; + +const useWorkflowLayoutContext = () => { + const location = useLocation(); + const context = React.useContext(WorkflowLayoutContext); + + if (!context) { + throw new Error("useWorkflowLayoutContext was invoked outside of a valid context"); + } + + // Reset state on route change + React.useEffect(() => { + context.setTitle(null); + context.setSubtitle(null); + context.setHeaderContent(null); + }, [location.pathname]); + + return context; +}; + +export { WorkflowLayoutContextProvider, useWorkflowLayoutContext }; diff --git a/frontend/packages/core/src/WorkflowLayout/index.tsx b/frontend/packages/core/src/WorkflowLayout/index.tsx index 63c3c86052..db7e4c3e29 100644 --- a/frontend/packages/core/src/WorkflowLayout/index.tsx +++ b/frontend/packages/core/src/WorkflowLayout/index.tsx @@ -5,20 +5,23 @@ import { alpha } from "@mui/material"; import type { Workflow } from "../AppProvider/workflow"; import Breadcrumbs from "../Breadcrumbs"; -import { useLocation, useParams } from "../navigation"; +import Loadable from "../loading"; +import { useLocation } from "../navigation"; import styled from "../styled"; import { Typography } from "../typography"; import { generateBreadcrumbsEntries } from "../utils"; +import { useWorkflowLayoutContext } from "./context"; + export type LayoutVariant = "standard" | "wizard"; export type LayoutProps = { workflowsInPath: Array; variant?: LayoutVariant | null; - title?: string | ((params: Record) => string); + title?: string; subtitle?: string; - breadcrumbsOnly?: boolean; hideHeader?: boolean; + usesContext?: boolean; }; type StyledVariantComponentProps = { @@ -64,26 +67,36 @@ const PageHeaderBreadcrumbsWrapper = styled("div")(({ theme }: { theme: Theme }) marginBottom: theme.spacing("xs"), })); -const PageHeaderMainContainer = styled("div")(({ theme }: { theme: Theme }) => ({ +const PageHeaderMainContainer = styled("div")({ display: "flex", + flexWrap: "wrap", + justifyContent: "space-between", alignItems: "center", - height: "70px", - marginBottom: theme.spacing("sm"), -})); + minHeight: "70px", +}); const PageHeaderInformation = styled("div")({ display: "flex", flexDirection: "column", justifyContent: "space-evenly", - height: "100%", + height: "70px", +}); + +const PageHeaderSideContent = styled("div")({ + display: "flex", + flexDirection: "column", + justifyContent: "space-evenly", + height: "70px", }); const Title = styled(Typography)({ lineHeight: 1, + textTransform: "capitalize", }); const Subtitle = styled(Typography)(({ theme }: { theme: Theme }) => ({ color: alpha(theme.colors.neutral[900], 0.45), + whiteSpace: "nowrap", })); const WorkflowLayout = ({ @@ -91,12 +104,24 @@ const WorkflowLayout = ({ variant = null, title = null, subtitle = null, - breadcrumbsOnly = false, hideHeader = false, + usesContext = false, children, }: React.PropsWithChildren) => { - const params = useParams(); + const [headerLoading, setHeaderLoading] = React.useState(usesContext); + const location = useLocation(); + const context = useWorkflowLayoutContext(); + + const headerTitle = context?.title || title; + const headerSubtitle = context?.subtitle || subtitle; + + React.useEffect(() => { + if (context) { + // Done to avoid a flash of the default title and subtitle + setTimeout(() => setHeaderLoading(false), 750); + } + }, [context]); const entries = generateBreadcrumbsEntries(workflowsInPath, location); @@ -111,16 +136,17 @@ const WorkflowLayout = ({ - {!breadcrumbsOnly && (title || subtitle) && ( + {(headerTitle || headerSubtitle) && ( - - {title && ( - - {typeof title === "function" ? title(params) : title} - + + + {headerTitle && {headerTitle}} + {headerSubtitle && {headerSubtitle}} + + {context?.headerContent && ( + {context.headerContent} )} - {subtitle && {subtitle}} - + )} diff --git a/frontend/packages/core/src/index.tsx b/frontend/packages/core/src/index.tsx index 11d509aa50..2e9abee82c 100644 --- a/frontend/packages/core/src/index.tsx +++ b/frontend/packages/core/src/index.tsx @@ -62,7 +62,7 @@ export { default as ClutchApp } from "./AppProvider"; export { useTheme } from "./AppProvider/themes"; export { ThemeProvider } from "./Theme"; export { getDisplayName } from "./utils"; -export { default as WorkflowLayout } from "./WorkflowLayout"; +export { useWorkflowLayoutContext } from "./WorkflowLayout/context"; export { default as Breadcrumbs } from "./Breadcrumbs"; export { css as EMOTION_CSS, keyframes as EMOTION_KEYFRAMES } from "@emotion/react"; diff --git a/frontend/workflows/audit/src/index.tsx b/frontend/workflows/audit/src/index.tsx index f81be44749..6947688d3e 100644 --- a/frontend/workflows/audit/src/index.tsx +++ b/frontend/workflows/audit/src/index.tsx @@ -21,6 +21,7 @@ const register = (): WorkflowConfiguration => { displayName: "Audit Trail", defaultLayoutProps: { variant: "standard", + usesContext: true, }, routes: { landing: { @@ -35,6 +36,9 @@ const register = (): WorkflowConfiguration => { description: "View audit event", component: AuditEvent, hideNav: true, + layoutProps: { + usesContext: false, + }, }, }, }; diff --git a/frontend/workflows/audit/src/logs/index.tsx b/frontend/workflows/audit/src/logs/index.tsx index e88c4fc98e..cd7ae42978 100644 --- a/frontend/workflows/audit/src/logs/index.tsx +++ b/frontend/workflows/audit/src/logs/index.tsx @@ -9,6 +9,7 @@ import { Typography, useSearchParams, useTheme, + useWorkflowLayoutContext, } from "@clutch-sh/core"; import SearchIcon from "@mui/icons-material/Search"; import { CircularProgress, Stack, Theme, useMediaQuery } from "@mui/material"; @@ -69,12 +70,13 @@ const AuditLog: React.FC = ({ heading, detailsPathPrefix, downloa const theme = useTheme(); const shrink = useMediaQuery(theme.breakpoints.down("md")); + const workflowLayoutContent = useWorkflowLayoutContext(); const genTimeRangeKey = () => `${startTime}-${endTime}-${new Date().toString()}`; - return ( - - {!theme.clutch.useWorkflowLayout && {heading}} - + + React.useEffect(() => { + if (theme.clutch.useWorkflowLayout) { + workflowLayoutContent.setHeaderContent( = ({ heading, detailsPathPrefix, downloa )} + ); + } + }, [isLoading, shrink]); + + return ( + + {!theme.clutch.useWorkflowLayout && {heading}} + + {!theme.clutch.useWorkflowLayout && ( + + {isLoading && ( + + + + )} + { + setStartTime(start); + setEndTime(end); + setTimeRangeKey(genTimeRangeKey()); + }} + /> + {shrink ? ( +