frontend/src/user/UserSettings.tsx
2025-06-25 14:10:02 -06:00

680 lines
No EOL
22 KiB
TypeScript

import {
AccountBox,
Close,
ExpandLess,
ExpandMore,
Face,
Share as ShareIcon,
Notifications as NotificationsIcon,
Email as EmailIcon,
Textsms as TextIcon,
Contrast,
} from "@mui/icons-material";
import { AppBar, Box, Button, Checkbox, CircularProgress, Collapse, Divider, FormControl, FormControlLabel, FormGroup, FormHelperText, FormLabel, Grid2, IconButton, List, ListItem, ListItemButton, ListItemIcon, ListItemText, MenuItem, Radio, RadioGroup, TextField, Theme, ToggleButton, ToggleButtonGroup, Toolbar, Tooltip, Typography } from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog";
import UserAvatar from "./UserAvatar";
import { useGlobalState, useSnackbar, useUserAPI } from "providers";
import { useEffect, useState } from "react";
import { makeStyles } from "@mui/styles";
import { blue } from "@mui/material/colors";
import ShareObject from "./ShareObject";
import { pond } from "protobuf-ts/pond";
import { User, userScope } from "models";
import SearchSelect from "common/SearchSelect";
import {Option as OptionType} from "common/SearchSelect";
import moment from "moment";
import { IconPicker } from "common/IconPicker";
import { MuiTelInput } from "mui-tel-input";
import { useThemeMode } from "theme/AppThemeProvider";
// import ContractsIcon from "products/CommonIcons/contractIcon";
import UnitsIcon from "@mui/icons-material/Category";
import { setThemeType } from "theme";
import { IsAdaptiveAgriculture } from "services/whiteLabel";
import { setDistanceUnit, setGrainUnit, setPressureUnit, setTemperatureUnit } from "utils";
const useStyles = makeStyles((theme: Theme) => {
return ({
title: {
marginLeft: theme.spacing(2),
marginRight: theme.spacing(2),
flex: 1
},
checkboxIcon: {
minWidth: theme.spacing(5),
[theme.breakpoints.up("sm")]: {
minWidth: theme.spacing(6)
},
[theme.breakpoints.up("md")]: {
minWidth: theme.spacing(7)
}
},
shareIcon: {
color: blue["600"],
"&:hover": {
color: blue["700"]
}
},
iconButton: {
margin: theme.spacing(1),
position: "absolute",
right: theme.spacing(6)
}
});
});
interface Props {
isOpen: boolean;
closeDialogCallback: () => void;
}
export default function UserSettings(props: Props) {
const themeMode = useThemeMode()
const { isOpen, closeDialogCallback } = props;
const [globalState, dispatch] = useGlobalState()
const [sharing, setSharing] = useState(false)
const userAPI = useUserAPI();
const classes = useStyles()
const { success } = useSnackbar();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [user, setUser] = useState<User>(globalState.user);
const [profileExpanded, setProfileExpanded] = useState<boolean>(false);
const [avatarExpanded, setAvatarExpanded] = useState<boolean>(false);
const [notificationsExpanded, setNotificationsExpanded] = useState<boolean>(false);
const [unitsExpanded, setUnitsExpanded] = useState<boolean>(false);
const [avatarUrl, setAvatarUrl] = useState<string>("");
// const [themeValue, setThemeValue] = useState<"light" | "dark" | "system">(themeMode.mode)
useEffect(() => {
function changeIcon(icon: string | undefined) {
let updatedUser = User.clone(user);
updatedUser.settings.avatar = icon ? icon : user.settings.avatar;
setUser(updatedUser);
}
if (avatarUrl === "") {
changeIcon(undefined);
} else {
changeIcon(avatarUrl);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [avatarUrl]);
const submit = () => {
userAPI
.updateUser(user.id(), user.protobuf())
.then(() => {
dispatch({ key: "user", value: user });
success("User settings successfully updated!");
setTemperatureUnit(user.settings.temperatureUnit);
setPressureUnit(user.settings.pressureUnit);
setDistanceUnit(user.settings.distanceUnit);
setGrainUnit(user.settings.grainUnit);
})
.catch((error: any) => {
error("Error occurred while updating your profile");
})
.finally(() => {
setIsLoading(false);
closeDialogCallback();
});
};
const setDefaultState = () => {
setIsLoading(false);
setUser(globalState.user);
};
const closeDialog = () => {
setDefaultState();
closeDialogCallback();
};
const loading = () => {
return <CircularProgress variant="indeterminate" color="primary" />;
};
const changeName = (name: string) => {
let updatedUser = User.clone(user);
updatedUser.settings.name = name;
setUser(updatedUser);
};
const changeTimezone = (tz: string) => {
let updatedUser = User.clone(user);
updatedUser.settings.timezone = tz;
setUser(updatedUser);
};
const changePhoneNumber = (newPhoneNumber: string) => {
let updatedUser = User.clone(user);
updatedUser.settings.phoneNumber = newPhoneNumber;
setUser(updatedUser);
};
const generalSettings = () => {
const { name, email, phoneNumber, timezone } = user.settings;
return (
<Grid2 container direction="column" spacing={4} justifyContent="flex-start" alignItems="flex-start">
<ShareObject
scope={userScope(user.id())}
label={""}
permissions={[
pond.Permission.PERMISSION_READ,
pond.Permission.PERMISSION_SHARE,
pond.Permission.PERMISSION_USERS,
pond.Permission.PERMISSION_WRITE
]}
isDialogOpen={sharing}
closeDialogCallback={() => setSharing(false)}
/>
<Tooltip title={"Share control of profile with another user"}>
<IconButton
aria-label="Share"
className={classes.iconButton}
onClick={() => setSharing(true)}>
<ShareIcon className={classes.shareIcon} />
</IconButton>
</Tooltip>
<Grid2>
<TextField
label="Preferred Name"
fullWidth
value={name}
helperText={"Email: " + email}
onChange={event => changeName(event.target.value.toString())}
/>
</Grid2>
<Grid2>
<FormControl component="fieldset">
<FormLabel component="legend" htmlFor="phone-number-input">
Phone Number
</FormLabel>
<MuiTelInput value={phoneNumber} onChange={changePhoneNumber} />
</FormControl>
</Grid2>
<Grid2 size={{ xs: 12 }}>
<Box>
<SearchSelect
label="Timezone"
selected={{ label: timezone, value: timezone } as OptionType}
options={moment.tz.names().map(tz => ({ label: tz, value: tz } as OptionType))}
changeSelection={(selected: OptionType | null) =>
changeTimezone(selected ? selected.value : "")
}
/>
</Box>
</Grid2>
</Grid2>
);
};
const handleChangeNotifyByDefault = (notifyByDefault: boolean) => {
let updatedUser = User.clone(user);
updatedUser.settings.notifyByDefault = notifyByDefault;
setUser(updatedUser);
};
const handleNotificationMethod = (
checked: boolean,
notificationMethod: pond.NotificationMethod
) => {
let updatedUser = User.clone(user);
let notificationMethods = updatedUser.settings.notificationMethods;
if (checked) {
if (!notificationMethods.includes(notificationMethod)) {
notificationMethods.push(notificationMethod);
}
} else {
notificationMethods = notificationMethods.filter(element => element !== notificationMethod);
}
updatedUser.settings.notificationMethods = notificationMethods;
setUser(updatedUser);
};
const notificationsSettings = () => {
const { notifyByDefault, notificationMethods } = user.settings;
return (
<Grid2 container direction="column" spacing={4} justifyContent="flex-start" alignItems="flex-start">
<Grid2>
<FormControl component="fieldset">
<FormLabel component="legend">Notification Preference</FormLabel>
<RadioGroup
aria-label="notification-default"
name="notificationDefault"
value={notifyByDefault}
onChange={event => handleChangeNotifyByDefault(event.target.value === "true")}>
<FormControlLabel value={true} control={<Radio />} label="Opt-in" />
<FormControlLabel value={false} control={<Radio />} label="Opt-out" />
</RadioGroup>
<FormHelperText>
{notifyByDefault
? "Notifications are enabled by default"
: "Notifications are disabled by default"}
</FormHelperText>
</FormControl>
</Grid2>
<Grid2>
<FormControl component="fieldset">
<FormLabel component="legend">Notification Methods</FormLabel>
<FormGroup>
<FormControlLabel
control={
<Checkbox
onChange={(_event, checked) =>
handleNotificationMethod(
checked,
pond.NotificationMethod.NOTIFICATION_METHOD_EMAIL
)
}
checked={
notificationMethods
? notificationMethods.includes(
pond.NotificationMethod.NOTIFICATION_METHOD_EMAIL
)
: false
}
/>
}
label={
<ListItem dense disableGutters>
<ListItemIcon className={classes.checkboxIcon}>
<EmailIcon />
</ListItemIcon>
<ListItemText primary="Email" />
</ListItem>
}
/>
<FormControlLabel
control={
<Checkbox
onChange={(_event, checked) =>
handleNotificationMethod(
checked,
pond.NotificationMethod.NOTIFICATION_METHOD_TEXT
)
}
checked={
notificationMethods
? notificationMethods.includes(
pond.NotificationMethod.NOTIFICATION_METHOD_TEXT
)
: false
}
/>
}
label={
<ListItem dense disableGutters>
<ListItemIcon className={classes.checkboxIcon}>
<TextIcon />
</ListItemIcon>
<ListItemText
primary="Text Message (SMS)"
secondary="Requires a valid phone number"
secondaryTypographyProps={{ variant: "caption" }}
/>
</ListItem>
}
/>
<FormControlLabel
control={
<Checkbox
onChange={(_event, checked) =>
handleNotificationMethod(
checked,
pond.NotificationMethod.NOTIFICATION_METHOD_APP
)
}
checked={
notificationMethods
? notificationMethods.includes(
pond.NotificationMethod.NOTIFICATION_METHOD_APP
)
: false
}
/>
}
label={
<ListItem dense disableGutters>
<ListItemIcon className={classes.checkboxIcon}>
<TextIcon />
</ListItemIcon>
<ListItemText
primary="In-App Notifications"
secondary="No email or phone number required"
secondaryTypographyProps={{ variant: "caption" }}
/>
</ListItem>
}
/>
</FormGroup>
</FormControl>
</Grid2>
</Grid2>
);
};
const avatarSettings = () => {
return <IconPicker url={user.settings.avatar} setUrl={setAvatarUrl} id={user.settings.id} />;
};
const handleChange = (event: React.MouseEvent<HTMLElement>, theme: "light" | "dark" | "system") => {
event.preventDefault()
themeMode.setMode(theme)
setThemeType(theme)
};
const changePressureUnit = (value: any) => {
let updatedUser = User.clone(user);
let pressureUnit: pond.PressureUnit;
switch (value) {
case 1:
pressureUnit = pond.PressureUnit.PRESSURE_UNIT_KILOPASCALS;
break;
case 2:
pressureUnit = pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER;
break;
default:
pressureUnit = pond.PressureUnit.PRESSURE_UNIT_UNKNOWN;
break;
}
updatedUser.settings.pressureUnit = pressureUnit;
setUser(updatedUser);
};
const changeTemperatureUnit = (value: any) => {
let updatedUser = User.clone(user);
let temperatureUnit: pond.TemperatureUnit;
switch (value) {
case 1:
temperatureUnit = pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS;
break;
case 2:
temperatureUnit = pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT;
break;
default:
temperatureUnit = pond.TemperatureUnit.TEMPERATURE_UNIT_UNKNOWN;
break;
}
updatedUser.settings.temperatureUnit = temperatureUnit;
setUser(updatedUser);
};
const changeDistanceUnit = (value: any) => {
let updatedUser = User.clone(user);
let distanceUnit: pond.DistanceUnit;
switch (value) {
case 1:
distanceUnit = pond.DistanceUnit.DISTANCE_UNIT_FEET;
break;
case 2:
distanceUnit = pond.DistanceUnit.DISTANCE_UNIT_METERS;
break;
default:
distanceUnit = pond.DistanceUnit.DISTANCE_UNIT_UNKNOWN;
break;
}
updatedUser.settings.distanceUnit = distanceUnit;
setUser(updatedUser);
};
const changeGrainUnit = (value: any) => {
let updatedUser = User.clone(user);
let grainUnit: pond.GrainUnit;
switch (value) {
case 1:
grainUnit = pond.GrainUnit.GRAIN_UNIT_BUSHELS;
break;
case 2:
grainUnit = pond.GrainUnit.GRAIN_UNIT_WEIGHT;
break;
default:
grainUnit = pond.GrainUnit.GRAIN_UNIT_UNKNOWN;
break;
}
updatedUser.settings.grainUnit = grainUnit;
setUser(updatedUser);
};
const unitPreferences = () => {
const { pressureUnit, temperatureUnit, distanceUnit, grainUnit } = user.settings;
return (
<Grid2 container>
<Grid2 size={{ xs: 12 }}>
<TextField
select
fullWidth
id="pressureUnit"
name="pressureUnit"
label="Pressure Unit"
value={pressureUnit ? pressureUnit : pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER}
onChange={event => changePressureUnit(event.target.value)}
margin="normal"
variant="outlined"
InputLabelProps={{ shrink: true }}>
<MenuItem value={pond.PressureUnit.PRESSURE_UNIT_KILOPASCALS}>
Kilopascals (kPa)
</MenuItem>
<MenuItem value={pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER}>
Inches of Water (iwg)
</MenuItem>
</TextField>
</Grid2>
<Grid2 size={{ xs: 12 }}>
<TextField
select
fullWidth
id="temperatureUnit"
name="temperatureUnit"
label="Temperature Unit"
value={
temperatureUnit ? temperatureUnit : pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS
}
onChange={event => changeTemperatureUnit(event.target.value)}
margin="normal"
variant="outlined"
InputLabelProps={{ shrink: true }}>
<MenuItem value={pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS}>Celsius (°C)</MenuItem>
<MenuItem value={pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT}>
Fahrenheit (°F)
</MenuItem>
</TextField>
</Grid2>
<Grid2 size={{ xs: 12 }}>
<TextField
select
fullWidth
id="distanceUnit"
name="distanceUnit"
label="Distance Unit"
value={distanceUnit ? distanceUnit : pond.DistanceUnit.DISTANCE_UNIT_METERS}
onChange={event => changeDistanceUnit(event.target.value)}
margin="normal"
variant="outlined"
InputLabelProps={{ shrink: true }}>
<MenuItem value={pond.DistanceUnit.DISTANCE_UNIT_METERS}>Meters (m)</MenuItem>
<MenuItem value={pond.DistanceUnit.DISTANCE_UNIT_FEET}>Feet (ft)</MenuItem>
</TextField>
</Grid2>
{IsAdaptiveAgriculture() && (
<Grid2 size={{ xs: 12 }}>
<TextField
select
fullWidth
id="grainUnit"
name="grainUnit"
label="Grain Unit"
value={grainUnit ? grainUnit : pond.GrainUnit.GRAIN_UNIT_BUSHELS}
onChange={event => changeGrainUnit(event.target.value)}
margin="normal"
variant="outlined"
InputLabelProps={{ shrink: true }}>
<MenuItem value={pond.GrainUnit.GRAIN_UNIT_BUSHELS}>Bushels (bu)</MenuItem>
<MenuItem value={pond.GrainUnit.GRAIN_UNIT_WEIGHT}>Tonnes (mT)</MenuItem>
</TextField>
</Grid2>
)}
</Grid2>
);
};
const themeToggle = () => {
return (
<ToggleButtonGroup
value={themeMode.mode}
exclusive
onChange={handleChange}
size="small"
sx={{margin:-1}}
>
<ToggleButton value="light">
light
</ToggleButton>
<ToggleButton value="system">
system
</ToggleButton>
<ToggleButton value="dark">
dark
</ToggleButton>
</ToggleButtonGroup>
)
}
const settingsList = () => {
return (
<List disablePadding>
<ListItem >
<ListItemIcon>
<Contrast />
</ListItemIcon>
<ListItemText primary={"Theme"} />
{themeToggle()}
</ListItem>
<Divider />
<ListItemButton onClick={() => setProfileExpanded(!profileExpanded)}>
<ListItemIcon>
<AccountBox />
</ListItemIcon>
<ListItemText primary={"Profile"} />
{profileExpanded ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={profileExpanded} timeout="auto" unmountOnExit>
<List component="div">
<ListItem>
<ListItemText disableTypography inset>
{isLoading ? loading() : generalSettings()}
</ListItemText>
</ListItem>
</List>
</Collapse>
<Divider />
<ListItemButton onClick={() => setAvatarExpanded(!avatarExpanded)}>
<ListItemIcon>
<Face />
</ListItemIcon>
<ListItemText primary={"Display Picture"} />
{avatarExpanded ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={avatarExpanded} timeout="auto" unmountOnExit>
<List
component="div"
sx={{
paddingLeft: 4,
paddingRight: 4
}}>
{isLoading ? loading() : avatarSettings()}
</List>
</Collapse>
<Divider />
<ListItemButton onClick={() => setNotificationsExpanded(!notificationsExpanded)}>
<ListItemIcon>
<NotificationsIcon />
</ListItemIcon>
<ListItemText primary="Notifications" />
{notificationsExpanded ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={notificationsExpanded} timeout="auto" unmountOnExit>
<List component="div">
<ListItem>
<ListItemText disableTypography inset>
{isLoading ? loading() : notificationsSettings()}
</ListItemText>
</ListItem>
</List>
</Collapse>
<Divider />
<ListItemButton onClick={() => setUnitsExpanded(!unitsExpanded)}>
<ListItemIcon>
<UnitsIcon />
</ListItemIcon>
<ListItemText primary="Preferred Units" />
{unitsExpanded ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={unitsExpanded} timeout="auto" unmountOnExit>
<List component="div">
<ListItem>
<ListItemText disableTypography inset>
{isLoading ? loading() : unitPreferences()}
</ListItemText>
</ListItem>
</List>
</Collapse>
{/* <Divider />
<ListItem button onClick={() => setMapsExpanded(!mapsExpanded)}>
<ListItemIcon>
<FieldsIcon />
</ListItemIcon>
<ListItemText primary={"Map Settings"} />
{mapsExpanded ? <ExpandLess /> : <ExpandMore />}
</ListItem>
<Collapse in={mapsExpanded} timeout="auto" unmountOnExit>
<List component="div">
<ListItem>
<ListItemText disableTypography inset>
{isLoading ? loading() : mapSettings()}
</ListItemText>
</ListItem>
</List>
</Collapse> */}
</List>
);
};
return (
<ResponsiveDialog
maxWidth="sm"
fullWidth
open={isOpen}
onClose={closeDialog}
aria-labelledby="user-settings-dialog">
<AppBar position="relative">
<Toolbar>
<IconButton edge="start" color="inherit" onClick={closeDialog} aria-label="close">
<Close />
</IconButton>
<UserAvatar user={user} />
<Typography variant="h6" className={classes.title}>
User Settings
</Typography>
<Button color="inherit" onClick={submit}>
Save
</Button>
</Toolbar>
</AppBar>
{settingsList()}
</ResponsiveDialog>
);
}