updates Adding_Components.md to account for new component structure

This commit is contained in:
Mose Müller 2024-02-28 11:15:37 +01:00
parent 8f0a9ad21a
commit 416ae6f0b4

View File

@ -18,7 +18,7 @@ For example, for a `Image` component, create a file named `image.py`.
### Step 2: Define the Backend Class ### Step 2: Define the Backend Class
Within the newly created file, define a Python class representing the component. This class should inherit from `DataService` and contains the attributes that the frontend needs to render the component. Every public attribute defined in this class will synchronise across the clients. It can also contain methods which can be used to interact with the component from the backend. Within the newly created file, define a Python class representing the component. This class should inherit from `DataService` and contains the attributes that the frontend needs to render the component. Every public attribute defined in this class will synchronise across the clients. It can also contain (public) methods which you can provide for the user to interact with the component from the backend (or python clients).
For the `Image` component, the class may look like this: For the `Image` component, the class may look like this:
@ -31,21 +31,25 @@ from pydase.data_service.data_service import DataService
class Image(DataService): class Image(DataService):
def __init__( def __init__(
self, self,
image_representation: bytes = b"",
) -> None: ) -> None:
self.image_representation = image_representation
super().__init__() super().__init__()
self._value: str = ""
self._format: str = ""
# need to decode the bytes @property
def __setattr__(self, __name: str, __value: Any) -> None: def value(self) -> str:
if __name == "value": return self._value
if isinstance(__value, bytes):
__value = __value.decode()
return super().__setattr__(__name, __value)
@property
def format(self) -> str:
return self._format
def load_from_path(self, path: Path | str) -> None:
# changing self._value and self._format
...
``` ```
So, changing the `image_representation` will push the updated value to the browsers connected to the service. So, calling `load_from_path` will push the updated value and format to the browsers clients connected to the service.
### Step 3: Register the Backend Class ### Step 3: Register the Backend Class
@ -85,10 +89,11 @@ def test_Image(capsys: CaptureFixture) -> None:
class ServiceClass(DataService): class ServiceClass(DataService):
image = Image() image = Image()
service = ServiceClass() service_instance = ServiceClass()
# ...
```
service_instance.image.load_from_path("<path/to/image>.png")
assert service_instance.image.format == "PNG"
```
## Adding a Frontend Component to `pydase` ## Adding a Frontend Component to `pydase`
@ -107,43 +112,41 @@ Write the React component code, following the structure and patterns used in exi
For example, for the `Image` component, a template could look like this: For example, for the `Image` component, a template could look like this:
```tsx ```tsx
import { setAttribute, runMethod } from '../socket'; // use this when your component should sets values of attributes
// or runs a method, respectively
import { DocStringComponent } from './DocStringComponent';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { Card, Collapse, Image } from 'react-bootstrap'; import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons'; import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
type ImageComponentProps = { type ImageComponentProps = {
name: string; name: string; // needed to create the fullAccessPath
parentPath?: string; parentPath: string; // needed to create the fullAccessPath
readOnly: boolean; readOnly: boolean; // component changable through frontend?
docString: string; docString: string; // contains docstring of your component
displayName: string; // name defined in the web_settings.json
id: string; // unique identifier - created from fullAccessPath
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
// Define your component specific props here changeCallback?: ( // function used to communicate changes to the backend
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
// component-specific properties
value: string; value: string;
format: string; format: string;
} };
export const ImageComponent = React.memo((props: ImageComponentProps) => { export const ImageComponent = React.memo((props: ImageComponentProps) => {
const { name, parentPath, value, docString, format, addNotification } = props; const { value, docString, format, addNotification, displayName, id } = props;
const renderCount = useRef(0); const renderCount = useRef(0);
const [open, setOpen] = useState(true); // add this if you want to expand/collapse your component const [open, setOpen] = useState(true); // add this if you want to expand/collapse your component
const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); const fullAccessPath = [props.parentPath, props.name]
const id = getIdFromFullAccessPath(fullAccessPath); .filter((element) => element)
.join('.');
// Web settings contain the user-defined display name of the components (and possibly more later) // Your component logic here
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => { useEffect(() => {
renderCount.current++; renderCount.current++;
@ -151,13 +154,11 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
// This will trigger a notification if notifications are enabled. // This will trigger a notification if notifications are enabled.
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`); addNotification(`${fullAccessPath} changed.`);
}, [props.value]); }, [props.value]);
// Your component logic here
return ( return (
<div className={'imageComponent'} id={id}> <div className="component imageComponent" id={id}>
{/* Add the Card and Collapse components here if you want to be able to expand and {/* Add the Card and Collapse components here if you want to be able to expand and
collapse your component. */} collapse your component. */}
<Card> <Card>
@ -185,57 +186,98 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
### Step 3: Emitting Updates to the Backend ### Step 3: Emitting Updates to the Backend
React components in the frontend often need to send updates to the backend, particularly when user interactions modify the component's state or data. In `pydase`, we use `socketio` for smooth communication of these changes. To handle updates, we primarily use two events: `setAttribute` for updating attributes, and `runMethod` for executing backend methods. Below is a detailed guide on how to emit these events from your frontend component: React components in the frontend often need to send updates to the backend, particularly when user interactions modify the component's state or data. In `pydase`, we use `socketio` for communicating these changes.<br>
There are two different events a component might want to trigger: updating an attribute or triggering a method. Below is a guide on how to emit these events from your frontend component:
1. **Setup for emitting events**: 1. **Updating Attributes**
First, ensure you've imported the necessary functions from the `socket` module for both updating attributes and executing methods:
```tsx Updating the value of an attribute or property in the backend is a very common requirement. However, we want to define components in a reusable way, i.e. they can be linked to the backend but also be used without emitting change events.<br>
import { setAttribute, runMethod } from '../socket'; This is why we pass a `changeCallback` function as a prop to the component which it can use to communicate changes. If no function is passed, the component can be used in forms, for example.
```
2. **Event Parameters**: The `changeCallback` function takes the following arguments:
- When using **`setAttribute`**, we send three main pieces of data: - `value`: the new value for the attribute, which must match the backend attribute type.
- `name`: The name of the attribute within the `DataService` instance to update. - `attributeName`: the name of the attribute within the `DataService` instance to update. Defaults to the `name` prop of the component.
- `parentPath`: The access path for the parent object of the attribute to be updated. - `prefix`: the access path for the parent object of the attribute to be updated. Defaults to the `parentPath` prop of the component.
- `value`: The new value for the attribute, which must match the backend attribute type. - `callback`: the function that will be called when the server sends an acknowledgement. Defaults to `undefined`
- For **`runMethod`**, the parameters are slightly different:
- `name`: The name of the method to be executed in the backend.
- `parentPath`: Similar to `setAttribute`, it's the access path to the object containing the method.
- `kwargs`: A dictionary of keyword arguments that the method requires.
3. **Implementation**:
For illustation, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
```tsx
import { setAttribute } from '../socket';
// ... (other imports)
For illustration, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
```tsx
// file: frontend/src/components/ButtonComponent.tsx
// ... (import statements)
type ButtonComponentProps = {
// ...
changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
};
export const ButtonComponent = React.memo((props: ButtonComponentProps) => { export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
// ... const {
const { name, parentPath, value } = props; // ...
let displayName = ... // to access the user-defined display name changeCallback = () => {},
} = props;
const setChecked = (checked: boolean) => { const setChecked = (checked: boolean) => {
setAttribute(name, parentPath, checked); changeCallback(checked);
}; };
return ( return (
<ToggleButton <ToggleButton
checked={value}
value={parentPath}
// ... other props // ... other props
onChange={(e) => setChecked(e.currentTarget.checked)}> onChange={(e) => setChecked(e.currentTarget.checked)}>
{displayName} {/* component TSX */}
</ToggleButton> </ToggleButton>
); );
}); });
``` ```
In this example, whenever the button's checked state changes (`onChange` event), we invoke the `setChecked` method, which in turn emits the new state to the backend using `setAttribute`. In this example, whenever the button's checked state changes (`onChange` event), we invoke the `setChecked` method, which in turn emits the new state to the backend using `changeCallback`.
2. **Triggering Methods**
To trigger method through your component, you can either use the `MethodComponent` (which will render a button in the frontend), or use the low-level `runMethod` function. Its parameters are slightly different to the `changeCallback` function:
- `name`: the name of the method to be executed in the backend.
- `parentPath`: the access path to the object containing the method.
- `kwargs`: a dictionary of keyword arguments that the method requires.
To see how to use the `MethodComponent` in your component, have a look at the `DeviceConnection.tsx` file. Here is an example that demonstrates the usage of the `runMethod` function (also, have a look at the `MethodComponent.tsx` file):
```tsx
import { runMethod } from '../socket';
// ... (other imports)
type ComponentProps = {
name: string;
parentPath: string;
// ...
};
export const Component = React.memo((props: ComponentProps) => {
const {
name,
parentPath,
// ...
} = props;
// ...
const someFunction = () => {
// ...
runMethod(name, parentPath, {});
};
return (
{/* component TSX */}
);
});
```
### Step 4: Add the New Component to the GenericComponent ### Step 4: Add the New Component to the GenericComponent
@ -282,15 +324,17 @@ Inside the `GenericComponent` function, add a new conditional branch to render t
<ImageComponent <ImageComponent
name={name} name={name}
parentPath={parentPath} parentPath={parentPath}
readOnly={attribute.readonly} docString={attribute.value['value'].doc}
docString={attribute.doc} displayName={displayName}
id={id}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback}
// Add any other specific props for the ImageComponent here // Add any other specific props for the ImageComponent here
value={attribute.value['value']['value'] as string} value={attribute.value['value']['value'] as string}
format={attribute.value['format']['value'] as string} format={attribute.value['format']['value'] as string}
/> />
); );
} else { } else if (...) {
// other code // other code
``` ```
@ -305,12 +349,14 @@ For example, updating an `Image` component corresponds to setting a very long st
To create a custom notification message, you can update the message passed to the `addNotification` method in the `useEffect` hook in the component file file. For the `ImageComponent`, this could look like this: To create a custom notification message, you can update the message passed to the `addNotification` method in the `useEffect` hook in the component file file. For the `ImageComponent`, this could look like this:
```tsx ```tsx
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name} changed.`); addNotification(`${fullAccessPath} changed.`);
}, [props.value]); }, [props.value]);
``` ```
However, you might want to use the `addNotification` at different places. For an example, see the [MethodComponent](../../frontend/src/components/MethodComponent.tsx). However, you might want to use the `addNotification` at different places. For an example, see the `MethodComponent`.
**Note**: you can specify the notification level by passing a string of type `LevelName` (one of 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'). The default value is 'DEBUG'. **Note**: you can specify the notification level by passing a string of type `LevelName` (one of 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'). The default value is 'DEBUG'.
### Step 6: Write Tests for the Component (TODO) ### Step 6: Write Tests for the Component (TODO)