Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

frontend: Add context for workflow layout to support dynamic content #3178

Merged
merged 4 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading