From 6577deaa605d1250b48864bf5891332d43e68ca0 Mon Sep 17 00:00:00 2001 From: Josh Slaughter <8338893+jdslaugh@users.noreply.github.com> Date: Mon, 18 Sep 2023 09:17:01 -0700 Subject: [PATCH] frontend: Adding User Preferences Context and Customizable Header (#2790) --- .../packages/core/src/AppLayout/header.tsx | 68 +++++-- .../packages/core/src/AppLayout/index.tsx | 10 +- frontend/packages/core/src/AppLayout/user.tsx | 20 ++- .../packages/core/src/AppProvider/index.tsx | 169 +++++++++++------- frontend/packages/core/src/Contexts/index.tsx | 1 + .../core/src/Contexts/preferences-context.tsx | 85 +++++++++ frontend/packages/core/src/index.tsx | 10 +- 7 files changed, 275 insertions(+), 88 deletions(-) create mode 100644 frontend/packages/core/src/Contexts/preferences-context.tsx diff --git a/frontend/packages/core/src/AppLayout/header.tsx b/frontend/packages/core/src/AppLayout/header.tsx index 73df4676d6..e4d1410381 100644 --- a/frontend/packages/core/src/AppLayout/header.tsx +++ b/frontend/packages/core/src/AppLayout/header.tsx @@ -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)({ @@ -54,9 +74,13 @@ const Header: React.FC = ({ title = "clutch", logo = , enableNPS = false, + search = true, + feedback = true, + shortLinks = true, + notifications = false, + userInfo = true, + children = null, }) => { - const showNotifications = false; - return ( <> @@ -64,26 +88,34 @@ const Header: React.FC = ({ {typeof logo === "string" ? : logo} {title} - - - - - - - - - {showNotifications && } - {enableNPS ? ( - - ) : ( - + {search && ( + + + + )} + {shortLinks && ( + - + )} - - + {notifications && } + {feedback && ( + <> + {enableNPS ? ( + + ) : ( + + + + + + )} + + )} + {children && children} + {userInfo && } diff --git a/frontend/packages/core/src/AppLayout/index.tsx b/frontend/packages/core/src/AppLayout/index.tsx index 0f2f639f92..96487bf10a 100644 --- a/frontend/packages/core/src/AppLayout/index.tsx +++ b/frontend/packages/core/src/AppLayout/index.tsx @@ -23,12 +23,18 @@ const MainContent = styled.div({ overflowY: "auto", width: "100%" }); interface AppLayoutProps { isLoading?: boolean; configuration?: AppConfiguration; + header?: React.ReactElement; } -const AppLayout: React.FC = ({ isLoading = false, configuration = {} }) => { +const AppLayout: React.FC = ({ + isLoading = false, + configuration = {}, + header = null, +}) => { return ( -
+ {header && React.cloneElement(header, { ...configuration, ...header.props })} + {!header &&
} {isLoading ? ( diff --git a/frontend/packages/core/src/AppLayout/user.tsx b/frontend/packages/core/src/AppLayout/user.tsx index fec757910d..fd7b868332 100644 --- a/frontend/packages/core/src/AppLayout/user.tsx +++ b/frontend/packages/core/src/AppLayout/user.tsx @@ -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", @@ -162,7 +163,11 @@ export interface UserInformationProps { } // TODO (sperry): investigate using popover instead of popper -const UserInformation: React.FC = ({ data, user = userId() }) => { +const UserInformation: React.FC = ({ + data, + user = userId(), + children = null, +}) => { const userInitials = user.slice(0, 2).toUpperCase(); const [open, setOpen] = React.useState(false); const anchorRef = React.useRef(null); @@ -172,12 +177,14 @@ const UserInformation: React.FC = ({ 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(); @@ -219,6 +226,15 @@ const UserInformation: React.FC = ({ data, user = userId() {i > 0 && i < data.length && } ))} + {_.castArray(children).length > 0 && } +
+ {_.castArray(children)?.map((c, i) => ( + <> + {c} + {i < _.castArray(children).length - 1 && } + + ))} +
diff --git a/frontend/packages/core/src/AppProvider/index.tsx b/frontend/packages/core/src/AppProvider/index.tsx index f5fd526f4f..d09503a29c 100644 --- a/frontend/packages/core/src/AppProvider/index.tsx +++ b/frontend/packages/core/src/AppProvider/index.tsx @@ -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"; @@ -59,19 +59,33 @@ const featureFlagFilter = (workflows: Workflow[]): Promise => { ); }; +enum ChildType { + HEADER = "header", +} + +interface ChildTypes { + type: ChildType; +} + +type ClutchAppChildType = ChildTypes; + +type ClutchAppChild = React.ReactElement; + interface ClutchAppProps { availableWorkflows: { [key: string]: () => WorkflowConfiguration; }; configuration: UserConfiguration; appConfiguration: AppConfiguration; + children?: ClutchAppChild | ClutchAppChild[]; } -const ClutchApp: React.FC = ({ +const ClutchApp = ({ availableWorkflows, configuration: userConfiguration, appConfiguration, -}) => { + children, +}: ClutchAppProps) => { const [workflows, setWorkflows] = React.useState([]); const [isLoading, setIsLoading] = React.useState(true); const [workflowSessionStore, setWorkflowSessionStore] = React.useState(); @@ -79,6 +93,7 @@ const ClutchApp: React.FC = ({ const [hydrateError, setHydrateError] = React.useState(null); const [hasCustomLanding, setHasCustomLanding] = React.useState(false); const [triggeredHeaderData, setTriggeredHeaderData] = React.useState(); + const [customHeader, setCustomHeader] = React.useState(); /** Used to control a race condition from displaying the workflow and the state being updated with the hydrated data */ const [shortLinkLoading, setShortLinkLoading] = React.useState(false); @@ -129,6 +144,20 @@ const ClutchApp: React.FC = ({ [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 ( {/* TODO: use the ThemeProvider for proper theming in the future @@ -136,70 +165,82 @@ const ClutchApp: React.FC = ({
- - {hydrateError && ( - setHydrateError(null)}> - Unable to retrieve short link: {hydrateError?.status?.text} - - )} - - } - > - {!hasCustomLanding && } />} - {workflows.map((workflow: Workflow) => { - const workflowPath = workflow.path.replace(/^\/+/, "").replace(/\/+$/, ""); - const workflowKey = workflow.path.split("/")[0]; - return ( - - setHydrateState(null)} - > - {!shortLinkLoading && } - - - } - > - {workflow.routes.map(route => { - const heading = route.displayName - ? `${workflow.displayName}: ${route.displayName}` - : workflow.displayName; - return ( - , { - ...route.componentProps, - heading, - })} - /> - ); - })} - } /> - - ); - })} + + + {hydrateError && ( + setHydrateError(null)}> + Unable to retrieve short link: {hydrateError?.status?.text} + + )} + } - /> - } /> - - - + > + {!hasCustomLanding && } />} + {workflows.map((workflow: Workflow) => { + const workflowPath = workflow.path.replace(/^\/+/, "").replace(/\/+$/, ""); + const workflowKey = workflow.path.split("/")[0]; + return ( + + setHydrateState(null)} + > + {!shortLinkLoading && } + + + } + > + {workflow.routes.map(route => { + const heading = route.displayName + ? `${workflow.displayName}: ${route.displayName}` + : workflow.displayName; + return ( + , { + ...route.componentProps, + heading, + })} + /> + ); + })} + } + /> + + ); + })} + + } + /> + } /> + + + +
@@ -207,7 +248,7 @@ const ClutchApp: React.FC = ({ ); }; -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) { diff --git a/frontend/packages/core/src/Contexts/index.tsx b/frontend/packages/core/src/Contexts/index.tsx index 629126930d..35344f5e0b 100644 --- a/frontend/packages/core/src/Contexts/index.tsx +++ b/frontend/packages/core/src/Contexts/index.tsx @@ -7,3 +7,4 @@ export type { WorkflowRetrieveDataFn, WorkflowStoreDataFn, } from "./workflow-storage-context"; +export { useUserPreferences, UserPreferencesProvider } from "./preferences-context"; diff --git a/frontend/packages/core/src/Contexts/preferences-context.tsx b/frontend/packages/core/src/Contexts/preferences-context.tsx new file mode 100644 index 0000000000..c4fb88cd74 --- /dev/null +++ b/frontend/packages/core/src/Contexts/preferences-context.tsx @@ -0,0 +1,85 @@ +import React from "react"; + +export type ActionType = "SetPref" | "RemovePref" | "SetLocalPref" | "RemoveLocalPref"; +const STORAGE_KEY = "userPreferences"; +type State = { key: string; value?: unknown }; +type Action = { type: ActionType; payload: State }; +type Dispatch = (action: Action) => void; +type UserPreferencesProviderProps = { children: React.ReactNode }; +const DEFAULT_PREFERENCES = {} as State; +interface ContextProps { + preferences: State; + dispatch: Dispatch; +} + +type ContextType = ContextProps | undefined; + +export interface PreferencesContextProps { + preferences: { + [key: string]: unknown; + }; + getPref: (key: string) => unknown; + setPref: (key: string, value: unknown) => void; +} + +const preferencesReducer = (preferences: State, action: Action): State => { + switch (action.type) { + case "SetPref": { + const updatedPref = { ...preferences, [action.payload.key]: action.payload.value }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPref)); + } catch {} + return updatedPref; + } + case "RemovePref": { + const updatedPref = { ...preferences }; + delete updatedPref[action.payload.key]; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPref)); + } catch {} + return updatedPref; + } + case "SetLocalPref": { + return { ...preferences, [action.payload.key]: action.payload.value }; + } + case "RemoveLocalPref": { + const updatedPref = { ...preferences }; + delete updatedPref[action.payload.key]; + return updatedPref; + } + default: { + throw new Error(`Unhandled action type: ${action.type}`); + } + } +}; + +const UserPreferencesContext = React.createContext(undefined); + +const UserPreferencesProvider = ({ children }: UserPreferencesProviderProps) => { + // Load preferences as default value and if none then default to value + let pref = DEFAULT_PREFERENCES; + try { + pref = JSON.parse(localStorage.getItem(STORAGE_KEY) || ""); + } catch { + localStorage.removeItem(STORAGE_KEY); + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(pref)); + const [state, dispatch] = React.useReducer(preferencesReducer, pref); + + const value = React.useMemo(() => ({ preferences: state, dispatch }), [state, dispatch]); + + return ( + {children} + ); +}; + +const useUserPreferences = () => { + const context = React.useContext(UserPreferencesContext); + if (!context) { + throw new Error("useUserPreferences was invoked outside of a valid context"); + } + return context; +}; + +export { useUserPreferences, UserPreferencesProvider }; diff --git a/frontend/packages/core/src/index.tsx b/frontend/packages/core/src/index.tsx index 3d1f019a59..d24df73498 100644 --- a/frontend/packages/core/src/index.tsx +++ b/frontend/packages/core/src/index.tsx @@ -5,7 +5,8 @@ export { AccordionDivider, AccordionGroup, } from "./accordion"; -export { userId } from "./AppLayout/user"; +export { default as Header } from "./AppLayout/header"; +export { UserInformation, userId } from "./AppLayout/user"; export * from "./Assets/emojis"; export * from "./Assets/icons"; export { Button, ButtonGroup, ClipboardButton, IconButton } from "./button"; @@ -13,7 +14,12 @@ export { Card, CardContent, CardHeader } from "./card"; export * from "./Charts"; export * from "./chip"; export { default as Confirmation } from "./confirmation"; -export { useWorkflowStorageContext, useWizardContext, WizardContext } from "./Contexts"; +export { + useWorkflowStorageContext, + useWizardContext, + WizardContext, + useUserPreferences, +} from "./Contexts"; export { Dialog, DialogActions, DialogContent } from "./dialog"; export * from "./Feedback"; export { FeatureOff, FeatureOn, SimpleFeatureFlag } from "./flags";