adding control emails to control component settings form

This commit is contained in:
Carter 2026-06-16 12:32:43 -06:00
parent 7dd7504415
commit c4999a0ba5
4 changed files with 275 additions and 38 deletions

4
package-lock.json generated
View file

@ -43,7 +43,7 @@
"mui-tel-input": "^7.0.0", "mui-tel-input": "^7.0.0",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"openweathermap-ts": "^1.2.10", "openweathermap-ts": "^1.2.10",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#control_emails",
"query-string": "^9.2.1", "query-string": "^9.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
@ -11198,7 +11198,7 @@
}, },
"node_modules/protobuf-ts": { "node_modules/protobuf-ts": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#29d9765ce93c04ad70d795b80e9873fc2fc5f600", "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#4011e9e7a75148a195e7311c41a4e6c8d6886d7b",
"dependencies": { "dependencies": {
"protobufjs": "^6.8.8" "protobufjs": "^6.8.8"
} }

View file

@ -56,7 +56,7 @@
"mui-tel-input": "^7.0.0", "mui-tel-input": "^7.0.0",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"openweathermap-ts": "^1.2.10", "openweathermap-ts": "^1.2.10",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#master", "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#control_emails",
"query-string": "^9.2.1", "query-string": "^9.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",

View file

@ -21,7 +21,8 @@ import {
} from "@mui/material"; } from "@mui/material";
import { import {
ExpandMore, ExpandMore,
Close as CloseIcon Close as CloseIcon,
Remove as RemoveIcon
} from "@mui/icons-material"; } from "@mui/icons-material";
import PeriodSelect from "common/time/PeriodSelect"; import PeriodSelect from "common/time/PeriodSelect";
import SearchSelect, { Option } from "common/SearchSelect"; import SearchSelect, { Option } from "common/SearchSelect";
@ -97,6 +98,7 @@ interface Props {
component: Component; component: Component;
componentChanged: (component: Component, formValid: boolean) => void; componentChanged: (component: Component, formValid: boolean) => void;
canEdit: boolean; canEdit: boolean;
hideControllerFields?: boolean;
} }
interface ComponentData { interface ComponentData {
@ -119,7 +121,7 @@ export default function ComponentForm(props: Props) {
const classes = useStyles(); const classes = useStyles();
const grainOptions = GrainOptions(); const grainOptions = GrainOptions();
const [{ user }] = useGlobalState(); const [{ user }] = useGlobalState();
const { device, component, componentChanged, canEdit } = props; const { device, component, componentChanged, canEdit, hideControllerFields } = props;
const [reportExpanded, setReportExpanded] = useState(false); const [reportExpanded, setReportExpanded] = useState(false);
const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true); const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true);
const [form, setForm] = useState<ComponentData>({ const [form, setForm] = useState<ComponentData>({
@ -137,7 +139,8 @@ export default function ComponentForm(props: Props) {
sensorDistance: "0", sensorDistance: "0",
}); });
const [compMode, setCompMode] = useState<any>(); const [compMode, setCompMode] = useState<any>();
const [useCustomGrain, setUseCustomGrain] = useState<boolean>(false) const [useCustomGrain, setUseCustomGrain] = useState<boolean>(false);
const [controlEmail, setControlEmail] = useState("");
//const [numCalibrations, setNumCalibrations] = useState(0) //const [numCalibrations, setNumCalibrations] = useState(0)
useEffect(() => { useEffect(() => {
@ -356,6 +359,21 @@ export default function ComponentForm(props: Props) {
setForm(f); setForm(f);
}; };
const addControlEmail = () => {
const trimmed = controlEmail.trim().toLowerCase();
if (trimmed === "" || form.component.settings.controlEmails.includes(trimmed)) return;
let f = cloneDeep(form);
f.component.settings.controlEmails.push(trimmed);
setForm(f);
setControlEmail("");
};
const removeControlEmail = (index: number) => {
let f = cloneDeep(form);
f.component.settings.controlEmails.splice(index, 1);
setForm(f);
};
const toggleMeasure = (event: any) => { const toggleMeasure = (event: any) => {
let f = cloneDeep(form); let f = cloneDeep(form);
f.measure = event.target.checked; f.measure = event.target.checked;
@ -1341,7 +1359,7 @@ export default function ComponentForm(props: Props) {
}} }}
/> />
)} )}
{isController(component.settings.type) && ( {isController(component.settings.type) && !hideControllerFields && (
<React.Fragment> <React.Fragment>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<TextField <TextField
@ -1382,6 +1400,62 @@ export default function ComponentForm(props: Props) {
/> />
</FormControl> </FormControl>
</Grid> </Grid>
<Grid size={{ xs: 12 }}>
<Typography variant="subtitle2" style={{ marginTop: 16, marginBottom: 8 }}>
Authorized Control Emails
</Typography>
<Grid container spacing={1} alignItems="center">
<Grid size={{ xs: 9 }}>
<TextField
label="Email address"
type="email"
fullWidth
variant="outlined"
size="small"
value={controlEmail}
onChange={e => setControlEmail(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
addControlEmail();
}
}}
disabled={!canEdit}
/>
</Grid>
<Grid size={{ xs: 3 }}>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={addControlEmail}
disabled={!canEdit || controlEmail.trim() === ""}>
Add
</Button>
</Grid>
</Grid>
{component.settings.controlEmails.map((email, i) => (
<Grid
container
key={i}
alignItems="center"
justifyContent="space-between"
style={{ marginTop: 4 }}>
<Grid>
<Typography variant="body2">{email}</Typography>
</Grid>
<Grid>
<IconButton
size="small"
onClick={() => removeControlEmail(i)}
disabled={!canEdit}
className={classes.redButton}>
<RemoveIcon />
</IconButton>
</Grid>
</Grid>
))}
</Grid>
</React.Fragment> </React.Fragment>
)} )}
{showDataUsageWarning && ( {showDataUsageWarning && (

View file

@ -8,6 +8,7 @@ import {
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
Divider, Divider,
FormControl,
FormControlLabel, FormControlLabel,
Grid2 as Grid, Grid2 as Grid,
IconButton, IconButton,
@ -41,8 +42,10 @@ import {
GetComponentTypeOptions, GetComponentTypeOptions,
getFriendlyName, getFriendlyName,
getMeasurements, getMeasurements,
isController isController,
} from "pbHelpers/ComponentType"; } from "pbHelpers/ComponentType";
import PeriodSelect from "common/time/PeriodSelect";
import { bestUnit, milliToX } from "common/time/duration";
import { import {
ComponentAvailabilityMap, ComponentAvailabilityMap,
DeviceAvailabilityMap, DeviceAvailabilityMap,
@ -177,6 +180,7 @@ export default function ComponentSettings(props: Props) {
const [formValid, setFormValid] = useState(false); const [formValid, setFormValid] = useState(false);
const [excludedNodes, setExcludedNodes] = useState<number[]>([]); const [excludedNodes, setExcludedNodes] = useState<number[]>([]);
const [nodeToExclude, setNodeToExclude] = useState(0); const [nodeToExclude, setNodeToExclude] = useState(0);
const [controlEmail, setControlEmail] = useState("");
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
setTabVal(newValue); setTabVal(newValue);
@ -498,6 +502,7 @@ export default function ComponentSettings(props: Props) {
component={formComponent} component={formComponent}
device={device} device={device}
canEdit={canEdit} canEdit={canEdit}
hideControllerFields={isController(formComponent.settings.type)}
componentChanged={(formComp, isValid) => { componentChanged={(formComp, isValid) => {
formComponent.settings = formComp.settings; formComponent.settings = formComp.settings;
setFormValid(isValid); setFormValid(isValid);
@ -867,6 +872,145 @@ export default function ComponentSettings(props: Props) {
); );
}; };
const addControlEmail = () => {
const trimmed = controlEmail.trim().toLowerCase();
if (trimmed === "" || formComponent.settings.controlEmails.includes(trimmed)) return;
let f = cloneDeep(formComponent);
f.settings.controlEmails.push(trimmed);
setFormComponent(f);
setControlEmail("");
};
const removeControlEmail = (index: number) => {
let f = cloneDeep(formComponent);
f.settings.controlEmails.splice(index, 1);
setFormComponent(f);
};
const handleOutputModeChanged = (event: any) => {
let f = cloneDeep(formComponent);
f.settings.defaultOutputState = event.target.value;
setFormComponent(f);
};
const handleMinCycleTimeChanged = (ms: number) => {
let f = cloneDeep(formComponent);
f.settings.minCycleTimeMs = Number(ms);
setFormComponent(f);
};
const isMinCycleTimeValid = () => {
const ext = extension(formComponent.settings.type, formComponent.settings.subtype);
if (!ext.isController) return true;
let min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0);
return formComponent.settings.minCycleTimeMs >= min;
};
const minCycleTimeDescription = () => {
const ext = extension(formComponent.settings.type, formComponent.settings.subtype);
if (!ext.isController) return "";
const min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0);
const unit = bestUnit(min);
const value = milliToX(min, unit).toString();
return "Must be at least " + value + " " + unit;
};
const controlContent = () => {
return (
<DialogContent>
<TextField
id="output-mode"
name="outputMode"
select
label="Output Mode"
value={formComponent.settings.defaultOutputState}
onChange={handleOutputModeChanged}
margin="normal"
fullWidth
variant="outlined"
disabled={!canEdit}>
<MenuItem value={0}>Auto</MenuItem>
<MenuItem value={1}>On</MenuItem>
<MenuItem value={2}>Off</MenuItem>
</TextField>
<FormControl fullWidth>
<PeriodSelect
id="minCycleTimeMs"
label="Minimum cycle time"
units={["seconds", "minutes"]}
isDisabled={!canEdit}
isError={!isMinCycleTimeValid()}
initialMs={
formComponent.settings.minCycleTimeMs
? formComponent.settings.minCycleTimeMs
: undefined
}
onChange={handleMinCycleTimeChanged}
helperText={
isMinCycleTimeValid()
? "The minimum amount of time that the component spends in a state"
: minCycleTimeDescription()
}
/>
</FormControl>
<Typography variant="subtitle2" style={{ marginTop: 16, marginBottom: 8 }}>
Authorized Control Emails
</Typography>
<Grid container spacing={1} alignItems="center">
<Grid size={{ xs: 9 }}>
<TextField
label="Email address"
type="email"
fullWidth
variant="outlined"
size="small"
value={controlEmail}
onChange={e => setControlEmail(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
addControlEmail();
}
}}
disabled={!canEdit}
/>
</Grid>
<Grid size={{ xs: 3 }}>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={addControlEmail}
disabled={!canEdit || controlEmail.trim() === ""}>
Add
</Button>
</Grid>
</Grid>
{formComponent.settings.controlEmails.map((email, i) => (
<Grid
container
key={i}
alignItems="center"
justifyContent="space-between"
style={{ marginTop: 4 }}>
<Grid>
<Typography variant="body2">{email}</Typography>
</Grid>
<Grid>
<IconButton
size="small"
onClick={() => removeControlEmail(i)}
disabled={!canEdit}
className={classNames(classes.redButton)}>
<RemoveIcon />
</IconButton>
</Grid>
</Grid>
))}
</DialogContent>
);
};
const actions = () => { const actions = () => {
return ( return (
<DialogActions> <DialogActions>
@ -1042,7 +1186,25 @@ export default function ComponentSettings(props: Props) {
scroll="paper"> scroll="paper">
{title()} {title()}
<Divider /> <Divider />
{!isController(formComponent.settings.type) && ( {isController(formComponent.settings.type) ? (
<React.Fragment>
<Tabs
value={tabVal}
indicatorColor="primary"
textColor="primary"
onChange={handleChange}>
<Tab label="General" />
<Tab label="Control" />
</Tabs>
<TabPanelMine value={tabVal} index={0}>
{content()}
</TabPanelMine>
<TabPanelMine value={tabVal} index={1}>
{controlContent()}
</TabPanelMine>
</React.Fragment>
) : (
<React.Fragment>
<Tabs <Tabs
value={tabVal} value={tabVal}
indicatorColor="primary" indicatorColor="primary"
@ -1052,7 +1214,6 @@ export default function ComponentSettings(props: Props) {
<Tab label="Overlays" /> <Tab label="Overlays" />
<Tab label="Nodes" disabled={!prevComponent} /> <Tab label="Nodes" disabled={!prevComponent} />
</Tabs> </Tabs>
)}
<TabPanelMine value={tabVal} index={0}> <TabPanelMine value={tabVal} index={0}>
{content()} {content()}
</TabPanelMine> </TabPanelMine>
@ -1072,6 +1233,8 @@ export default function ComponentSettings(props: Props) {
<TabPanelMine value={tabVal} index={2}> <TabPanelMine value={tabVal} index={2}>
{componentPrefs()} {componentPrefs()}
</TabPanelMine> </TabPanelMine>
</React.Fragment>
)}
{actions()} {actions()}
</ResponsiveDialog> </ResponsiveDialog>
)} )}