Skip to content

Commit

Permalink
frontend: Add context for workflow layout to support dynamic content (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
septum authored Nov 18, 2024
1 parent edd2f15 commit f29174a
Show file tree
Hide file tree
Showing 9 changed files with 254 additions and 58 deletions.
17 changes: 10 additions & 7 deletions frontend/packages/core/src/AppProvider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = (
Expand All @@ -254,9 +255,11 @@ const ClutchApp = ({
path={`${route.path.replace(/^\/+/, "").replace(/\/+$/, "")}`}
element={
appConfiguration?.useWorkflowLayout ? (
<WorkflowLayout {...workflowLayoutProps}>
{workflowRouteComponent}
</WorkflowLayout>
<WorkflowLayoutContextProvider>
<WorkflowLayout {...workflowLayoutProps}>
{workflowRouteComponent}
</WorkflowLayout>
</WorkflowLayoutContextProvider>
) : (
workflowRouteComponent
)
Expand Down
81 changes: 81 additions & 0 deletions frontend/packages/core/src/WorkflowLayout/context.tsx
Original file line number Diff line number Diff line change
@@ -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<WorkflowLayoutContextProps>(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 (
<WorkflowLayoutContext.Provider value={providerValue}>
{children}
</WorkflowLayoutContext.Provider>
);
};

const useWorkflowLayoutContext = () => {
const location = useLocation();
const context = React.useContext<WorkflowLayoutContextProps>(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 };
62 changes: 44 additions & 18 deletions frontend/packages/core/src/WorkflowLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Workflow>;
variant?: LayoutVariant | null;
title?: string | ((params: Record<string, string>) => string);
title?: string;
subtitle?: string;
breadcrumbsOnly?: boolean;
hideHeader?: boolean;
usesContext?: boolean;
};

type StyledVariantComponentProps = {
Expand Down Expand Up @@ -64,39 +67,61 @@ 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 = ({
workflowsInPath,
variant = null,
title = null,
subtitle = null,
breadcrumbsOnly = false,
hideHeader = false,
usesContext = false,
children,
}: React.PropsWithChildren<LayoutProps>) => {
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);

Expand All @@ -111,16 +136,17 @@ const WorkflowLayout = ({
<PageHeaderBreadcrumbsWrapper>
<Breadcrumbs entries={entries} />
</PageHeaderBreadcrumbsWrapper>
{!breadcrumbsOnly && (title || subtitle) && (
{(headerTitle || headerSubtitle) && (
<PageHeaderMainContainer>
<PageHeaderInformation>
{title && (
<Title variant="h2" textTransform="capitalize">
{typeof title === "function" ? title(params) : title}
</Title>
<Loadable isLoading={headerLoading}>
<PageHeaderInformation>
{headerTitle && <Title variant="h2">{headerTitle}</Title>}
{headerSubtitle && <Subtitle variant="subtitle2">{headerSubtitle}</Subtitle>}
</PageHeaderInformation>
{context?.headerContent && (
<PageHeaderSideContent>{context.headerContent}</PageHeaderSideContent>
)}
{subtitle && <Subtitle variant="subtitle2">{subtitle}</Subtitle>}
</PageHeaderInformation>
</Loadable>
</PageHeaderMainContainer>
)}
</PageHeader>
Expand Down
2 changes: 1 addition & 1 deletion frontend/packages/core/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 4 additions & 0 deletions frontend/workflows/audit/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const register = (): WorkflowConfiguration => {
displayName: "Audit Trail",
defaultLayoutProps: {
variant: "standard",
usesContext: true,
},
routes: {
landing: {
Expand All @@ -35,6 +36,9 @@ const register = (): WorkflowConfiguration => {
description: "View audit event",
component: AuditEvent,
hideNav: true,
layoutProps: {
usesContext: false,
},
},
},
};
Expand Down
54 changes: 50 additions & 4 deletions frontend/workflows/audit/src/logs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -69,12 +70,13 @@ const AuditLog: React.FC<AuditLogProps> = ({ heading, detailsPathPrefix, downloa

const theme = useTheme();
const shrink = useMediaQuery(theme.breakpoints.down("md"));
const workflowLayoutContent = useWorkflowLayoutContext();

const genTimeRangeKey = () => `${startTime}-${endTime}-${new Date().toString()}`;
return (
<RootContainer spacing={2} direction="column" padding={theme.clutch.layout.gutter}>
{!theme.clutch.useWorkflowLayout && <Typography variant="h2">{heading}</Typography>}
<Stack direction="column" spacing={2}>

React.useEffect(() => {
if (theme.clutch.useWorkflowLayout) {
workflowLayoutContent.setHeaderContent(
<Stack
direction={shrink ? "column" : "row"}
spacing={1}
Expand Down Expand Up @@ -109,6 +111,50 @@ const AuditLog: React.FC<AuditLogProps> = ({ heading, detailsPathPrefix, downloa
</IconButton>
)}
</Stack>
);
}
}, [isLoading, shrink]);

return (
<RootContainer spacing={2} direction="column" padding={theme.clutch.layout.gutter}>
{!theme.clutch.useWorkflowLayout && <Typography variant="h2">{heading}</Typography>}
<Stack direction="column" spacing={2}>
{!theme.clutch.useWorkflowLayout && (
<Stack
direction={shrink ? "column" : "row"}
spacing={1}
sx={{
alignSelf: shrink ? "center" : "flex-end",
width: shrink ? "100%" : "inherit",
}}
>
{isLoading && (
<LoadingContainer>
<LoadingSpinner />
</LoadingContainer>
)}
<DateTimeRangeSelector
shrink={shrink}
disabled={isLoading}
start={startTime}
end={endTime}
onStartChange={setStartTime}
onEndChange={setEndTime}
onQuickSelect={(start, end) => {
setStartTime(start);
setEndTime(end);
setTimeRangeKey(genTimeRangeKey());
}}
/>
{shrink ? (
<Button text="Search" onClick={() => setTimeRangeKey(genTimeRangeKey())} />
) : (
<IconButton onClick={() => setTimeRangeKey(genTimeRangeKey())}>
<SearchIcon />
</IconButton>
)}
</Stack>
)}
{error && <Error subject={error} />}
</Stack>
<TableContainer>
Expand Down
Loading

0 comments on commit f29174a

Please sign in to comment.