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];
}

View File

@ -1,13 +1,13 @@
{
"files": {
"main.css": "/static/css/main.398bc7f8.css",
"main.js": "/static/js/main.799e0879.js",
"main.js": "/static/js/main.39f6b50e.js",
"index.html": "/index.html",
"main.398bc7f8.css.map": "/static/css/main.398bc7f8.css.map",
"main.799e0879.js.map": "/static/js/main.799e0879.js.map"
"main.39f6b50e.js.map": "/static/js/main.39f6b50e.js.map"
},
"entrypoints": [
"static/css/main.398bc7f8.css",
"static/js/main.799e0879.js"
"static/js/main.39f6b50e.js"
]
}

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.799e0879.js"></script><link href="/static/css/main.398bc7f8.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.39f6b50e.js"></script><link href="/static/css/main.398bc7f8.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long