From bb92077d0b522cef92fbf10ea61c661e25ddd71a Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 28 Nov 2025 16:36:59 -0600 Subject: [PATCH] finished the form for creating an interaction/alert for a device through an object --- src/objects/ObjectAlerts.tsx | 973 ------------------ src/objects/objectInteractions/Alerts.tsx | 9 +- src/objects/objectInteractions/Controls.tsx | 8 +- .../NewObjectInteraction.tsx | 908 +++++++++++++++- .../objectInteractions/ObjectInteractions.tsx | 19 +- src/pages/Bin.tsx | 4 +- 6 files changed, 916 insertions(+), 1005 deletions(-) delete mode 100644 src/objects/ObjectAlerts.tsx diff --git a/src/objects/ObjectAlerts.tsx b/src/objects/ObjectAlerts.tsx deleted file mode 100644 index ef443dc..0000000 --- a/src/objects/ObjectAlerts.tsx +++ /dev/null @@ -1,973 +0,0 @@ -import { - Accordion, - AccordionDetails, - AccordionSummary, - Box, - Button, - Card, - Checkbox, - DialogActions, - DialogContent, - DialogTitle, - FormControl, - FormControlLabel, - FormHelperText, - FormLabel, - Grid2 as Grid, - IconButton, - InputAdornment, - List, - ListItem, - ListItemIcon, - ListItemText, - ListSubheader, - MenuItem, - Select, - TextField, - Theme, - Tooltip, - Typography -} from "@mui/material"; -import { ExpandMore, RemoveCircle as RemoveIcon, AddCircle as AddIcon } from "@mui/icons-material"; -import ResponsiveDialog from "common/ResponsiveDialog"; -import { Option } from "common/SearchSelect"; -import classNames from "classnames"; -import { Component, Interaction } from "models"; -import moment from "moment"; -import { getFriendlyName, getMeasurements } from "pbHelpers/ComponentType"; -import { Measurement, Operator } from "pbHelpers/Enums"; -import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; -import { pond } from "protobuf-ts/pond"; -import { quack } from "protobuf-ts/quack"; -import { useGlobalState, useInteractionsAPI, useSnackbar } from "providers"; -import { useNotificationAPI } from "providers/pond/notificationAPI"; -import React, { useEffect, useState } from "react"; -import { capitalize, cloneDeep } from "lodash"; -import { timeOfDayDescriptor } from "pbHelpers/Interaction"; -import { TimePicker } from "@mui/x-date-pickers"; -import { getThemeType } from "theme/themeType"; -import { green, red } from "@mui/material/colors"; -import { makeStyles } from "@mui/styles"; - - - -interface Props { - linkedComponents: Map; - componentDevices: Map; - objectType: pond.ObjectType; - objectKey: string; -} - -interface Alert { - conditions: pond.InteractionCondition[]; - components: Component[]; -} - -const useStyles = makeStyles((theme: Theme) => { - return ({ - borderedContainer: { - padding: theme.spacing(1), - marginBottom: theme.spacing(2), - border: "1px solid rgba(255, 255, 255, 0.12)", - borderRadius: "4px" - }, - greenButton: { - color: green["600"] - }, - redButton: { - color: red["600"] - }, - noPadding: { - padding: 0 - }, - timeRange: { - fontSize: "0.875em", - marginTop: "20px" - }, - dark: { - backgroundColor: getThemeType() === "light" ? "rgb(245, 245, 245)" : "rgb(50, 50, 50)", - padding: 5 - }, - light: { - backgroundColor: getThemeType() === "light" ? "rgb(235, 235, 235)" : "rgb(60, 60, 60)", - padding: 5 - } - }); -}); - -export default function ObjectAlerts(props: Props) { - const { linkedComponents, componentDevices, objectKey, objectType } = props; - const classes = useStyles(); - const { openSnack } = useSnackbar(); - const [{ as }] = useGlobalState(); - const interactionsAPI = useInteractionsAPI(); - const [interactions, setInteractions] = useState([]); - const [typeOptions, setTypeOptions] = useState([]); - const [alerts, setAlerts] = useState([]); - //the interaction key to the component it is set on - const [interactionComponent, setInteractionComponent] = useState>(new Map()); - - //state variables for notifications - const notificationAPI = useNotificationAPI(); - const [recentNotifications, setRecentNotifications] = useState([]); - const [notificationsLoading, setNotificationsLoading] = useState(false); - const [totalNotifications, setTotalNotifications] = useState(0); - - //stat variables for adding new alerts - const [newAlertOpen, setNewAlertOpen] = useState(false); - const [selectedAlertComponents, setSelectedAlertComponents] = useState([]); - const [newAlertComponentType, setNewAlertComponentType] = useState( - quack.ComponentType.COMPONENT_TYPE_INVALID - ); - const [conditions, setConditions] = useState([]); - const [nodeOptions, setNodeOptions] = useState([]); - const [subtypeDropdown, setSubtypeDropdown] = useState(0); - //condition value strings - so that decimals are easier to type into the field - const [valStrings, setValStrings] = useState(["0", "0"]); - //the nodes for the subnode interactions - const [nodeOne, setNodeOne] = useState(0); - const [nodeTwo, setNodeTwo] = useState(0); - - //schedule variables - const [schedule, setSchedule] = useState( - pond.InteractionSchedule.create({ - weekdays: ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] - }) - ); - - useEffect(() => { - //loop through the linked components to get each of their interactions - //TODO-CS: consider making an api function to get the interactions for multiple components, possibly based off objects, in one call, - //to get all interactions for bin components this would solve the issue of making multiple calls as well as not having to use async await - let newInteractions: Interaction[] = []; - let icMap: Map = new Map(); - let includedOps: quack.ComponentType[] = []; - let typeOps: Option[] = []; - let index = 0; - //use async to make sure to wait for the response from the api call and have the interactions set before going to get the next one - //this prevents seperate responses from assigning to the array without knowing about interactions from those seperate responses - //for example: without async two responses come back at the same time, one has two interactions and another has one but, neither have put their interactions - //into the array yet so they both reference an empty array, the first response puts its two into an empty array and sets it but then the next one also puts - //its response into an empty array as well and sets it causing the first two to be lost - linkedComponents.forEach(async (comp, key) => { - index++; - //get the interactions for the component - let device = componentDevices.get(key); - if (device !== undefined) { - await interactionsAPI.listInteractionsByComponent(device, comp.location(), undefined, as).then(resp => { - if (resp.length > 0) { - resp.forEach(interaction => { - icMap.set(interaction.key(), key); - }); - newInteractions = newInteractions.concat(resp); - } - }); - } - //set the state variables if it is the last one in the list - if (index === linkedComponents.size) { - setInteractionComponent(icMap); - setInteractions([...newInteractions]); - } - //use the component type to the type options if it is not already present - if (!includedOps.includes(comp.type())) { - includedOps.push(comp.type()); - typeOps.push({ - label: getFriendlyName(comp.type()), - value: comp.type() - }); - } - }); - setTypeOptions(typeOps); - }, [linkedComponents, interactionsAPI, componentDevices]); - - const matchConditions = (interaction: Interaction, alert: Alert) => { - //if the number of conditions are not the same they are different - if (interaction.settings.conditions.length !== alert.conditions.length) { - return false; - } - //continure with the comparison - let matching = true; - interaction.settings.conditions.forEach((condition, i) => { - if ( - condition.comparison !== alert.conditions[i].comparison || - condition.measurementType !== alert.conditions[i].measurementType || - condition.value !== alert.conditions[i].value - ) { - matching = false; - } - }); - return matching; - }; - - //build the alerts to display - useEffect(() => { - //loop through the interactions - let currentAlerts: Alert[] = []; - interactions.forEach(interaction => { - //if the interaction sends notifications - if (interaction.settings.notifications && interaction.settings.notifications.notify) { - //determine if the interaction already has an alert in the alerts array - let similarAlert: number = -1; - currentAlerts.forEach((alert, i) => { - if (matchConditions(interaction, alert)) { - similarAlert = i; - } - }); - - //if no current alert matched the conditions add a new alert to the array - let compKey = interactionComponent.get(interaction.key()); - if (compKey) { - let component = linkedComponents.get(compKey); - if (component) { - if (similarAlert === -1) { - let newAlert: Alert = { - conditions: interaction.settings.conditions, - components: [component] - }; - currentAlerts.push(newAlert); - } else { - currentAlerts[similarAlert].components.push(component); - } - } - } - } - }); - setAlerts(currentAlerts); - }, [interactions, interactionComponent, linkedComponents]); - - //get the notifications for the components on an object - useEffect(() => { - if (notificationsLoading) return; - setNotificationsLoading(true); - notificationAPI - .listObjectNotifications(objectKey, objectType, 10, 0, undefined, undefined, undefined, as) - .then(resp => { - setRecentNotifications(resp.data.notifications); - setTotalNotifications(resp.data.total); - }) - .catch(_err => {}) - .finally(() => { - setNotificationsLoading(false); - }); - }, [objectKey, objectType, notificationAPI]); //eslint-disable-line react-hooks/exhaustive-deps - - //use the selected components to determine the lowest amount of nodes for the options - useEffect(() => { - setNodeOne(0); - setNodeTwo(0); - setSubtypeDropdown(0); - let nodeCount = 12; //start with the maximum number of nodes for a cable - linkedComponents.forEach(comp => { - if (selectedAlertComponents.includes(comp.key())) { - if (comp.lastMeasurement[0] && comp.lastMeasurement[0].values[0]) { - let nodes = comp.lastMeasurement[0].values[0].values.length; - nodeCount = nodes < nodeCount ? nodes : nodeCount; - } - } - }); - let options = []; - for (let i = 0; i <= nodeCount; i++) { - options.push( - - {i === 0 ? "None" : "Node " + i} - - ); - } - setNodeOptions(options); - }, [linkedComponents, selectedAlertComponents]); - - const loadMoreNotifications = () => { - console.log("load more"); - let current = recentNotifications; - setNotificationsLoading(true); - notificationAPI - .listObjectNotifications( - objectKey, - objectType, - 10, - current.length, - undefined, - undefined, - undefined, - as - ) - .then(resp => { - if (resp.data.notifications.length > 0) { - let c = current.concat(resp.data.notifications); - setRecentNotifications([...c]); - } - }) - .catch(_err => {}) - .finally(() => { - setNotificationsLoading(false); - }); - }; - - const readableCondition = (condition: pond.InteractionCondition) => { - let describer = describeMeasurement(condition.measurementType); - let type = describer.label(); - let comparison = - condition.comparison === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO - ? "Exactly" - : condition.comparison === quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN - ? "Above" - : "Below"; - let value = describer.toDisplay(condition.value); - return ( - - {type + " " + comparison + " " + value + describer.GetUnit()} - - ); - }; - - const alertAccordion = (alert: Alert) => { - return ( - - }> - - {alert.conditions.map((condition, i) => ( - - {readableCondition(condition)} - - ))} - - - - - Reporting Components - {alert.components.map((comp, i) => ( - {comp.name()} - ))} - - - - ); - }; - - const addInteractionToComponents = () => { - //array of device and component ids ie, [4:9-5-12, 4:9-5-13, 3:9-5-12] - let compIds: string[] = []; - selectedAlertComponents.forEach(comp => { - let devID = componentDevices.get(comp); - let component = linkedComponents.get(comp); - if (devID && component) { - compIds.push(devID + ":" + component.locationString()); - } - }); - let newAlert = pond.InteractionSettings.create(); - newAlert.conditions = conditions; - newAlert.instance = 1; - newAlert.schedule = schedule; - newAlert.subtype = Interaction.create().subtypeFromNodes(nodeOne, nodeTwo); - newAlert.result = pond.InteractionResult.create({ - type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_REPORT - }); - newAlert.notifications = pond.InteractionNotifications.create({ - notify: true - }); - interactionsAPI - .addInteractionToComponents(compIds, newAlert, as) - .then(_resp => { - openSnack("interaction added to selected components"); - }) - .catch(_err => { - openSnack("there was a problem adding the interaction to the selected components"); - }); - setNewAlertOpen(false); - }; - - //display the components that match the type that was selected for the new alert - const listMatchingComponents = () => { - let matching: Component[] = []; - linkedComponents.forEach(comp => { - if (comp.type() === newAlertComponentType) { - matching.push(comp); - } - }); - return ( - - {matching.map(comp => ( - - - { - let selected = selectedAlertComponents; - if (checked) { - selected.push(comp.key()); - } else { - selected.splice(selected.indexOf(comp.key()), 1); - } - setSelectedAlertComponents([...selected]); - }} - /> - - {comp.name()} - - ))} - - ); - }; - - const availableMeasurementTypes = (type: quack.ComponentType): quack.MeasurementType[] => { - return getMeasurements(type).map(m => m.measurementType); - }; - - const initialConditions = (type: quack.ComponentType): pond.InteractionCondition[] => { - let measurementType = availableMeasurementTypes(type)[0]; - let condition = pond.InteractionCondition.create({ - measurementType: measurementType, - comparison: measurementType === Measurement.boolean ? Operator.equals : Operator.less, - value: 0 - }); - return [condition]; - }; - - const measurementTypeMenuItems = () => { - return availableMeasurementTypes(newAlertComponentType).map(measurementType => ( - - {describeMeasurement(measurementType).label()} - - )); - }; - - const changeCondition = (newCondition: pond.InteractionCondition, index: number) => { - let c = conditions; - c[index] = newCondition; - setConditions([...c]); - }; - - const conditionGroup = (condition: pond.InteractionCondition, index: number) => { - let measurementType = condition.measurementType; - let describer = describeMeasurement(measurementType, newAlertComponentType); - let isBoolean = condition.measurementType === Measurement.boolean; - return ( - - - {index > 0 && ( - - AND - - )} - - { - condition.measurementType = +event.target.value; - changeCondition(condition, index); - }} - autoFocus={false} - margin="dense" - variant="standard" - fullWidth - InputLabelProps={{ shrink: true }}> - {measurementTypeMenuItems()} - - - - { - condition.comparison = +event.target.value; - changeCondition(condition, index); - }} - autoFocus={false} - margin="dense" - variant="standard" - fullWidth - InputLabelProps={{ shrink: true }}> - {!isBoolean && [ - - {"is below"} - , - - {"is above"} - - ]} - - {"is exactly"} - - - - - { - let strings = valStrings; - strings[index] = event.target.value; - setValStrings([...strings]); - if (!isNaN(+valStrings[index])) { - condition.value = describer.toStored(+event.target.value); - changeCondition(condition, index); - } - }} - autoFocus={false} - margin="dense" - variant="standard" - fullWidth - InputProps={{ - endAdornment: {describer.unit()} - }} - InputLabelProps={{ shrink: true }}> - {isBoolean && [ - - {describer.enumerations()[0]} - , - - {describer.enumerations()[1]} - - ]} - - - - {index === 0 ? ( - - 1} - aria-label="Add another condition" - onClick={() => { - let c = cloneDeep(conditions); - let newConditions = c.concat(initialConditions(newAlertComponentType)); - setConditions(newConditions); - }} - className={classNames(classes.greenButton, classes.noPadding)}> - - - - ) : ( - - { - let c = conditions; - c.splice(index, 1); - setConditions([...c]); - }} - className={classNames(classes.redButton, classes.noPadding)}> - - - - )} - - - {nodeOptions.length > 2 && ( - - - { - setSubtypeDropdown(+event.target.value); - }} - margin="dense" - variant="standard" - fullWidth - InputLabelProps={{ shrink: true }}> - - Any - - - Average - - - Single Node - - - Node Diff - - - Up To - - - - - {(subtypeDropdown === 2 || subtypeDropdown === 3 || subtypeDropdown === 4) && ( - { - setNodeOne(+event.target.value); - }} - margin="dense" - variant="standard" - fullWidth - InputLabelProps={{ shrink: true }}> - {nodeOptions} - - )} - - - {subtypeDropdown === 3 && ( - { - setNodeTwo(+event.target.value); - }} - margin="dense" - variant="standard" - fullWidth - InputLabelProps={{ shrink: true }}> - {nodeOptions} - - )} - - - )} - - ); - }; - - const conditionInput = () => { - // const conditionGroups: JSX.Element[] = []; - // for (let i = 0; i < conditions.length; i++) { - // conditionGroups[i] = conditionGroup(i); - // } - return ( - - Conditions - {selectedAlertComponents.length > 0 ? ( - - {conditions.map((condition, i) => conditionGroup(condition, i))} - - ) : ( - You must select a source before adding conditions - )} - - ); - }; - - const setScheduleTime = (id: string, event: any) => { - let updatedSchedule = cloneDeep(schedule); - if (id === "shortcut") { - let value = event.target.value; - if (value === "allDay") { - updatedSchedule.timeOfDayStart = "00:00"; - updatedSchedule.timeOfDayEnd = "24:00"; - } else if (value === "before") { - updatedSchedule.timeOfDayStart = "00:00"; - if (!updatedSchedule.timeOfDayEnd || updatedSchedule.timeOfDayEnd === "24:00") { - updatedSchedule.timeOfDayEnd = "23:59"; - } - } else if (value === "after") { - updatedSchedule.timeOfDayEnd = "24:00"; - if (updatedSchedule.timeOfDayStart === "00:00") { - updatedSchedule.timeOfDayStart = "00:01"; - } - } else { - if (updatedSchedule.timeOfDayStart === "00:00") { - updatedSchedule.timeOfDayStart = "00:01"; - } - if (updatedSchedule.timeOfDayEnd === "24:00") { - updatedSchedule.timeOfDayEnd = "23:59"; - } - } - } else { - let timeOfDay = - event - .hour() - .toString() - .padStart(2, "0") + - ":" + - event - .minute() - .toString() - .padStart(2, "0"); - if (id === "start") { - updatedSchedule.timeOfDayStart = timeOfDay; - } else if (id === "end") { - if (timeOfDay === "00:00") { - timeOfDay = "24:00"; - } - updatedSchedule.timeOfDayEnd = timeOfDay; - } - } - updatedSchedule.timezone = moment.tz.guess(); - setSchedule(updatedSchedule); - }; - - const toggleDaySelected = (day: string, event: any) => { - let updatedSchedule = cloneDeep(schedule); - if (event.target.checked) { - if (!updatedSchedule.weekdays.includes(day)) { - updatedSchedule.weekdays.push(day); - } - } else { - updatedSchedule.weekdays.splice(updatedSchedule.weekdays.indexOf(day), 1); - } - setSchedule(updatedSchedule); - }; - - const daySelector = (day: string, size: any) => { - //const schedule = or(interaction.settings.schedule, pond.InteractionSchedule.create()); - return ( - - toggleDaySelected(day, event)} - value={day} - color="secondary" - /> - } - label={capitalize(day.substr(0, 2))} - labelPlacement="bottom" - /> - - ); - }; - - const scheduler = () => { - //let schedule = pond.InteractionSchedule.create(); - let timezone = moment.tz.guess(); - let todStart = "00:00".split(":"); - let todEnd = "23:59".split(":"); - let start = moment - .tz(timezone) - .startOf("day") - .add(todStart[0], "hours") - .add(todStart[1], "minutes") - .local(); - let end = moment - .tz(timezone) - .startOf("day") - .add(todEnd[0], "hours") - .add(todEnd[1], "minutes") - .local(); - let descriptor = timeOfDayDescriptor(schedule); - let showStart = descriptor === "after" || descriptor === "from"; - let showEnd = descriptor === "before" || descriptor === "from"; - let showBoth = showStart && showEnd; - return ( - - Schedule - - {daySelector("sunday", 3)} - {daySelector("monday", 3)} - {daySelector("tuesday", 3)} - {daySelector("wednesday", 3)} - {daySelector("thursday", 4)} - {daySelector("friday", 4)} - {daySelector("saturday", 4)} - - - - setScheduleTime("shortcut", event)} - fullWidth - autoFocus={false} - margin="normal" - variant="standard" - InputLabelProps={{ shrink: true }}> - - All Day - - - Before - - - After - - - From - - - - {showStart && ( - - } - value={start} - onChange={(event: any) => setScheduleTime("start", event)} - /> - - )} - {showBoth && ( - - - to - - - )} - {showEnd && ( - - } - value={end} - onChange={(event: any) => setScheduleTime("end", event)} - /> - - )} - - - ); - }; - - const newAlertDialog = () => { - return ( - { - setNewAlertOpen(false); - }}> - Set New Alert - - {/* Dropdown to select the component type to add the interaction to */} - - {/* list of checkboxes for the components that match that type */} - {listMatchingComponents()} - {/* have the conditions set here */} - {conditionInput()} - {/* have the schedule set here */} - {scheduler()} - - - - - - - ); - }; - - const alertDisplay = () => { - return ( - - Alerts - {alerts.map((alert, i) => ( - {alertAccordion(alert)} - ))} - - ); - }; - - const notificationList = () => { - return ( - - Notifications - {recentNotifications && ( - - {recentNotifications.map((notification, i) => ( - - - {moment(notification.status?.timestamp).format("MMMM DD, YYYY - h:mm a")} - {" "} - :{" "} - - {notification.settings?.title + " - " + notification.settings?.subtitle} - - - ))} - {recentNotifications.length < totalNotifications && ( - - - - )} - - )} - - ); - }; - - return ( - - - Alerts & Notifications - - - {alertDisplay()} - {notificationList()} - {newAlertDialog()} - - ); -} diff --git a/src/objects/objectInteractions/Alerts.tsx b/src/objects/objectInteractions/Alerts.tsx index 562216b..0875a22 100644 --- a/src/objects/objectInteractions/Alerts.tsx +++ b/src/objects/objectInteractions/Alerts.tsx @@ -14,11 +14,12 @@ export interface Alert { interface Props { alerts: Alert[] + permissions: pond.Permission[] addNew?: () => void } export default function Alerts(props: Props){ - const {alerts, addNew} = props + const {alerts, addNew, permissions} = props // const [openNewAlert, setOpenNewAlert] = useState(false) const readableCondition = (condition: pond.InteractionCondition) => { @@ -64,17 +65,13 @@ export default function Alerts(props: Props){ return ( - {/* { - setOpenNewAlert(false) - if(refresh && refreshCallback) refreshCallback() - }} typeOptions={typeOptions} linkedComponents={linkedComponents}/> */} Alerts {addNew && - {addNew()}} color="primary"> + {addNew()}} color="primary"> } {alerts.map((alert, i) => ( diff --git a/src/objects/objectInteractions/Controls.tsx b/src/objects/objectInteractions/Controls.tsx index 9498fed..1dc04ff 100644 --- a/src/objects/objectInteractions/Controls.tsx +++ b/src/objects/objectInteractions/Controls.tsx @@ -15,11 +15,12 @@ export interface Control { interface Props { devices: Device[] controls: Control[] - addNew?: (deviceID: number) => void + permissions: pond.Permission[] + addNew?: (device: Device) => void } export default function Controls(props: Props){ - const { devices, controls, addNew } = props + const { devices, controls, addNew, permissions } = props const [deviceControls, setDeviceControls] = useState>(new Map()) const [openDeviceMenu, setOpenDeviceMenu] = useState(false) const [anchor, setAnchor] = useState(null) @@ -62,7 +63,7 @@ export default function Controls(props: Props){ onClick={() => { setAnchor(null) setOpenDeviceMenu(false) - if (addNew) addNew(dev.id()) + if (addNew) addNew(dev) }}> {dev.name()} @@ -81,6 +82,7 @@ export default function Controls(props: Props){ Alerts { setOpenDeviceMenu(true) setAnchor(e.currentTarget) diff --git a/src/objects/objectInteractions/NewObjectInteraction.tsx b/src/objects/objectInteractions/NewObjectInteraction.tsx index b868fb7..889e061 100644 --- a/src/objects/objectInteractions/NewObjectInteraction.tsx +++ b/src/objects/objectInteractions/NewObjectInteraction.tsx @@ -1,12 +1,26 @@ -import { Button, Checkbox, DialogActions, DialogContent, DialogTitle, List, ListItem, ListItemIcon, ListItemText, MenuItem, Select } from "@mui/material"; +import { Button, Checkbox, DialogActions, DialogContent, DialogTitle, FormControl, FormControlLabel, FormHelperText, FormLabel, Grid2, IconButton, InputAdornment, List, ListItem, ListItemIcon, ListItemText, MenuItem, Select, Switch, TextField, Theme, Tooltip, Typography } from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import classNames from "classnames"; import ResponsiveDialog from "common/ResponsiveDialog"; import { Option } from "common/SearchSelect"; -import { Component } from "models"; +import { capitalize, cloneDeep } from "lodash"; +import { Component, Device, Interaction } from "models"; import { extension, getMeasurements } from "pbHelpers/ComponentType"; -import { Measurement, Operator } from "pbHelpers/Enums"; +import { ComponentType, Measurement, Operator } from "pbHelpers/Enums"; +import { describeMeasurement, MeasurementDescriber } from "pbHelpers/MeasurementDescriber"; import { pond } from "protobuf-ts/pond"; import { quack } from "protobuf-ts/quack"; +import React from "react"; import { useEffect, useState } from "react"; +import AddIcon from "@mui/icons-material/AddCircle"; +import RemoveIcon from "@mui/icons-material/RemoveCircle"; +import { green, red } from "@mui/material/colors"; +import { or } from "utils"; +import moment from "moment"; +import { timeOfDayDescriptor } from "pbHelpers/Interaction"; +import { TimePicker } from "@mui/x-date-pickers"; +import { useInteractionsAPI, useSnackbar } from "hooks"; +import { useGlobalState } from "providers"; interface Props { open: boolean @@ -14,11 +28,45 @@ interface Props { linkedComponents: Map componentDevices: Map //if we pass in a device id we could filter the components to only be for that device, this could be used for the control part - device?: number + device?: Device } +const useStyles = makeStyles((theme: Theme) => { + return ( + { + borderedContainer: { + padding: theme.spacing(1), + marginBottom: theme.spacing(2), + border: "1px solid rgba(255, 255, 255, 0.12)", + borderRadius: "4px" + }, + greenButton: { + color: green["600"] + }, + redButton: { + color: red["600"] + }, + noPadding: { + padding: 0 + }, + forPrompt: { + fontSize: "0.875em", + marginTop: "10px" + }, + timeRange: { + fontSize: "0.875em", + marginTop: "20px" + }, + } + ) +}) + export default function NewObjectInteraction(props: Props){ const { open, onClose, device, linkedComponents, componentDevices } = props + const classes = useStyles() + const [{ as }] = useGlobalState(); + const {openSnack} = useSnackbar(); + const interactionsAPI = useInteractionsAPI(); const [newAlertComponentType, setNewAlertComponentType] = useState( quack.ComponentType.COMPONENT_TYPE_INVALID ); @@ -26,14 +74,49 @@ export default function NewObjectInteraction(props: Props){ const [selectedAlertComponents, setSelectedAlertComponents] = useState([]); const [validOptions, setValidOptions] = useState([]) const [validComponents, setValidComponents] = useState([]) + //condition value strings - so that decimals are easier to type into the field + const [valStrings, setValStrings] = useState(["0", "0"]); + const [numConditions, setNumConditions] = useState(0) + const [nodeOptions, setNodeOptions] = useState([]); + const [subtypeDropdown, setSubtypeDropdown] = useState(0); + const [nodeOne, setNodeOne] = useState(0) + const [nodeTwo, setNodeTwo] = useState(0) + const [maxConditions, setMaxConditions] = useState(2) + const [resultType, setResultType] = useState(quack.InteractionResultType.INTERACTION_RESULT_TYPE_REPORT) + const [sinkMotor, setSinkMotor] = useState(false) + const [selectedSink, setSelectedSink] = useState("")//the key of the component to be the sink + const [sinkOptions, setSinkOptions] = useState([]) + const [resultMode, setResultMode] = useState(0) + const [resultValue, setResultValue] = useState("") + const [dutyCycleEnabled, setDutyCycleEnabled] = useState(false) + const [dutyCycle, setDutyCycle] = useState(""); + const [reporting, setReporting] = useState(true) + const [notify, setNotify] = useState(true) + //schedule variables + const [schedule, setSchedule] = useState( + pond.InteractionSchedule.create({ + weekdays: ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] + }) + ); + + useEffect(() => { //if the device is passed in need to filter out possible components let validCompList: Component[] = [] let validOptions: Option[] = [] + let sinkOptions: Component[] = [] + + if(device){ + setMaxConditions(device.maxConditions()) + } linkedComponents.forEach((comp, key) => { - if(componentDevices.get(key) === device || !device){ + if(componentDevices.get(key) === device?.id() || !device){ + let ext = extension(comp.type(), comp.subType()) + if(ext.isController){ + sinkOptions.push(comp) + } validCompList.push(comp) } }) @@ -62,10 +145,59 @@ export default function NewObjectInteraction(props: Props){ setValidOptions(validOptions) setValidComponents(validCompList) + setSinkOptions(sinkOptions) },[device, linkedComponents, componentDevices]) + + //use the selected components to determine the lowest amount of nodes for the options + useEffect(() => { + setNodeOne(0); + setNodeTwo(0); + setSubtypeDropdown(0); + let nodeCount = 12; //start with the maximum number of nodes for a cable + validComponents.forEach(comp => { + if (selectedAlertComponents.includes(comp.key())) { + if (comp.lastMeasurement[0] && comp.lastMeasurement[0].values[0]) { + let nodes = comp.lastMeasurement[0].values[0].values.length; + nodeCount = nodes < nodeCount ? nodes : nodeCount; + } + } + }); + let options = []; + for (let i = 0; i <= nodeCount; i++) { + options.push( + + {i === 0 ? "None" : "Node " + i} + + ); + } + setNodeOptions(options); + }, [validComponents, selectedAlertComponents]); const close = (refresh: boolean) => { + //reset the state variables to their default values + setNewAlertComponentType(quack.ComponentType.COMPONENT_TYPE_INVALID); + setConditions([]); + setSelectedAlertComponents([]); + setValidOptions([]) + setValidComponents([]) + setValStrings(["0", "0"]); + setNumConditions(0) + setNodeOptions([]); + setSubtypeDropdown(0); + setNodeOne(0) + setNodeTwo(0) + setMaxConditions(2) + setResultType(quack.InteractionResultType.INTERACTION_RESULT_TYPE_REPORT) + setSinkMotor(false) + setSelectedSink("")//the key of the component to be the sink + setSinkOptions([]) + setResultMode(0) + setResultValue("") + setDutyCycleEnabled(false) + setDutyCycle(""); + setReporting(true) + setNotify(true) onClose(refresh) } const availableMeasurementTypes = (type: quack.ComponentType): quack.MeasurementType[] => { @@ -115,6 +247,758 @@ export default function NewObjectInteraction(props: Props){ ); }; + const measurementTypeMenuItems = () => { + return availableMeasurementTypes(newAlertComponentType).map(measurementType => ( + + {describeMeasurement(measurementType).label()} + + )); + }; + + const changeCondition = (newCondition: pond.InteractionCondition, index: number) => { + let c = conditions; + c[index] = newCondition; + setConditions([...c]); + }; + + const conditionGroup = (condition: pond.InteractionCondition, index: number) => { + let measurementType = condition.measurementType; + let describer = describeMeasurement(measurementType, newAlertComponentType); + let isBoolean = condition.measurementType === Measurement.boolean; + return ( + + + {index > 0 && ( + + AND + + )} + + { + condition.measurementType = +event.target.value; + changeCondition(condition, index); + }} + autoFocus={false} + margin="dense" + variant="standard" + fullWidth + InputLabelProps={{ shrink: true }}> + {measurementTypeMenuItems()} + + + + { + condition.comparison = +event.target.value; + changeCondition(condition, index); + }} + autoFocus={false} + margin="dense" + variant="standard" + fullWidth + InputLabelProps={{ shrink: true }}> + {!isBoolean && [ + + {"is below"} + , + + {"is above"} + + ]} + + {"is exactly"} + + + + + { + let strings = valStrings; + strings[index] = event.target.value; + setValStrings([...strings]); + if (!isNaN(+valStrings[index])) { + condition.value = describer.toStored(+event.target.value); + changeCondition(condition, index); + } + }} + autoFocus={false} + margin="dense" + variant="standard" + fullWidth + InputProps={{ + endAdornment: {describer.unit()} + }} + InputLabelProps={{ shrink: true }}> + {isBoolean && [ + + {describer.enumerations()[0]} + , + + {describer.enumerations()[1]} + + ]} + + + + + { + conditions.splice(index, 1) + setNumConditions(conditions.length) + }} + className={classNames(classes.redButton, classes.noPadding)}> + + + + + + {nodeOptions.length > 2 && ( + + + { + setSubtypeDropdown(+event.target.value); + }} + margin="dense" + variant="standard" + fullWidth + InputLabelProps={{ shrink: true }}> + + Any + + + Average + + + Single Node + + + Node Diff + + + Up To + + + + + {(subtypeDropdown === 2 || subtypeDropdown === 3 || subtypeDropdown === 4) && ( + { + setNodeOne(+event.target.value); + }} + margin="dense" + variant="standard" + fullWidth + InputLabelProps={{ shrink: true }}> + {nodeOptions} + + )} + + + {subtypeDropdown === 3 && ( + { + setNodeTwo(+event.target.value); + }} + margin="dense" + variant="standard" + fullWidth + InputLabelProps={{ shrink: true }}> + {nodeOptions} + + )} + + + )} + + ); + }; + + const conditionInput = () => { + return ( + + Conditions + {selectedAlertComponents.length > 0 ? ( + + {conditions.map((condition, i) => conditionGroup(condition, i))} + + ) : ( + You must select a source before adding conditions + )} + + = maxConditions} + aria-label="Add another condition" + onClick={() => { + let newConditions = conditions.concat(initialConditions(newAlertComponentType)); + setConditions(newConditions); + setNumConditions(newConditions.length) + }} + className={classNames(classes.greenButton, classes.noPadding)}> + + + + + ); + }; + + const sinkSelector = () => { + return ( + { + setSelectedSink(e.target.value) + setSinkMotor(linkedComponents.get(e.target.value)?.type() === ComponentType.stepperMotor) + }} + fullWidth + autoFocus={false} + margin="dense" + variant="standard" + InputLabelProps={{ shrink: true }}> + + Select Controller + + {sinkOptions.map(comp => ( + + {comp.name()} + + ))} + + ); + } + + const describeSink = (): MeasurementDescriber => { + const sink = linkedComponents.get(selectedSink); + return describeMeasurement( + Measurement.boolean, + or(sink, Component.create()).settings.type, + or(sink, Component.create()).settings.subtype + ); + }; + + const dutyCycleInput = () => { + return ( + + + { + setDutyCycleEnabled(checked) + }} + value="enableDutyCycle" + color="secondary" + /> + } + label="Once every" + /> + + + { + setDutyCycle(e.target.value) + }} + fullWidth + autoFocus={false} + margin="dense" + variant="standard" + disabled={!dutyCycleEnabled} + error={isNaN(Number(dutyCycle)) || Number(dutyCycle) < 0} + InputLabelProps={{ + shrink: true + }} + InputProps={{ + endAdornment: seconds + }}> + + + ); + }; + + const notificationInput = () => { + return ( + + {resultType !== quack.InteractionResultType.INTERACTION_RESULT_TYPE_REPORT && ( + + { + setReporting(checked) + }} + value="notificationReports" + color="secondary" + /> + } + label="Report" + /> + + )} + + { + setNotify(checked) + }} + value="interactionNotification" + color="secondary" + /> + } + label="Notify" + disabled={!reporting} + /> + + {notify && ( + + + Everyone with notifications enabled for this device will receive an email and/or SMS + each time this result occurs + + + )} + + ); + }; + + const resultInput = () => { + return ( + + Result + + + { + setResultType(+e.target.value as quack.InteractionResultType) + }} + autoFocus={false} + margin="dense" + variant="standard" + fullWidth + InputLabelProps={{ shrink: true }}> + Report + Set + Toggle + Run + + + {resultType === quack.InteractionResultType.INTERACTION_RESULT_TYPE_RUN && ( + + + {sinkSelector()} + + {sinkMotor && ( + + { + setResultMode(+e.target.value) + }} + fullWidth + autoFocus={false} + margin="dense" + variant="standard" + InputLabelProps={{ shrink: true }}> + + Clockwise + + + Counter Clockwise + + + + )} + + + for + + + + { + setResultValue(e.target.value) + }} + autoFocus={false} + margin="dense" + variant="standard" + fullWidth + InputProps={{ + endAdornment: sec + }} + InputLabelProps={{ shrink: true }} + /> + + + )} + {resultType === quack.InteractionResultType.INTERACTION_RESULT_TYPE_SET && ( + + + {sinkSelector()} + + {linkedComponents.get(selectedSink)?.type() !== + quack.ComponentType.COMPONENT_TYPE_INTERNAL_FUNCTION && ( + + + to + + + )} + {linkedComponents.get(selectedSink)?.type() !== + quack.ComponentType.COMPONENT_TYPE_INTERNAL_FUNCTION && ( + + { + setResultValue(e.target.value) + }} + fullWidth + autoFocus={false} + margin="dense" + variant="standard" + InputLabelProps={{ shrink: true }}> + {extension(linkedComponents.get(selectedSink)?.type() ?? quack.ComponentType.COMPONENT_TYPE_INVALID).states.map((state, i) => ( + + {state} + + ))} + + + )} + + )} + {resultType === quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE && ( + + + {sinkSelector()} + + + { + if(checked){ + setResultValue('1') + }else{ + setResultValue('0') + } + }} + color="secondary" + /> + } + label={describeSink().enumerations()[isNaN(parseFloat(resultValue))?0:parseFloat(resultValue)]} + /> + + + )} + {resultType === quack.InteractionResultType.INTERACTION_RESULT_TYPE_RUN && + dutyCycleInput()} + {notificationInput()} + + + ); + }; + + const toggleDaySelected = (day: string, event: any) => { + let updatedSchedule = cloneDeep(schedule); + if (event.target.checked) { + if (!updatedSchedule.weekdays.includes(day)) { + updatedSchedule.weekdays.push(day); + } + } else { + updatedSchedule.weekdays.splice(updatedSchedule.weekdays.indexOf(day), 1); + } + setSchedule(updatedSchedule); + }; + + const daySelector = (day: string, size: any) => { + //const schedule = or(interaction.settings.schedule, pond.InteractionSchedule.create()); + return ( + + toggleDaySelected(day, event)} + value={day} + color="secondary" + /> + } + label={capitalize(day.substr(0, 2))} + labelPlacement="bottom" + /> + + ); + }; + + const setScheduleTime = (id: string, event: any) => { + let updatedSchedule = cloneDeep(schedule); + if (id === "shortcut") { + let value = event.target.value; + if (value === "allDay") { + updatedSchedule.timeOfDayStart = "00:00"; + updatedSchedule.timeOfDayEnd = "24:00"; + } else if (value === "before") { + updatedSchedule.timeOfDayStart = "00:00"; + if (!updatedSchedule.timeOfDayEnd || updatedSchedule.timeOfDayEnd === "24:00") { + updatedSchedule.timeOfDayEnd = "23:59"; + } + } else if (value === "after") { + updatedSchedule.timeOfDayEnd = "24:00"; + if (updatedSchedule.timeOfDayStart === "00:00") { + updatedSchedule.timeOfDayStart = "00:01"; + } + } else { + if (updatedSchedule.timeOfDayStart === "00:00") { + updatedSchedule.timeOfDayStart = "00:01"; + } + if (updatedSchedule.timeOfDayEnd === "24:00") { + updatedSchedule.timeOfDayEnd = "23:59"; + } + } + } else { + let timeOfDay = + event + .hour() + .toString() + .padStart(2, "0") + + ":" + + event + .minute() + .toString() + .padStart(2, "0"); + if (id === "start") { + updatedSchedule.timeOfDayStart = timeOfDay; + } else if (id === "end") { + if (timeOfDay === "00:00") { + timeOfDay = "24:00"; + } + updatedSchedule.timeOfDayEnd = timeOfDay; + } + } + updatedSchedule.timezone = moment.tz.guess(); + setSchedule(updatedSchedule); + }; + + const scheduler = () => { + //let schedule = pond.InteractionSchedule.create(); + let timezone = moment.tz.guess(); + let todStart = "00:00".split(":"); + let todEnd = "23:59".split(":"); + let start = moment + .tz(timezone) + .startOf("day") + .add(todStart[0], "hours") + .add(todStart[1], "minutes") + .local(); + let end = moment + .tz(timezone) + .startOf("day") + .add(todEnd[0], "hours") + .add(todEnd[1], "minutes") + .local(); + let descriptor = timeOfDayDescriptor(schedule); + let showStart = descriptor === "after" || descriptor === "from"; + let showEnd = descriptor === "before" || descriptor === "from"; + let showBoth = showStart && showEnd; + return ( + + Schedule + + {daySelector("sunday", 3)} + {daySelector("monday", 3)} + {daySelector("tuesday", 3)} + {daySelector("wednesday", 3)} + {daySelector("thursday", 4)} + {daySelector("friday", 4)} + {daySelector("saturday", 4)} + + + + setScheduleTime("shortcut", event)} + fullWidth + autoFocus={false} + margin="normal" + variant="standard" + InputLabelProps={{ shrink: true }}> + + All Day + + + Before + + + After + + + From + + + + {showStart && ( + + } + value={start} + onChange={(event: any) => setScheduleTime("start", event)} + /> + + )} + {showBoth && ( + + + to + + + )} + {showEnd && ( + + } + value={end} + onChange={(event: any) => setScheduleTime("end", event)} + /> + + )} + + + ); + }; + + const addInteractionToComponents = () => { + //array of device and component ids ie, [4:9-5-12, 4:9-5-13, 3:9-5-12] + let compIds: string[] = []; + selectedAlertComponents.forEach(comp => { + let devID = componentDevices.get(comp); + let component = linkedComponents.get(comp); + if (devID && component) { + compIds.push(devID + ":" + component.locationString()); + } + }); + let newInteraction = pond.InteractionSettings.create(); + newInteraction.conditions = conditions; + newInteraction.instance = 1; + newInteraction.schedule = schedule; + newInteraction.subtype = Interaction.create().subtypeFromNodes(nodeOne, nodeTwo); + newInteraction.sink = linkedComponents.get(selectedSink)?.location() + newInteraction.result = pond.InteractionResult.create({ + type: resultType, + mode: resultMode, + value: isNaN(parseFloat(resultValue)) ? 0 : parseFloat(resultValue), + dutyCycle: isNaN(parseFloat(dutyCycle)) ? 0 : parseFloat(dutyCycle) + }); + newInteraction.notifications = pond.InteractionNotifications.create({ + reports: reporting, + notify: reporting && notify + }); + interactionsAPI + .addInteractionToComponents(compIds, newInteraction, as) + .then(_resp => { + openSnack("interaction added to selected components"); + }) + .catch(_err => { + openSnack("there was a problem adding the interaction to the selected components"); + }); + }; + return ( ))} - {/* list of checkboxes for the components that match that type */} {listMatchingComponents()} - {/* have the conditions set here */} - {/* {conditionInput()} */} - {/* have the schedule set here */} - {/* {scheduler()} */} + {conditionInput()} + {device && resultInput()} + {scheduler()} - {/* */} + ) diff --git a/src/objects/objectInteractions/ObjectInteractions.tsx b/src/objects/objectInteractions/ObjectInteractions.tsx index 51e3230..bec7c1b 100644 --- a/src/objects/objectInteractions/ObjectInteractions.tsx +++ b/src/objects/objectInteractions/ObjectInteractions.tsx @@ -20,11 +20,12 @@ interface Props { objectKey: string objectType: pond.ObjectType devices: Device[]; + permissions: pond.Permission[] refreshCallback?: () => {} } export default function ObjectInteractions(props: Props) { - const { linkedComponents, componentDevices, devices, objectKey, objectType, refreshCallback } = props + const { linkedComponents, componentDevices, devices, objectKey, objectType, refreshCallback, permissions } = props const [{as}] = useGlobalState() //list of alerts, each alert contains a list of interactions with matching conditions const [alerts, setAlerts] = useState([]); @@ -34,7 +35,7 @@ export default function ObjectInteractions(props: Props) { const [openNewInteraction, setOpenNewInteraction] = useState(false) const [allSinks, setAllSinks] = useState([]); // const [typeOptions, setTypeOptions] = useState([]); - const [selectedDevice, setSelectedDevice] = useState(undefined) + const [selectedDevice, setSelectedDevice] = useState(undefined) const matchConditions = (interaction: Interaction, set: Alert | Control) => { @@ -178,9 +179,7 @@ export default function ObjectInteractions(props: Props) { }); setAllSinks(sinkOptions); setAlerts(alerts); - console.log(alerts) setControls(controls); - console.log(controls) }).catch(err => { console.error("Interaction fetch error:", err) }) @@ -206,15 +205,19 @@ export default function ObjectInteractions(props: Props) { Interactions { - setSelectedDevice(deviceID) + addNew={(device) => { + setSelectedDevice(device) setOpenNewInteraction(true) }}/> - { + { setSelectedDevice(undefined) setOpenNewInteraction(true) - }}/> + }}/> diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 37c2d62..9fa36b6 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -50,11 +50,9 @@ import { GrainCable } from "models/GrainCable"; // import { Controller } from "models/Controller"; import { Pressure } from "models/Pressure"; import { CheckCircle, ExpandMore, Warning } from "@mui/icons-material"; -import { getThemeType } from "theme"; import BinGraphs from "bin/graphs/BinGraphs"; import BinTour from "bin/BinTour"; import { getBinModel } from "common/DataImports/BinCables/BinCableImporter"; -import ObjectAlerts from "objects/ObjectAlerts"; import DevicePresetController from "device/presets/devicePresetController"; import { DevicePreset } from "models/DevicePreset"; import BinVisualizerV2 from "bin/BinVisualizerV2"; @@ -866,6 +864,7 @@ export default function Bin(props: Props) { devices={devices} objectKey={bin.key()} objectType={pond.ObjectType.OBJECT_TYPE_BIN} + permissions={permissions} /> )} @@ -1004,6 +1003,7 @@ export default function Bin(props: Props) { devices={devices} objectKey={bin.key()} objectType={pond.ObjectType.OBJECT_TYPE_BIN} + permissions={permissions} />