feat: components implement their notifications now

- removing nestedObjectUtils and useNotification hook
- passing addNotification method to all components
- components can use the addNotification method to create their
  notifications
This commit is contained in:
Mose Müller
2023-08-10 15:07:09 +02:00
parent 8205e4d463
commit f7579c3a89
21 changed files with 235 additions and 167 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useReducer, useRef, useState } from 'react';
import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap';
import { hostname, port, socket } from './socket';
import {
@ -6,9 +6,7 @@ import {
DataServiceJSON
} from './components/DataServiceComponent';
import './App.css';
import { getDataServiceJSONValueByPathAndKey } from './utils/nestedObjectUtils';
import { Notifications } from './components/NotificationsComponent';
import { useNotification } from './hooks/useNotification';
type ValueType = boolean | string | number | object;
@ -114,51 +112,11 @@ const reducer = (state: State, action: Action): State => {
const App = () => {
const [state, dispatch] = useReducer(reducer, null);
const stateRef = useRef(state); // Declare a reference to hold the current state
const [isInstantUpdate, setIsInstantUpdate] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const { notifications, notify, removeNotificationById } = useNotification();
const {
notifications: exceptions,
notify: notifyException,
removeNotificationById: removeExceptionById
} = useNotification();
const handleCloseSettings = () => setShowSettings(false);
const handleShowSettings = () => setShowSettings(true);
function onNotify(value: UpdateMessage) {
// Extracting data from the notification
const { parent_path: parentPath, name, value: newValue } = value.data;
// Dispatching the update to the reducer
dispatch({
type: 'UPDATE_ATTRIBUTE',
parentPath,
name,
value: newValue
});
// Formatting the value if it is of type 'Quantity'
let notificationMsg: object | string = newValue;
const path = parentPath.concat('.', name);
if (
getDataServiceJSONValueByPathAndKey(stateRef.current, path, 'type') === 'Quantity'
) {
notificationMsg = `${newValue['magnitude']} ${newValue['unit']}`;
}
// Creating a new notification
const newNotification = `${parentPath}.${name} changed to ${notificationMsg}.`;
// Adding the new notification to the list
notify(newNotification);
}
function onException(value: ExceptionMessage) {
const newNotification = `${value.data.type}: ${value.data.exception}.`;
notifyException(newNotification);
}
const [showNotification, setShowNotification] = useState(true);
const [notifications, setNotifications] = useState([]);
const [exceptions, setExceptions] = useState([]);
// Keep the state reference up to date
useEffect(() => {
@ -180,6 +138,64 @@ const App = () => {
};
}, []);
// Adding useCallback to prevent notify to change causing a re-render of all
// components
const addNotification = useCallback((text: string) => {
// Getting the current time in the required format
const timeString = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
// Custom logic for notifications
setNotifications((prevNotifications) => [
{ id, text, time: timeString },
...prevNotifications
]);
}, []);
const notifyException = (text: string) => {
// Getting the current time in the required format
const timeString = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
// Custom logic for notifications
setExceptions((prevNotifications) => [
{ id, text, time: timeString },
...prevNotifications
]);
};
const removeNotificationById = (id: number) => {
setNotifications((prevNotifications) =>
prevNotifications.filter((n) => n.id !== id)
);
};
const removeExceptionById = (id: number) => {
setExceptions((prevNotifications) => prevNotifications.filter((n) => n.id !== id));
};
const handleCloseSettings = () => setShowSettings(false);
const handleShowSettings = () => setShowSettings(true);
function onNotify(value: UpdateMessage) {
// Extracting data from the notification
const { parent_path: parentPath, name, value: newValue } = value.data;
// Dispatching the update to the reducer
dispatch({
type: 'UPDATE_ATTRIBUTE',
parentPath,
name,
value: newValue
});
}
function onException(value: ExceptionMessage) {
const newException = `${value.data.type}: ${value.data.exception}.`;
notifyException(newException);
}
// While the data is loading
if (!state) {
return <p>Loading...</p>;
@ -230,6 +246,7 @@ const App = () => {
<DataServiceComponent
props={state as DataServiceJSON}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
</div>
</>

View File

@ -10,29 +10,13 @@ interface AsyncMethodProps {
value: Record<string, string>;
docString?: string;
hideOutput?: boolean;
addNotification: (string) => void;
}
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
const { name, parentPath, docString, value: runningTask, addNotification } = props;
const renderCount = useRef(0);
const formRef = useRef(null);
const { name, parentPath, docString, value: runningTask } = props;
const execute = async (event: React.FormEvent) => {
event.preventDefault();
let method_name: string;
const args = {};
if (runningTask !== undefined && runningTask !== null) {
method_name = `stop_${name}`;
} else {
Object.keys(props.parameters).forEach(
(name) => (args[name] = event.target[name].value)
);
method_name = `start_${name}`;
}
emit_update(method_name, parentPath, { args: args });
};
useEffect(() => {
renderCount.current++;
@ -52,6 +36,38 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
}
}, [runningTask]);
useEffect(() => {
let message: string;
if (runningTask === null) {
message = `${parentPath}.${name} task was stopped.`;
} else {
const runningTaskEntries = Object.entries(runningTask)
.map(([key, value]) => `${key}: "${value}"`)
.join(', ');
message = `${parentPath}.${name} was started with parameters { ${runningTaskEntries} }.`;
}
addNotification(message);
}, [props.value]);
const execute = async (event: React.FormEvent) => {
event.preventDefault();
let method_name: string;
const args = {};
if (runningTask !== undefined && runningTask !== null) {
method_name = `stop_${name}`;
} else {
Object.keys(props.parameters).forEach(
(name) => (args[name] = event.target[name].value)
);
method_name = `start_${name}`;
}
emit_update(method_name, parentPath, { args: args });
};
const args = Object.entries(props.parameters).map(([name, type], index) => {
const form_name = `${name} (${type})`;
const value = runningTask && runningTask[name];

View File

@ -10,17 +10,23 @@ interface ButtonComponentProps {
readOnly: boolean;
docString: string;
mapping?: [string, string]; // Enforce a tuple of two strings
addNotification: (string) => void;
}
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
const { name, parentPath, value, readOnly, docString, mapping, addNotification } =
props;
const buttonName = mapping ? (value ? mapping[0] : mapping[1]) : name;
const renderCount = useRef(0);
useEffect(() => {
renderCount.current++;
});
const { name, parentPath, value, readOnly, docString, mapping } = props;
const buttonName = mapping ? (value ? mapping[0] : mapping[1]) : name;
useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`);
}, [props.value]);
const setChecked = (checked: boolean) => {
emit_update(name, parentPath, checked);

View File

@ -8,12 +8,18 @@ type DataServiceProps = {
props: DataServiceJSON;
parentPath?: string;
isInstantUpdate: boolean;
addNotification: (string) => void;
};
export type DataServiceJSON = Record<string, Attribute>;
export const DataServiceComponent = React.memo(
({ props, parentPath = 'DataService', isInstantUpdate }: DataServiceProps) => {
({
props,
parentPath = 'DataService',
isInstantUpdate,
addNotification
}: DataServiceProps) => {
const [open, setOpen] = useState(true);
return (
@ -35,6 +41,7 @@ export const DataServiceComponent = React.memo(
name={key}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
);
})}

View File

@ -9,16 +9,28 @@ interface EnumComponentProps {
value: string;
docString?: string;
enumDict: Record<string, string>;
addNotification: (string) => void;
}
export const EnumComponent = React.memo((props: EnumComponentProps) => {
const {
name,
parentPath: parentPath,
value,
docString,
enumDict,
addNotification
} = props;
const renderCount = useRef(0);
useEffect(() => {
renderCount.current++;
});
const { name, parentPath: parentPath, value, docString, enumDict } = props;
useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`);
}, [props.value]);
const handleValueChange = (newValue: string) => {
emit_update(name, parentPath, newValue);

View File

@ -38,10 +38,17 @@ type GenericComponentProps = {
name: string;
parentPath: string;
isInstantUpdate: boolean;
addNotification: (string) => void;
};
export const GenericComponent = React.memo(
({ attribute, name, parentPath, isInstantUpdate }: GenericComponentProps) => {
({
attribute,
name,
parentPath,
isInstantUpdate,
addNotification
}: GenericComponentProps) => {
if (attribute.type === 'bool') {
return (
<ButtonComponent
@ -50,6 +57,7 @@ export const GenericComponent = React.memo(
docString={attribute.doc}
readOnly={attribute.readonly}
value={Boolean(attribute.value)}
addNotification={addNotification}
/>
);
} else if (attribute.type === 'float' || attribute.type === 'int') {
@ -62,6 +70,7 @@ export const GenericComponent = React.memo(
readOnly={attribute.readonly}
value={Number(attribute.value)}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
);
} else if (attribute.type === 'Quantity') {
@ -75,6 +84,7 @@ export const GenericComponent = React.memo(
value={Number(attribute.value['magnitude'])}
unit={attribute.value['unit']}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
);
} else if (attribute.type === 'NumberSlider') {
@ -89,6 +99,7 @@ export const GenericComponent = React.memo(
max={attribute.value['max']['value']}
stepSize={attribute.value['step_size']['value']}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
);
} else if (attribute.type === 'Enum') {
@ -99,6 +110,7 @@ export const GenericComponent = React.memo(
docString={attribute.doc}
value={String(attribute.value)}
enumDict={attribute.enum}
addNotification={addNotification}
/>
);
} else if (attribute.type === 'method') {
@ -109,6 +121,7 @@ export const GenericComponent = React.memo(
parentPath={parentPath}
docString={attribute.doc}
parameters={attribute.parameters}
addNotification={addNotification}
/>
);
} else {
@ -119,6 +132,7 @@ export const GenericComponent = React.memo(
docString={attribute.doc}
parameters={attribute.parameters}
value={attribute.value as Record<string, string>}
addNotification={addNotification}
/>
);
}
@ -131,6 +145,7 @@ export const GenericComponent = React.memo(
docString={attribute.doc}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
);
} else if (attribute.type === 'DataService') {
@ -139,6 +154,7 @@ export const GenericComponent = React.memo(
props={attribute.value as DataServiceJSON}
parentPath={parentPath.concat('.', name)}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
);
} else if (attribute.type === 'list') {
@ -149,6 +165,7 @@ export const GenericComponent = React.memo(
docString={attribute.doc}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
);
} else if (attribute.type === 'Image') {
@ -161,6 +178,7 @@ export const GenericComponent = React.memo(
docString={attribute.doc}
// Add any other specific props for the ImageComponent here
format={attribute.value['format']['value'] as string}
addNotification={addNotification}
/>
);
} else {

View File

@ -1,5 +1,4 @@
import React, { useEffect, useRef } from 'react';
import { emit_update } from '../socket';
import { Card, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
@ -10,16 +9,22 @@ interface ImageComponentProps {
readOnly: boolean;
docString: string;
format: string;
addNotification: (string) => void;
}
export const ImageComponent = React.memo((props: ImageComponentProps) => {
const { name, parentPath, value, docString, format, addNotification } = props;
const renderCount = useRef(0);
const { name, parentPath, value, docString, format } = props;
useEffect(() => {
renderCount.current++;
});
useEffect(() => {
addNotification(`${parentPath}.${name} changed.`);
}, [props.value]);
return (
<div className={'imageComponent'} id={parentPath.concat('.' + name)}>
<Card>

View File

@ -8,16 +8,18 @@ interface ListComponentProps {
value: Attribute[];
docString: string;
isInstantUpdate: boolean;
addNotification: (string) => void;
}
export const ListComponent = React.memo((props: ListComponentProps) => {
const { name, parentPath, value, docString, isInstantUpdate, addNotification } =
props;
const renderCount = useRef(0);
useEffect(() => {
renderCount.current++;
});
const { name, parentPath, value, docString, isInstantUpdate } = props;
}, [props]);
return (
<div className={'listComponent'} id={parentPath.concat(name)}>
@ -33,6 +35,7 @@ export const ListComponent = React.memo((props: ListComponentProps) => {
name={`${name}[${index}]`}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
);
})}

View File

@ -9,16 +9,37 @@ interface MethodProps {
parameters: Record<string, string>;
docString?: string;
hideOutput?: boolean;
addNotification: (string) => void;
}
export const MethodComponent = React.memo((props: MethodProps) => {
const { name, parentPath, docString, addNotification } = props;
const renderCount = useRef(0);
const [hideOutput, setHideOutput] = useState(false);
// Add a new state variable to hold the list of function calls
const [functionCalls, setFunctionCalls] = useState([]);
const { name, parentPath, docString } = props;
useEffect(() => {
renderCount.current++;
if (props.hideOutput !== undefined) {
setHideOutput(props.hideOutput);
}
});
const triggerNotification = (args: Record<string, string>) => {
const argsString = Object.entries(args)
.map(([key, value]) => `${key}: "${value}"`)
.join(', ');
let message = `Method ${parentPath}.${name} was triggered`;
if (argsString === '') {
message += '.';
} else {
message += ` with arguments {${argsString}}.`;
}
addNotification(message);
};
const execute = async (event: React.FormEvent) => {
event.preventDefault();
@ -33,14 +54,9 @@ export const MethodComponent = React.memo((props: MethodProps) => {
setFunctionCalls((prevCalls) => [...prevCalls, { name, args, result: ack }]);
}
});
};
useEffect(() => {
renderCount.current++;
if (props.hideOutput !== undefined) {
setHideOutput(props.hideOutput);
}
});
triggerNotification(args);
};
const args = Object.entries(props.parameters).map(([name, type], index) => {
const form_name = `${name} (${type})`;

View File

@ -22,6 +22,7 @@ interface NumberComponentProps {
value: number,
callback?: (ack: unknown) => void
) => void;
addNotification: (string) => void;
}
// TODO: highlight the digit that is being changed by setting both selectionStart and
@ -108,7 +109,15 @@ const handleDeleteKey = (
};
export const NumberComponent = React.memo((props: NumberComponentProps) => {
const { name, parentPath, readOnly, docString, isInstantUpdate, unit } = props;
const {
name,
parentPath,
readOnly,
docString,
isInstantUpdate,
unit,
addNotification
} = props;
// Whether to show the name infront of the component (false if used with a slider)
const showName = props.showName !== undefined ? props.showName : true;
@ -143,6 +152,15 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
if (props.value !== numericInputString) {
setInputString(props.value.toString());
}
// emitting notification
let notificationMsg = `${parentPath}.${name} changed to ${props.value}`;
if (unit === undefined) {
notificationMsg += '.';
} else {
notificationMsg += ` ${unit}.`;
}
addNotification(notificationMsg);
}, [props.value]);
const handleNumericKey = (key: string, value: string, selectionStart: number) => {

View File

@ -15,6 +15,7 @@ interface SliderComponentProps {
docString: string;
stepSize: number;
isInstantUpdate: boolean;
addNotification: (string) => void;
}
export const SliderComponent = React.memo((props: SliderComponentProps) => {
@ -34,9 +35,26 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
stepSize,
readOnly,
docString,
isInstantUpdate
isInstantUpdate,
addNotification
} = props;
useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`);
}, [props.value]);
useEffect(() => {
addNotification(`${parentPath}.${name}.min changed to ${min}.`);
}, [props.min]);
useEffect(() => {
addNotification(`${parentPath}.${name}.max changed to ${max}.`);
}, [props.max]);
useEffect(() => {
addNotification(`${parentPath}.${name}.stepSize changed to ${stepSize}.`);
}, [props.stepSize]);
const emitSliderUpdate = (
name: string,
parentPath: string,
@ -122,6 +140,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
value={value}
showName={false}
customEmitUpdate={emitSliderUpdate}
addNotification={() => null}
/>
</Col>
<Col xs="auto">

View File

@ -13,13 +13,16 @@ interface StringComponentProps {
readOnly: boolean;
docString: string;
isInstantUpdate: boolean;
addNotification: (string) => void;
}
export const StringComponent = React.memo((props: StringComponentProps) => {
const { name, parentPath, readOnly, docString, isInstantUpdate, addNotification } =
props;
const renderCount = useRef(0);
const [inputString, setInputString] = useState(props.value);
const { name, parentPath, readOnly, docString, isInstantUpdate } = props;
useEffect(() => {
renderCount.current++;
}, [isInstantUpdate, inputString, renderCount]);
@ -29,6 +32,7 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
if (props.value !== inputString) {
setInputString(props.value);
}
addNotification(`${parentPath}.${name} changed to ${props.value}.`);
}, [props.value]);
const handleChange = (event) => {

View File

@ -1,27 +0,0 @@
import { useState } from 'react';
import { Notification } from '../components/NotificationsComponent';
export const useNotification = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const notify = (text: string) => {
// Getting the current time in the required format
const timeString = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
// Custom logic for notifications
setNotifications((prevNotifications) => [
{ id, text, time: timeString },
...prevNotifications
]);
};
const removeNotificationById = (id: number) => {
setNotifications((prevNotifications) =>
prevNotifications.filter((n) => n.id !== id)
);
};
return { notifications, notify, removeNotificationById };
};

View File

@ -1,46 +0,0 @@
type Data = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
};
const STANDARD_TYPES = [
'int',
'float',
'bool',
'str',
'Enum',
'method',
'NoneType',
'Quantity'
];
export function getDataServiceJSONValueByPathAndKey(
data: Data,
path: string,
key = 'value'
): string {
// Split the path into parts
const parts = path.split(/\.|(?=\[\d+\])/);
parts.shift(); // Remove the first element
// Traverse the dictionary according to the path parts
for (const part of parts) {
if (part.startsWith('[')) {
// List index
const idx = parseInt(part.substring(1, part.length - 1)); // Strip the brackets and convert to integer
data = data[idx];
} else {
// Dictionary key
data = data[part];
}
// When the attribute is a class instance, the attributes are nested in the
// "value" key
if (!STANDARD_TYPES.includes(data['type'])) {
data = data['value'];
}
}
// Return the value at the terminal point of the path
return data[key];
}