added state containers, user model, and the button for user menu

This commit is contained in:
Carter 2024-10-31 20:30:11 -06:00
parent 4fd156f663
commit f602a22b1b
7 changed files with 341 additions and 17 deletions

15
package-lock.json generated
View file

@ -16,6 +16,7 @@
"@mui/styles": "^6.1.6",
"@types/classnames": "^2.3.0",
"axios": "^1.7.7",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging",
"react": "^18.3.1",
@ -25,6 +26,7 @@
"devDependencies": {
"@babel/types": "^7.25.8",
"@eslint/js": "^9.11.1",
"@types/lodash": "^4.17.13",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
@ -1857,6 +1859,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.13",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz",
"integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/long": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
@ -3626,6 +3635,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",

View file

@ -25,6 +25,7 @@
"@mui/styles": "^6.1.6",
"@types/classnames": "^2.3.0",
"axios": "^1.7.7",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging",
"react": "^18.3.1",
@ -34,6 +35,7 @@
"devDependencies": {
"@babel/types": "^7.25.8",
"@eslint/js": "^9.11.1",
"@types/lodash": "^4.17.13",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",

View file

@ -5,6 +5,7 @@ import { makeStyles } from "@mui/styles";
import { getDarkLogo, getLightLogo, getSignatureAccentColour, hasTransparentLogoBG, hideLogo } from "../services/whiteLabel";
import { Link } from "react-router-dom";
import { useThemeType } from "../hooks/useThemeType";
import UserMenu from "../user/UserMenu";
const useStyles = makeStyles((theme: Theme) => ({
appBar: {
@ -53,10 +54,20 @@ const useStyles = makeStyles((theme: Theme) => ({
height: "64px"
}
},
appBarRight: {
display: "flex",
flexDirection: "row",
marginLeft: theme.spacing(2),
marginRight: theme.spacing(1),
[theme.breakpoints.up("md")]: {
marginLeft: theme.spacing(3),
marginRight: theme.spacing(2)
}
},
}));
interface Props {
// toggleTheme: () => void;
toggleTheme: () => void;
sideIsOpen: boolean;
openSide: () => void;
// teams: Team[];
@ -65,7 +76,7 @@ interface Props {
export default function Header(props: Props) {
const { sideIsOpen, openSide} = props;
const { sideIsOpen, openSide, toggleTheme } = props;
const themeType = useThemeType();
const classes = useStyles()
@ -95,6 +106,9 @@ export default function Header(props: Props) {
</div>
)}
</Box>
<Box className={classes.appBarRight}>
<UserMenu toggleTheme={toggleTheme} />
</Box>
</Toolbar>
</AppBar>
)

View file

@ -8,7 +8,15 @@ import { or } from '../utils/types'
import LoadingScreen from './LoadingScreen'
import { pond } from "protobuf-ts/pond";
import NavigationContainer from '../navigation/NavigationContainer'
import { GlobalState, GlobalStateAction, StateProvider } from '../providers/StateContainer'
import { User } from '../models/user'
const reducer = (state: GlobalState, action: GlobalStateAction): GlobalState => {
return {
...state,
[action.key]: action.value
};
};
export default function UserWrapper() {
const [count, setCount] = useState(0)
@ -18,6 +26,7 @@ export default function UserWrapper() {
const hasFetched = useRef(false);
const [user, setUser] = useState<pond.User | undefined>(undefined)
const [message, setMessage] = useState("Loading user profile...")
const [global, setGlobal] = useState<undefined | GlobalState>(undefined)
let user_id = or(useAuth.user?.sub, "")
@ -26,7 +35,15 @@ export default function UserWrapper() {
setLoading(true)
userAPI.getUser(user_id).then(resp => {
setUser(resp)
setGlobal({
user: User.create(resp),
as: "",
showErrors: false,
userTeamPermissions: [],
backgroundTasksComplete: false
})
}).catch(err => {
console.log(err.toString())
if (err.toString().includes("CORS")) {
setMessage("CORS error")
} else {
@ -34,8 +51,8 @@ export default function UserWrapper() {
}
})
.finally(() => {
setLoading(false)
hasFetched.current = true;
setLoading(false)
hasFetched.current = true;
})
}, [])
@ -44,24 +61,25 @@ export default function UserWrapper() {
loadUser()
}, [loading])
if (!user || loading) return (
if (!user || loading || !global) return (
<LoadingScreen
message={message}
/>
)
return (
<NavigationContainer>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<StateProvider state={global} reducer={reducer}>
<NavigationContainer>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
@ -71,6 +89,8 @@ export default function UserWrapper() {
</div><p className="read-the-docs">
Coming soon
</p>
</NavigationContainer>
</NavigationContainer>
</StateProvider>
)
}

103
src/models/user.ts Normal file
View file

@ -0,0 +1,103 @@
import { pond } from "protobuf-ts/pond";
import { or } from "../utils/types";
import { cloneDeep } from "lodash";
const adminActions = [
"provision",
"upload-firmware",
"remove-devices",
"copy-token",
"recluse",
"pause-data"
];
const adminFeatures = [
"beta",
"billing",
"security",
"dev-channel",
"docs",
"maps",
"sleep",
"support",
"json",
"tasks",
"teams",
"developer"
];
export class User {
public settings: pond.UserSettings = pond.UserSettings.create();
public status: pond.UserStatus = pond.UserStatus.create();
public permissions: pond.Permission[] = [];
public preferences: pond.UserPreferences = pond.UserPreferences.create();
public static create(pb?: pond.User): User {
let my = new User();
if (pb) {
my.settings = pond.UserSettings.fromObject(cloneDeep(or(pb.settings, {})));
my.status = pond.UserStatus.fromObject(cloneDeep(or(pb.status, {})));
my.permissions = cloneDeep(pb.permissions);
my.preferences = pond.UserPreferences.fromObject(cloneDeep(or(pb.preferences, {})));
}
return my;
}
public static any(data: any): User {
return User.create(pond.User.fromObject(cloneDeep(data)));
}
public empty(): boolean {
return this.id() === "";
}
public id(): string {
return this.settings.id;
}
public name(): string {
return this.settings.name !== "" ? this.settings.name : "User " + this.settings.id;
}
public static clone(other?: User): User {
if (other) {
return User.create(
pond.User.fromObject({
settings: pond.UserSettings.fromObject(cloneDeep(or(other.settings, {}))),
status: pond.UserStatus.fromObject(cloneDeep(or(other.status, {}))),
permissions: cloneDeep(other.permissions),
preferences: pond.UserPreferences.fromObject(cloneDeep(or(other.preferences, {})))
})
);
}
return User.create();
}
public protobuf(): pond.User {
return pond.User.fromObject({
settings: this.settings,
status: this.status
});
}
public hasAdmin(): boolean {
if (this.settings.features.includes("admin")) {
return true;
}
return false;
}
public allowedTo(action: string): boolean {
if (this.hasAdmin() && adminActions.includes(action)) {
return true;
}
return this.settings.actions.includes(action);
}
public hasFeature(flag: string): boolean {
if (this.hasAdmin() && adminFeatures.includes(flag)) {
return true;
}
return this.settings.features.includes(flag);
}
}

View file

@ -0,0 +1,66 @@
import { createContext, useContext, useReducer, Dispatch } from "react";
// import { User } from "../models";
import { pond } from "protobuf-ts/pond";
import { User } from "../models/user";
export interface GlobalState {
// tags: Tag[];
user: User;
// team: Team;
as: string;
// firmware: Map<string, Firmware>;
// newStructure: boolean;
showErrors: boolean;
userTeamPermissions: pond.Permission[];
backgroundTasksComplete: boolean;
}
export interface GlobalStateAction {
key: keyof GlobalState;
value: GlobalState[keyof GlobalState];
}
export const StateContext = createContext<[GlobalState, Dispatch<GlobalStateAction>]>([
{} as GlobalState,
{} as Dispatch<GlobalStateAction>
]);
interface StateProviderProps {
state: GlobalState;
reducer: (ctx: GlobalState, action: GlobalStateAction) => GlobalState;
// user: User;
children?: any;
}
export const StateProvider = (props: StateProviderProps) => {
return (
<StateContext.Provider value={useReducer(props.reducer, props.state)}>
{props.children}
</StateContext.Provider>
);
};
//-------------State container usage-------------
// a) Functional Components (hook)
export const useGlobalState = () => useContext(StateContext);
/*
1. Import the hook:
import { useGlobalState } from "providers";
2. Access the context members by binding it (x is an example context member);
const [{x}, dispatch] = useGlobalState();
*/
// b) Class Components (context)
/*
1. Import state context:
import { StateContext } from "providers";
2. Add the context to the beginning of the class component:
static contextType = StateContext;
context!: React.ContextType<typeof StateContext>;
3. Access the context where x is a key of context:
const [{x}, dispatch] = this.context;
*/

104
src/user/UserMenu.tsx Normal file
View file

@ -0,0 +1,104 @@
import { Avatar, Button, PaletteColor, Theme, Typography, useTheme } from "@mui/material";
import { useGlobalState } from "../providers/StateContainer";
import { getSecondaryColour, getSignatureAccentColour } from "../services/whiteLabel";
import { makeStyles } from "@mui/styles";
import { Person } from "@mui/icons-material";
import { useEffect } from "react";
const useStyles = makeStyles((theme: Theme) => ({
userAvatar: {
color: "#fff",
backgroundColor: getSecondaryColour()["700" as keyof PaletteColor],
[theme.breakpoints.down("xs")]: {
width: "32px",
height: "32px"
}
},
profileButton: {
maxWidth: "50vw"
},
profileName: {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
textTransform: "capitalize",
color: getSignatureAccentColour(),
marginBottom: -3,
marginTop: -3
},
red: {
color: "var(--status-alert)"
}
}));
interface Props {
toggleTheme: () => void;
}
export default function UserMenu(props: Props) {
const { toggleTheme } = props;
const [{ user }] = useGlobalState();
const classes = useStyles();
const theme = useTheme();
const name = user.name();
const picture = user.settings.avatar;
const hasAdmin = user.hasFeature ? user.hasFeature("admin") : false;
const allowedToCopyToken = user.allowedTo("copy-token");
const hasTeams = user.hasFeature ? user.hasFeature("teams") : false;
// const hasBilling = user.settings.useTeam
// ? userTeamPermissions.includes(pond.Permission.PERMISSION_BILLING)
// : true;
useEffect(() => {
console.log(user)
}, [user])
return (
<Button
id="tour-user-menu"
// aria-owns={userMenuIsOpen ? "userMenu" : undefined}
aria-haspopup="true"
// onClick={openUserMenu}
className={classes.profileButton}>
<div style={{ display: "flex", flexDirection: "column" }}>
<Typography className={classes.profileName} variant="button">
{name}
</Typography>
{/* {team.settings.name && hasTeams && (
<Typography className={classes.profileName} variant="button">
({team.settings.name})
</Typography>
)} */}
</div>
<div style={{ marginLeft: theme.spacing(1) }}>
{/* {picture && hasTeams ? (
<div>
<UserAvatar
user={user}
className={classes.userAvatar2}
style={{ zIndex: as === "" ? 1000 : 0 }}
/>
<div style={{ position: "relative" }}>
<Avatar
src={team.settings.avatar}
className={classes.teamAvatar}
style={{ background: purple[500] }}>
<TeamIcon style={{ fontSize: 38 }} />
</Avatar>
</div>
</div>
) : picture ? (
<Avatar alt={name} src={picture} className={classes.userAvatar}>
{!picture && name}
</Avatar>
) : ( */}
<Avatar alt={name} src={picture} className={classes.userAvatar}>
{/* <Person /> */}
</Avatar>
{/* )} */}
</div>
</Button>
)
}