Merge branch 'email_control' into staging_environment
This commit is contained in:
commit
64fcc934ce
3 changed files with 295 additions and 36 deletions
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -46,7 +46,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",
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -499,6 +503,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);
|
||||||
|
|
@ -870,6 +875,167 @@ 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" sx={{ marginTop: 2, marginBottom: 1 }}>
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
<Divider sx={{ marginTop: 3, marginBottom: 2 }} />
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Email Control
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||||
|
Authorized emails can control this component by sending an email to the
|
||||||
|
control address. Contact your administrator for the control email
|
||||||
|
address. Use one of the following as the email subject:
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "action.hover",
|
||||||
|
borderRadius: 1,
|
||||||
|
padding: 1.5,
|
||||||
|
marginTop: 1,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.85rem"
|
||||||
|
}}>
|
||||||
|
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - ON</div>
|
||||||
|
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - OFF</div>
|
||||||
|
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - AUTO</div>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const actions = () => {
|
const actions = () => {
|
||||||
return (
|
return (
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|
@ -1045,7 +1211,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"
|
||||||
|
|
@ -1055,7 +1239,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>
|
||||||
|
|
@ -1075,6 +1258,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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue