From f29174a62a1d4131d438608114400b69951640a0 Mon Sep 17 00:00:00 2001
From: Orlando Valverde <4467518+septum@users.noreply.github.com>
Date: Mon, 18 Nov 2024 13:36:01 -0600
Subject: [PATCH] frontend: Add context for workflow layout to support dynamic
content (#3178)
---
.../packages/core/src/AppProvider/index.tsx | 17 ++--
.../core/src/WorkflowLayout/context.tsx | 81 ++++++++++++++++++
.../core/src/WorkflowLayout/index.tsx | 62 ++++++++++----
frontend/packages/core/src/index.tsx | 2 +-
frontend/workflows/audit/src/index.tsx | 4 +
frontend/workflows/audit/src/logs/index.tsx | 54 +++++++++++-
.../src/details/components/layout.tsx | 84 +++++++++++++------
.../workflows/projectCatalog/src/index.tsx | 5 +-
.../redisexperimentation/src/index.tsx | 3 +
9 files changed, 254 insertions(+), 58 deletions(-)
create mode 100644 frontend/packages/core/src/WorkflowLayout/context.tsx
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 ? (
+
+ )}
{error && }
diff --git a/frontend/workflows/projectCatalog/src/details/components/layout.tsx b/frontend/workflows/projectCatalog/src/details/components/layout.tsx
index 55a5624c78..14fbad11c4 100644
--- a/frontend/workflows/projectCatalog/src/details/components/layout.tsx
+++ b/frontend/workflows/projectCatalog/src/details/components/layout.tsx
@@ -1,6 +1,13 @@
import React from "react";
import type { clutch as IClutch } from "@clutch-sh/api";
-import { Grid, QuickLinkGroup, useNavigate, useParams, useTheme } from "@clutch-sh/core";
+import {
+ Grid,
+ QuickLinkGroup,
+ useNavigate,
+ useParams,
+ useTheme,
+ useWorkflowLayoutContext,
+} from "@clutch-sh/core";
import type { ProjectDetailsWorkflowProps } from "../../types";
import { ProjectDetailsContext } from "../context";
@@ -35,6 +42,7 @@ const CatalogLayout = ({
);
const projInfo = React.useMemo(() => ({ projectId, projectInfo }), [projectId, projectInfo]);
const theme = useTheme();
+ const workflowLayoutContent = useWorkflowLayoutContext();
const redirectNotFound = () => navigate(`/${projectId}/notFound`, { replace: true });
@@ -64,35 +72,63 @@ const CatalogLayout = ({
return () => {};
}, [projectId]);
+ React.useEffect(() => {
+ if (theme.clutch.useWorkflowLayout) {
+ workflowLayoutContent.setTitle(
+ `${projectInfo?.name ?? projectId}${title ? ` ${title}` : ""}`
+ );
+
+ workflowLayoutContent.setSubtitle(description ?? (projectInfo?.data?.description as string));
+
+ workflowLayoutContent.setHeaderContent(
+ projectInfo ? (
+
+ ) : null
+ );
+ }
+ }, [description, projectInfo, title]);
+
return (
{!theme.clutch.useWorkflowLayout && (
-
-
-
+ <>
+
+
+
+
-
- )}
-
-
-
-
-
-
- {projectInfo && (
-
- )}
+
+
+
+
+
+
+ {projectInfo && (
+
+ )}
+
+
-
-
+ >
+ )}
{children && children}
diff --git a/frontend/workflows/projectCatalog/src/index.tsx b/frontend/workflows/projectCatalog/src/index.tsx
index 59ace5d9e2..bd9fd08c0a 100644
--- a/frontend/workflows/projectCatalog/src/index.tsx
+++ b/frontend/workflows/projectCatalog/src/index.tsx
@@ -15,7 +15,7 @@ const register = (): WorkflowConfiguration => {
displayName: "Catalog",
defaultLayoutProps: {
variant: "standard",
- breadcrumbsOnly: true,
+ usesContext: true,
},
routes: {
catalog: {
@@ -24,9 +24,6 @@ const register = (): WorkflowConfiguration => {
description: "A searchable catalog of services",
component: Catalog,
featureFlag: "projectCatalog",
- layoutProps: {
- breadcrumbsOnly: false,
- },
},
details: {
path: "/:projectId",
diff --git a/frontend/workflows/redisexperimentation/src/index.tsx b/frontend/workflows/redisexperimentation/src/index.tsx
index 859fefc8cd..b6b09ab224 100644
--- a/frontend/workflows/redisexperimentation/src/index.tsx
+++ b/frontend/workflows/redisexperimentation/src/index.tsx
@@ -13,6 +13,9 @@ const register = (): WorkflowConfiguration => {
path: "redis-experimentation",
group: "Chaos Experimentation",
displayName: "Redis Fault Injection",
+ defaultLayoutProps: {
+ variant: "standard",
+ },
routes: {
startExperiment: {
path: "/start",