Skip to content

Commit

Permalink
frontend: Adding User Preferences Context and Customizable Header (#2790
Browse files Browse the repository at this point in the history
)
  • Loading branch information
jdslaugh committed Sep 18, 2023
1 parent 31c25d6 commit 6577dea
Show file tree
Hide file tree
Showing 7 changed files with 275 additions and 88 deletions.
68 changes: 50 additions & 18 deletions frontend/packages/core/src/AppLayout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ interface HeaderProps extends AppConfiguration {
* Will enable the NPS feedback component in the header
*/
enableNPS?: boolean;
/**
* Will enable the workflow search component in the header
*/
search?: boolean;
/**
* Will enable the NPS feedback component in the header
*/
feedback?: boolean;
/**
* Will enable the shortlinks component in the header
*/
shortLinks?: boolean;
/**
* Will enable the notifications component in the header
*/
notifications?: boolean;
/**
* Will enable the user information component in the header
*/
userInfo?: boolean;
}

const AppBar = styled(MuiAppBar)({
Expand Down Expand Up @@ -54,36 +74,48 @@ const Header: React.FC<HeaderProps> = ({
title = "clutch",
logo = <Logo />,
enableNPS = false,
search = true,
feedback = true,
shortLinks = true,
notifications = false,
userInfo = true,
children = null,
}) => {
const showNotifications = false;

return (
<>
<AppBar position="fixed" elevation={0}>
<Toolbar>
<Link to="/">{typeof logo === "string" ? <StyledLogo src={logo} /> : logo}</Link>
<Title>{title}</Title>
<Grid container alignItems="center" justifyContent="flex-end">
<Box>
<SearchField />
</Box>
<SimpleFeatureFlag feature="shortLinks">
<FeatureOn>
<ShortLinker />
</FeatureOn>
</SimpleFeatureFlag>
{showNotifications && <Notifications />}
{enableNPS ? (
<NPSHeader />
) : (
<SimpleFeatureFlag feature="npsHeader">
{search && (
<Box>
<SearchField />
</Box>
)}
{shortLinks && (
<SimpleFeatureFlag feature="shortLinks">
<FeatureOn>
<NPSHeader />
<ShortLinker />
</FeatureOn>
</SimpleFeatureFlag>
)}

<UserInformation />
{notifications && <Notifications />}
{feedback && (
<>
{enableNPS ? (
<NPSHeader />
) : (
<SimpleFeatureFlag feature="npsHeader">
<FeatureOn>
<NPSHeader />
</FeatureOn>
</SimpleFeatureFlag>
)}
</>
)}
{children && children}
{userInfo && <UserInformation />}
</Grid>
</Toolbar>
</AppBar>
Expand Down
10 changes: 8 additions & 2 deletions frontend/packages/core/src/AppLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,18 @@ const MainContent = styled.div({ overflowY: "auto", width: "100%" });
interface AppLayoutProps {
isLoading?: boolean;
configuration?: AppConfiguration;
header?: React.ReactElement<any>;
}

const AppLayout: React.FC<AppLayoutProps> = ({ isLoading = false, configuration = {} }) => {
const AppLayout: React.FC<AppLayoutProps> = ({
isLoading = false,
configuration = {},
header = null,
}) => {
return (
<AppGrid container direction="column" data-testid="app-layout-component">
<Header {...configuration} />
{header && React.cloneElement(header, { ...configuration, ...header.props })}
{!header && <Header {...configuration} />}
<ContentGrid container wrap="nowrap">
{isLoading ? (
<Loadable isLoading={isLoading} variant="overlay" />
Expand Down
20 changes: 18 additions & 2 deletions frontend/packages/core/src/AppLayout/user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "@mui/material";
import Cookies from "js-cookie";
import jwtDecode from "jwt-decode";
import * as _ from "lodash";

const UserPhoto = styled(IconButton)({
padding: "12px",
Expand Down Expand Up @@ -162,7 +163,11 @@ export interface UserInformationProps {
}

// TODO (sperry): investigate using popover instead of popper
const UserInformation: React.FC<UserInformationProps> = ({ data, user = userId() }) => {
const UserInformation: React.FC<UserInformationProps> = ({
data,
user = userId(),
children = null,
}) => {
const userInitials = user.slice(0, 2).toUpperCase();
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef(null);
Expand All @@ -172,12 +177,14 @@ const UserInformation: React.FC<UserInformationProps> = ({ data, user = userId()
};

const handleClose = event => {
if (event.target.localName === "body") {
return;
}
if (anchorRef.current && anchorRef.current.contains(event.target)) {
return;
}
setOpen(false);
};

const handleListKeyDown = event => {
if (event.key === "Tab") {
event.preventDefault();
Expand Down Expand Up @@ -219,6 +226,15 @@ const UserInformation: React.FC<UserInformationProps> = ({ data, user = userId()
{i > 0 && i < data.length && <Divider />}
</>
))}
{_.castArray(children).length > 0 && <Divider />}
<div style={{ marginBottom: "8px" }}>
{_.castArray(children)?.map((c, i) => (
<>
<MenuItem>{c}</MenuItem>
{i < _.castArray(children).length - 1 && <Divider />}
</>
))}
</div>
</MenuList>
</ClickAwayListener>
</Paper>
Expand Down
169 changes: 105 additions & 64 deletions frontend/packages/core/src/AppProvider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Bugsnag from "@bugsnag/js";
import BugsnagPluginReact from "@bugsnag/plugin-react";

import AppLayout from "../AppLayout";
import { ApplicationContext, ShortLinkContext } from "../Contexts";
import { ApplicationContext, ShortLinkContext, UserPreferencesProvider } from "../Contexts";
import type { HeaderItem, TriggeredHeaderData } from "../Contexts/app-context";
import type { ShortLinkContextProps } from "../Contexts/short-link-context";
import type { HydratedData, HydrateState } from "../Contexts/workflow-storage-context/types";
Expand Down Expand Up @@ -59,26 +59,41 @@ const featureFlagFilter = (workflows: Workflow[]): Promise<Workflow[]> => {
);
};

enum ChildType {
HEADER = "header",
}

interface ChildTypes {
type: ChildType;
}

type ClutchAppChildType = ChildTypes;

type ClutchAppChild = React.ReactElement<ClutchAppChildType>;

interface ClutchAppProps {
availableWorkflows: {
[key: string]: () => WorkflowConfiguration;
};
configuration: UserConfiguration;
appConfiguration: AppConfiguration;
children?: ClutchAppChild | ClutchAppChild[];
}

const ClutchApp: React.FC<ClutchAppProps> = ({
const ClutchApp = ({
availableWorkflows,
configuration: userConfiguration,
appConfiguration,
}) => {
children,
}: ClutchAppProps) => {
const [workflows, setWorkflows] = React.useState<Workflow[]>([]);
const [isLoading, setIsLoading] = React.useState<boolean>(true);
const [workflowSessionStore, setWorkflowSessionStore] = React.useState<HydratedData>();
const [hydrateState, setHydrateState] = React.useState<HydrateState | null>(null);
const [hydrateError, setHydrateError] = React.useState<ClutchError | null>(null);
const [hasCustomLanding, setHasCustomLanding] = React.useState<boolean>(false);
const [triggeredHeaderData, setTriggeredHeaderData] = React.useState<TriggeredHeaderData>();
const [customHeader, setCustomHeader] = React.useState<React.ReactElement>();

/** Used to control a race condition from displaying the workflow and the state being updated with the hydrated data */
const [shortLinkLoading, setShortLinkLoading] = React.useState<boolean>(false);
Expand Down Expand Up @@ -129,85 +144,111 @@ const ClutchApp: React.FC<ClutchAppProps> = ({
[discoverableWorkflows, triggeredHeaderData]
);

React.useEffect(() => {
if (children) {
React.Children.forEach(children, child => {
if (React.isValidElement(child)) {
const { type = "" } = child?.props || {};

if (type.toLowerCase() === ChildType.HEADER) {
setCustomHeader(child);
}
}
});
}
}, [children]);

return (
<Router>
{/* TODO: use the ThemeProvider for proper theming in the future
See https://github.com/lyft/clutch/commit/f6c6706b9ba29c4d4c3e5d0ac0c5d0f038203937 */}
<Theme variant="light">
<div id="App">
<ApplicationContext.Provider value={appContextValue}>
<ShortLinkContext.Provider value={shortLinkProviderProps}>
{hydrateError && (
<Toast onClose={() => setHydrateError(null)}>
Unable to retrieve short link: {hydrateError?.status?.text}
</Toast>
)}
<Routes>
<Route
path="/"
element={<AppLayout isLoading={isLoading} configuration={appConfiguration} />}
>
{!hasCustomLanding && <Route key="landing" path="" element={<Landing />} />}
{workflows.map((workflow: Workflow) => {
const workflowPath = workflow.path.replace(/^\/+/, "").replace(/\/+$/, "");
const workflowKey = workflow.path.split("/")[0];
return (
<Route
path={`${workflowPath}/`}
key={workflowKey}
element={
<ErrorBoundary workflow={workflow}>
<ShortLinkStateHydrator
sharedState={hydrateState}
clearTemporaryState={() => setHydrateState(null)}
>
{!shortLinkLoading && <Outlet />}
</ShortLinkStateHydrator>
</ErrorBoundary>
}
>
{workflow.routes.map(route => {
const heading = route.displayName
? `${workflow.displayName}: ${route.displayName}`
: workflow.displayName;
return (
<Route
key={workflow.path}
path={`${route.path.replace(/^\/+/, "").replace(/\/+$/, "")}`}
element={React.cloneElement(<route.component />, {
...route.componentProps,
heading,
})}
/>
);
})}
<Route key={`${workflow.path}/notFound`} path="*" element={<NotFound />} />
</Route>
);
})}
<UserPreferencesProvider>
<ShortLinkContext.Provider value={shortLinkProviderProps}>
{hydrateError && (
<Toast onClose={() => setHydrateError(null)}>
Unable to retrieve short link: {hydrateError?.status?.text}
</Toast>
)}
<Routes>
<Route
key="short-links"
path={`/${ShortLinkBaseRoute}/:hash`}
path="/"
element={
<ShortLinkProxy
setLoading={setShortLinkLoading}
hydrate={setHydrateState}
onError={setHydrateError}
<AppLayout
isLoading={isLoading}
configuration={appConfiguration}
header={customHeader}
/>
}
/>
<Route key="notFound" path="*" element={<NotFound />} />
</Route>
</Routes>
</ShortLinkContext.Provider>
>
{!hasCustomLanding && <Route key="landing" path="" element={<Landing />} />}
{workflows.map((workflow: Workflow) => {
const workflowPath = workflow.path.replace(/^\/+/, "").replace(/\/+$/, "");
const workflowKey = workflow.path.split("/")[0];
return (
<Route
path={`${workflowPath}/`}
key={workflowKey}
element={
<ErrorBoundary workflow={workflow}>
<ShortLinkStateHydrator
sharedState={hydrateState}
clearTemporaryState={() => setHydrateState(null)}
>
{!shortLinkLoading && <Outlet />}
</ShortLinkStateHydrator>
</ErrorBoundary>
}
>
{workflow.routes.map(route => {
const heading = route.displayName
? `${workflow.displayName}: ${route.displayName}`
: workflow.displayName;
return (
<Route
key={workflow.path}
path={`${route.path.replace(/^\/+/, "").replace(/\/+$/, "")}`}
element={React.cloneElement(<route.component />, {
...route.componentProps,
heading,
})}
/>
);
})}
<Route
key={`${workflow.path}/notFound`}
path="*"
element={<NotFound />}
/>
</Route>
);
})}
<Route
key="short-links"
path={`/${ShortLinkBaseRoute}/:hash`}
element={
<ShortLinkProxy
setLoading={setShortLinkLoading}
hydrate={setHydrateState}
onError={setHydrateError}
/>
}
/>
<Route key="notFound" path="*" element={<NotFound />} />
</Route>
</Routes>
</ShortLinkContext.Provider>
</UserPreferencesProvider>
</ApplicationContext.Provider>
</div>
</Theme>
</Router>
);
};

const BugSnagApp = props => {
const BugSnagApp = (props: ClutchAppProps) => {
if (process.env.REACT_APP_BUGSNAG_API_TOKEN) {
// eslint-disable-next-line no-underscore-dangle
if (!(Bugsnag as any)._client) {
Expand Down
1 change: 1 addition & 0 deletions frontend/packages/core/src/Contexts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export type {
WorkflowRetrieveDataFn,
WorkflowStoreDataFn,
} from "./workflow-storage-context";
export { useUserPreferences, UserPreferencesProvider } from "./preferences-context";
Loading

0 comments on commit 6577dea

Please sign in to comment.