mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-12-19 12:41:19 +01:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b719684702 | ||
|
|
7254482b35 | ||
|
|
44d5a98449 | ||
|
|
29558758af | ||
|
|
f9be97a910 | ||
|
|
fa45ee566b | ||
|
|
6e8ad98282 | ||
|
|
c42872aad4 | ||
|
|
34eb4a0e7c | ||
|
|
7d50bd5759 | ||
|
|
c98f191d20 | ||
|
|
b1e6663c66 | ||
|
|
a5a957d290 | ||
|
|
b856ed3a12 | ||
|
|
b83e241b32 | ||
|
|
fb251649a0 | ||
|
|
a2ee8d02d6 | ||
|
|
44d73c3b77 | ||
|
|
cddb83451a | ||
|
|
218dab1ade | ||
|
|
81af62dc6e | ||
|
|
6ffb068f47 | ||
|
|
73a3283a7d | ||
|
|
c0734d58ce | ||
|
|
b5a7d90d81 | ||
|
|
b91eaaaf90 | ||
|
|
4039d29f42 | ||
|
|
e8428e4a31 | ||
|
|
25459949a0 | ||
|
|
9649f914ac | ||
|
|
4ecc44fdd8 | ||
|
|
4cea7eeb59 | ||
|
|
3c48a23277 | ||
|
|
bfcf72fec7 | ||
|
|
639161d373 | ||
|
|
6f3910efd0 | ||
|
|
fe5d0eed2d | ||
|
|
a11ab1520f | ||
|
|
ae79150252 | ||
|
|
7fdd08021a | ||
|
|
00c6d4c068 | ||
|
|
f49cdd87e4 | ||
|
|
052bf79487 | ||
|
|
203cc0f0f5 | ||
|
|
0c54c9d4b7 | ||
|
|
381e73d624 | ||
|
|
9f27f07ccb | ||
|
|
94cef50e03 | ||
|
|
9fa8f06280 | ||
|
|
84abd63d56 | ||
|
|
999a6016ff | ||
|
|
19f91b7cf3 | ||
|
|
a0b7b92898 | ||
|
|
d7e604992d | ||
|
|
2d1d228c78 | ||
|
|
9c3c92361b | ||
|
|
ba9dbc03f1 | ||
|
|
f783d0b25c | ||
|
|
8285a37a4c | ||
|
|
6a894b6154 | ||
|
|
f9a5352efe | ||
|
|
9c5d133d65 | ||
|
|
eacd5bc6b1 | ||
|
|
314e89ba38 |
6
.github/workflows/python-package.yml
vendored
6
.github/workflows/python-package.yml
vendored
@@ -16,15 +16,15 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: chartboost/ruff-action@v1
|
||||
with:
|
||||
src: "./src"
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
|
||||
41
README.md
41
README.md
@@ -30,6 +30,7 @@
|
||||
- [Controlling Property State Loading with `@load_state`](#controlling-property-state-loading-with-load_state)
|
||||
- [Understanding Tasks in pydase](#understanding-tasks-in-pydase)
|
||||
- [Understanding Units in pydase](#understanding-units-in-pydase)
|
||||
- [Using `validate_set` to Validate Property Setters](#using-validate_set-to-validate-property-setters)
|
||||
- [Configuring pydase via Environment Variables](#configuring-pydase-via-environment-variables)
|
||||
- [Customizing the Web Interface](#customizing-the-web-interface)
|
||||
- [Enhancing the Web Interface Style with Custom CSS](#enhancing-the-web-interface-style-with-custom-css)
|
||||
@@ -52,6 +53,7 @@
|
||||
- [Saving and restoring the service state for service persistence](#understanding-service-persistence)
|
||||
- [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase)
|
||||
- [Support for units](#understanding-units-in-pydase)
|
||||
- [Validating Property Setters](#using-validate_set-to-validate-property-setters)
|
||||
<!-- Support for additional servers for specific use-cases -->
|
||||
|
||||
## Installation
|
||||
@@ -800,6 +802,45 @@ if __name__ == "__main__":
|
||||
|
||||
For more information about what you can do with the units, please consult the documentation of [`pint`](https://pint.readthedocs.io/en/stable/).
|
||||
|
||||
## Using `validate_set` to Validate Property Setters
|
||||
|
||||
The `validate_set` decorator ensures that a property setter reads back the set value using the property getter and checks it against the desired value.
|
||||
This decorator can be used to validate that a parameter has been correctly set on a device within a specified precision and timeout.
|
||||
|
||||
The decorator takes two keyword arguments: `timeout` and `precision`. The `timeout` argument specifies the maximum time (in seconds) to wait for the value to be within the precision boundary.
|
||||
If the value is not within the precision boundary after this time, an exception is raised.
|
||||
The `precision` argument defines the acceptable deviation from the desired value.
|
||||
If `precision` is `None`, the value must be exact.
|
||||
For example, if `precision` is set to `1e-5`, the value read from the device must be within ±0.00001 of the desired value.
|
||||
|
||||
Here’s how to use the `validate_set` decorator in a `DataService` class:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
from pydase.observer_pattern.observable.decorators import validate_set
|
||||
|
||||
|
||||
class Service(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._device = RemoteDevice() # dummy class
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
# Implement how to get the value from the remote device...
|
||||
return self._device.value
|
||||
|
||||
@value.setter
|
||||
@validate_set(timeout=1.0, precision=1e-5)
|
||||
def value(self, value: float) -> None:
|
||||
# Implement how to set the value on the remote device...
|
||||
self._device.value = value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pydase.Server(Service()).run()
|
||||
```
|
||||
|
||||
## Configuring pydase via Environment Variables
|
||||
|
||||
Configuring `pydase` through environment variables enhances flexibility, security, and reusability. This approach allows for easy adaptation of services across different environments without code changes, promoting scalability and maintainability. With that, it simplifies deployment processes and facilitates centralized configuration management. Moreover, environment variables enable separation of configuration from code, aiding in secure and collaborative development.
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"prettier"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"prettier/prettier": "error"
|
||||
}
|
||||
}
|
||||
38
frontend/.gitignore
vendored
38
frontend/.gitignore
vendored
@@ -1,20 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": true,
|
||||
"endOfLine": "auto",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"printWidth": 88,
|
||||
"trailingComma": "none"
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": true,
|
||||
"endOfLine": "auto",
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 88
|
||||
}
|
||||
|
||||
@@ -1,70 +1,30 @@
|
||||
# Getting Started with Create React App
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
## Available Scripts
|
||||
Currently, two official plugins are available:
|
||||
|
||||
In the project directory, you can run:
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
### `npm start`
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
### `npm test`
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
|
||||
24
frontend/eslint.config.js
Normal file
24
frontend/eslint.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
|
||||
import reactRecommended from "eslint-plugin-react/configs/recommended.js";
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.stylistic,
|
||||
{
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
...reactRecommended,
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
rules: {
|
||||
"prettier/prettier": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
},
|
||||
},
|
||||
eslintPluginPrettierRecommended,
|
||||
);
|
||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site displaying a pydase UI." />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
24624
frontend/package-lock.json
generated
24624
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,58 +1,40 @@
|
||||
{
|
||||
"name": "pydase",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fsouza/prettierd": "^0.25.1",
|
||||
"@mui/material": "^5.14.1",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.8.0",
|
||||
"react-bootstrap-icons": "^1.10.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"socket.io-client": "^4.7.1",
|
||||
"web-vitals": "^3.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=development react-scripts start",
|
||||
"build": "BUILD_PATH='../src/pydase/frontend' react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"@typescript-eslint/parser": "^6.9.0",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^4.9.0"
|
||||
}
|
||||
"name": "pydase",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build --emptyOutDir",
|
||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/material": "^5.14.1",
|
||||
"bootstrap": "^5.3.3",
|
||||
"deep-equal": "^2.2.3",
|
||||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.0",
|
||||
"react-bootstrap-icons": "^1.11.4",
|
||||
"socket.io-client": "^4.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.6.0",
|
||||
"@types/deep-equal": "^1.0.4",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.15.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react": "^7.34.3",
|
||||
"prettier": "3.3.2",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^7.15.0",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/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="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>pydase App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -1,43 +1,48 @@
|
||||
import { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap';
|
||||
import { hostname, port, socket } from './socket';
|
||||
import './App.css';
|
||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
import { Navbar, Form, Offcanvas, Container } from "react-bootstrap";
|
||||
import { hostname, port, socket } from "./socket";
|
||||
import "./App.css";
|
||||
import {
|
||||
Notifications,
|
||||
Notification,
|
||||
LevelName
|
||||
} from './components/NotificationsComponent';
|
||||
import { ConnectionToast } from './components/ConnectionToast';
|
||||
import { setNestedValueByPath, State } from './utils/stateUtils';
|
||||
import { WebSettingsContext, WebSetting } from './WebSettings';
|
||||
import { SerializedValue, GenericComponent } from './components/GenericComponent';
|
||||
LevelName,
|
||||
} from "./components/NotificationsComponent";
|
||||
import { ConnectionToast } from "./components/ConnectionToast";
|
||||
import { setNestedValueByPath, State } from "./utils/stateUtils";
|
||||
import { WebSettingsContext, WebSetting } from "./WebSettings";
|
||||
import { GenericComponent } from "./components/GenericComponent";
|
||||
import { SerializedObject } from "./types/SerializedObject";
|
||||
|
||||
type Action =
|
||||
| { type: 'SET_DATA'; data: State }
|
||||
| { type: "SET_DATA"; data: State }
|
||||
| {
|
||||
type: 'UPDATE_ATTRIBUTE';
|
||||
type: "UPDATE_ATTRIBUTE";
|
||||
fullAccessPath: string;
|
||||
newValue: SerializedValue;
|
||||
newValue: SerializedObject;
|
||||
};
|
||||
type UpdateMessage = {
|
||||
data: { full_access_path: string; value: SerializedValue };
|
||||
};
|
||||
type LogMessage = {
|
||||
interface UpdateMessage {
|
||||
data: { full_access_path: string; value: SerializedObject };
|
||||
}
|
||||
interface LogMessage {
|
||||
levelname: LevelName;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
const reducer = (state: State, action: Action): State => {
|
||||
const reducer = (state: State | null, action: Action): State | null => {
|
||||
switch (action.type) {
|
||||
case 'SET_DATA':
|
||||
case "SET_DATA":
|
||||
return action.data;
|
||||
case 'UPDATE_ATTRIBUTE': {
|
||||
case "UPDATE_ATTRIBUTE": {
|
||||
if (state === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
value: setNestedValueByPath(state.value, action.fullAccessPath, action.newValue)
|
||||
value: setNestedValueByPath(
|
||||
state.value as Record<string, SerializedObject>,
|
||||
action.fullAccessPath,
|
||||
action.newValue,
|
||||
),
|
||||
};
|
||||
}
|
||||
default:
|
||||
@@ -46,12 +51,19 @@ const reducer = (state: State, action: Action): State => {
|
||||
};
|
||||
const App = () => {
|
||||
const [state, dispatch] = useReducer(reducer, null);
|
||||
const [serviceName, setServiceName] = useState<string | null>(null);
|
||||
const [webSettings, setWebSettings] = useState<Record<string, WebSetting>>({});
|
||||
const [isInstantUpdate, setIsInstantUpdate] = useState(false);
|
||||
const [isInstantUpdate, setIsInstantUpdate] = useState(() => {
|
||||
const saved = localStorage.getItem("isInstantUpdate");
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showNotification, setShowNotification] = useState(false);
|
||||
const [showNotification, setShowNotification] = useState(() => {
|
||||
const saved = localStorage.getItem("showNotification");
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [connectionStatus, setConnectionStatus] = useState('connecting');
|
||||
const [connectionStatus, setConnectionStatus] = useState("connecting");
|
||||
|
||||
useEffect(() => {
|
||||
// Allow the user to add a custom css file
|
||||
@@ -59,49 +71,62 @@ const App = () => {
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
// If the file exists, create a link element for the custom CSS
|
||||
const link = document.createElement('link');
|
||||
const link = document.createElement("link");
|
||||
link.href = `http://${hostname}:${port}/custom.css`;
|
||||
link.type = 'text/css';
|
||||
link.rel = 'stylesheet';
|
||||
link.type = "text/css";
|
||||
link.rel = "stylesheet";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
})
|
||||
.catch(console.error); // Handle the error appropriately
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.on("connect", () => {
|
||||
// Fetch data from the API when the client connects
|
||||
fetch(`http://${hostname}:${port}/service-properties`)
|
||||
.then((response) => response.json())
|
||||
.then((data: State) => dispatch({ type: 'SET_DATA', data }));
|
||||
.then((data: State) => {
|
||||
dispatch({ type: "SET_DATA", data });
|
||||
setServiceName(data.name);
|
||||
|
||||
document.title = data.name; // Setting browser tab title
|
||||
});
|
||||
fetch(`http://${hostname}:${port}/web-settings`)
|
||||
.then((response) => response.json())
|
||||
.then((data: Record<string, WebSetting>) => setWebSettings(data));
|
||||
setConnectionStatus('connected');
|
||||
setConnectionStatus("connected");
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
setConnectionStatus('disconnected');
|
||||
socket.on("disconnect", () => {
|
||||
setConnectionStatus("disconnected");
|
||||
setTimeout(() => {
|
||||
// Only set "reconnecting" is the state is still "disconnected"
|
||||
// E.g. when the client has already reconnected
|
||||
setConnectionStatus((currentState) =>
|
||||
currentState === 'disconnected' ? 'reconnecting' : currentState
|
||||
currentState === "disconnected" ? "reconnecting" : currentState,
|
||||
);
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
socket.on('notify', onNotify);
|
||||
socket.on('log', onLogMessage);
|
||||
socket.on("notify", onNotify);
|
||||
socket.on("log", onLogMessage);
|
||||
|
||||
return () => {
|
||||
socket.off('notify', onNotify);
|
||||
socket.off('log', onLogMessage);
|
||||
socket.off("notify", onNotify);
|
||||
socket.off("log", onLogMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Persist isInstantUpdate and showNotification state changes to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("isInstantUpdate", JSON.stringify(isInstantUpdate));
|
||||
}, [isInstantUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("showNotification", JSON.stringify(showNotification));
|
||||
}, [showNotification]);
|
||||
// Adding useCallback to prevent notify to change causing a re-render of all
|
||||
// components
|
||||
const addNotification = useCallback(
|
||||
(message: string, levelname: LevelName = 'DEBUG') => {
|
||||
(message: string, levelname: LevelName = "DEBUG") => {
|
||||
// Getting the current time in the required format
|
||||
const timeStamp = new Date().toISOString().substring(11, 19);
|
||||
// Adding an id to the notification to provide a way of removing it
|
||||
@@ -110,15 +135,15 @@ const App = () => {
|
||||
// Custom logic for notifications
|
||||
setNotifications((prevNotifications) => [
|
||||
{ levelname, id, message, timeStamp },
|
||||
...prevNotifications
|
||||
...prevNotifications,
|
||||
]);
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const removeNotificationById = (id: number) => {
|
||||
setNotifications((prevNotifications) =>
|
||||
prevNotifications.filter((n) => n.id !== id)
|
||||
prevNotifications.filter((n) => n.id !== id),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -131,9 +156,9 @@ const App = () => {
|
||||
|
||||
// Dispatching the update to the reducer
|
||||
dispatch({
|
||||
type: 'UPDATE_ATTRIBUTE',
|
||||
type: "UPDATE_ATTRIBUTE",
|
||||
fullAccessPath,
|
||||
newValue
|
||||
newValue,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,7 +174,7 @@ const App = () => {
|
||||
<>
|
||||
<Navbar expand={false} bg="primary" variant="dark" fixed="top">
|
||||
<Container fluid>
|
||||
<Navbar.Brand>Data Service App</Navbar.Brand>
|
||||
<Navbar.Brand>{serviceName}</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="offcanvasNavbar" onClick={handleShowSettings} />
|
||||
</Container>
|
||||
</Navbar>
|
||||
@@ -188,7 +213,7 @@ const App = () => {
|
||||
<div className="App navbarOffset">
|
||||
<WebSettingsContext.Provider value={webSettings}>
|
||||
<GenericComponent
|
||||
attribute={state as SerializedValue}
|
||||
attribute={state as SerializedObject}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createContext } from 'react';
|
||||
import { createContext } from "react";
|
||||
|
||||
export const WebSettingsContext = createContext<Record<string, WebSetting>>({});
|
||||
|
||||
export type WebSetting = {
|
||||
export interface WebSetting {
|
||||
displayName: string;
|
||||
display: boolean;
|
||||
index: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { runMethod } from '../socket';
|
||||
import { Form, Button, InputGroup, Spinner } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { runMethod } from "../socket";
|
||||
import { Form, Button, InputGroup, Spinner } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { useRenderCount } from "../hooks/useRenderCount";
|
||||
|
||||
type AsyncMethodProps = {
|
||||
interface AsyncMethodProps {
|
||||
fullAccessPath: string;
|
||||
value: 'RUNNING' | null;
|
||||
docString?: string;
|
||||
value: "RUNNING" | null;
|
||||
docString: string | null;
|
||||
hideOutput?: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
render: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
const {
|
||||
@@ -22,7 +23,7 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
value: runningTask,
|
||||
addNotification,
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
} = props;
|
||||
|
||||
// Conditional rendering based on the 'render' prop.
|
||||
@@ -30,14 +31,13 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const formRef = useRef(null);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const name = fullAccessPath.split('.').at(-1);
|
||||
const name = fullAccessPath.split(".").at(-1)!;
|
||||
const parentPath = fullAccessPath.slice(0, -(name.length + 1));
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
let message: string;
|
||||
|
||||
if (runningTask === null) {
|
||||
@@ -59,16 +59,14 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
method_name = `start_${name}`;
|
||||
}
|
||||
|
||||
const accessPath = [parentPath, method_name].filter((element) => element).join('.');
|
||||
const accessPath = [parentPath, method_name].filter((element) => element).join(".");
|
||||
setSpinning(true);
|
||||
runMethod(accessPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="component asyncMethodComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<Form onSubmit={execute} ref={formRef}>
|
||||
<InputGroup>
|
||||
<InputGroup.Text>
|
||||
@@ -78,10 +76,10 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
<Button id={`button-${id}`} type="submit">
|
||||
{spinning ? (
|
||||
<Spinner size="sm" role="status" aria-hidden="true" />
|
||||
) : runningTask === 'RUNNING' ? (
|
||||
'Stop '
|
||||
) : runningTask === "RUNNING" ? (
|
||||
"Stop "
|
||||
) : (
|
||||
'Start '
|
||||
"Start "
|
||||
)}
|
||||
</Button>
|
||||
</InputGroup>
|
||||
@@ -89,3 +87,5 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AsyncMethodComponent.displayName = "AsyncMethodComponent";
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { ToggleButton } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useEffect } from "react";
|
||||
import { ToggleButton } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import { useRenderCount } from "../hooks/useRenderCount";
|
||||
|
||||
type ButtonComponentProps = {
|
||||
interface ButtonComponentProps {
|
||||
fullAccessPath: string;
|
||||
value: boolean;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
mapping?: [string, string]; // Enforce a tuple of two strings
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
const {
|
||||
@@ -25,15 +26,11 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
} = props;
|
||||
// const buttonName = props.mapping ? (value ? props.mapping[0] : props.mapping[1]) : name;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath} changed to ${value}.`);
|
||||
@@ -41,24 +38,22 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
|
||||
const setChecked = (checked: boolean) => {
|
||||
changeCallback({
|
||||
type: 'bool',
|
||||
type: "bool",
|
||||
value: checked,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
doc: docString,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'component buttonComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className={"component buttonComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
|
||||
<ToggleButton
|
||||
id={`toggle-check-${id}`}
|
||||
type="checkbox"
|
||||
variant={value ? 'success' : 'secondary'}
|
||||
variant={value ? "success" : "secondary"}
|
||||
checked={value}
|
||||
value={displayName}
|
||||
disabled={readOnly}
|
||||
@@ -69,3 +64,5 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ButtonComponent.displayName = "ButtonComponent";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Toast, Button, ToastContainer } from 'react-bootstrap';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Toast, Button, ToastContainer } from "react-bootstrap";
|
||||
|
||||
type ConnectionToastProps = {
|
||||
interface ConnectionToastProps {
|
||||
connectionStatus: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ConnectionToast Component
|
||||
@@ -36,31 +36,31 @@ export const ConnectionToast = React.memo(
|
||||
delay: number | undefined;
|
||||
} => {
|
||||
switch (connectionStatus) {
|
||||
case 'connecting':
|
||||
case "connecting":
|
||||
return {
|
||||
message: 'Connecting...',
|
||||
bg: 'info',
|
||||
delay: undefined
|
||||
message: "Connecting...",
|
||||
bg: "info",
|
||||
delay: undefined,
|
||||
};
|
||||
case 'connected':
|
||||
return { message: 'Connected', bg: 'success', delay: 1000 };
|
||||
case 'disconnected':
|
||||
case "connected":
|
||||
return { message: "Connected", bg: "success", delay: 1000 };
|
||||
case "disconnected":
|
||||
return {
|
||||
message: 'Disconnected',
|
||||
bg: 'danger',
|
||||
delay: undefined
|
||||
message: "Disconnected",
|
||||
bg: "danger",
|
||||
delay: undefined,
|
||||
};
|
||||
case 'reconnecting':
|
||||
case "reconnecting":
|
||||
return {
|
||||
message: 'Reconnecting...',
|
||||
bg: 'info',
|
||||
delay: undefined
|
||||
message: "Reconnecting...",
|
||||
bg: "info",
|
||||
delay: undefined,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
message: '',
|
||||
bg: 'info',
|
||||
delay: undefined
|
||||
message: "",
|
||||
bg: "info",
|
||||
delay: undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -82,5 +82,7 @@ export const ConnectionToast = React.memo(
|
||||
</Toast>
|
||||
</ToastContainer>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ConnectionToast.displayName = "ConnectionToast";
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Card, Collapse } from 'react-bootstrap';
|
||||
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
|
||||
import { SerializedValue, GenericComponent } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { Card, Collapse } from "react-bootstrap";
|
||||
import { ChevronDown, ChevronRight } from "react-bootstrap-icons";
|
||||
import { GenericComponent } from "./GenericComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
|
||||
type DataServiceProps = {
|
||||
interface DataServiceProps {
|
||||
props: DataServiceJSON;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type DataServiceJSON = Record<string, SerializedValue>;
|
||||
export type DataServiceJSON = Record<string, SerializedObject>;
|
||||
|
||||
export const DataServiceComponent = React.memo(
|
||||
({ props, isInstantUpdate, addNotification, displayName, id }: DataServiceProps) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
// Retrieve the initial state from localStorage, default to true if not found
|
||||
const [open, setOpen] = useState(() => {
|
||||
const savedState = localStorage.getItem(`dataServiceComponent-${id}-open`);
|
||||
return savedState !== null ? JSON.parse(savedState) : true;
|
||||
});
|
||||
|
||||
if (displayName !== '') {
|
||||
// Update localStorage whenever the state changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem(`dataServiceComponent-${id}-open`, JSON.stringify(open));
|
||||
}, [open]);
|
||||
|
||||
if (displayName !== "") {
|
||||
return (
|
||||
<div className="component dataServiceComponent" id={id}>
|
||||
<Card>
|
||||
<Card.Header onClick={() => setOpen(!open)} style={{ cursor: 'pointer' }}>
|
||||
<Card.Header onClick={() => setOpen(!open)} style={{ cursor: "pointer" }}>
|
||||
{displayName} {open ? <ChevronDown /> : <ChevronRight />}
|
||||
</Card.Header>
|
||||
<Collapse in={open}>
|
||||
@@ -55,5 +65,7 @@ export const DataServiceComponent = React.memo(
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
DataServiceComponent.displayName = "DataServiceComponent";
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React from 'react';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
|
||||
import { MethodComponent } from './MethodComponent';
|
||||
import React from "react";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { DataServiceComponent, DataServiceJSON } from "./DataServiceComponent";
|
||||
import { MethodComponent } from "./MethodComponent";
|
||||
|
||||
type DeviceConnectionProps = {
|
||||
interface DeviceConnectionProps {
|
||||
fullAccessPath: string;
|
||||
props: DataServiceJSON;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DeviceConnectionComponent = React.memo(
|
||||
({
|
||||
@@ -19,7 +19,7 @@ export const DeviceConnectionComponent = React.memo(
|
||||
isInstantUpdate,
|
||||
addNotification,
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
}: DeviceConnectionProps) => {
|
||||
const { connected, connect, ...updatedProps } = props;
|
||||
const connectedVal = connected.value;
|
||||
@@ -29,14 +29,14 @@ export const DeviceConnectionComponent = React.memo(
|
||||
{!connectedVal && (
|
||||
<div className="overlayContent">
|
||||
<div>
|
||||
{displayName != '' ? displayName : 'Device'} is currently not available!
|
||||
{displayName != "" ? displayName : "Device"} is currently not available!
|
||||
</div>
|
||||
<MethodComponent
|
||||
fullAccessPath={`${fullAccessPath}.connect`}
|
||||
docString={connect.doc}
|
||||
addNotification={addNotification}
|
||||
displayName={'reconnect'}
|
||||
id={id + '-connect'}
|
||||
displayName={"reconnect"}
|
||||
id={id + "-connect"}
|
||||
render={true}
|
||||
/>
|
||||
</div>
|
||||
@@ -50,5 +50,7 @@ export const DeviceConnectionComponent = React.memo(
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
DeviceConnectionComponent.displayName = "DeviceConnectionComponent";
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { SerializedValue, GenericComponent } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React from "react";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { GenericComponent } from "./GenericComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import { useRenderCount } from "../hooks/useRenderCount";
|
||||
|
||||
type DictComponentProps = {
|
||||
value: Record<string, SerializedValue>;
|
||||
docString: string;
|
||||
interface DictComponentProps {
|
||||
value: Record<string, SerializedObject>;
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DictComponent = React.memo((props: DictComponentProps) => {
|
||||
const { value, docString, isInstantUpdate, addNotification, id } = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const valueArray = Object.values(value);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<div className={'listComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className={"listComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<DocStringComponent docString={docString} />
|
||||
{valueArray.map((item) => {
|
||||
return (
|
||||
@@ -40,3 +36,5 @@ export const DictComponent = React.memo((props: DictComponentProps) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DictComponent.displayName = "DictComponent";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Badge, Tooltip, OverlayTrigger } from 'react-bootstrap';
|
||||
import React from 'react';
|
||||
import { Badge, Tooltip, OverlayTrigger } from "react-bootstrap";
|
||||
import React from "react";
|
||||
|
||||
type DocStringProps = {
|
||||
docString?: string;
|
||||
};
|
||||
interface DocStringProps {
|
||||
docString?: string | null;
|
||||
}
|
||||
|
||||
export const DocStringComponent = React.memo((props: DocStringProps) => {
|
||||
const { docString } = props;
|
||||
@@ -21,3 +21,5 @@ export const DocStringComponent = React.memo((props: DocStringProps) => {
|
||||
</OverlayTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
DocStringComponent.displayName = "DocStringComponent";
|
||||
|
||||
@@ -1,64 +1,40 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useEffect } from "react";
|
||||
import { InputGroup, Form, Row, Col } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject, SerializedEnum } from "../types/SerializedObject";
|
||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||
import { useRenderCount } from "../hooks/useRenderCount";
|
||||
|
||||
export type EnumSerialization = {
|
||||
type: 'Enum' | 'ColouredEnum';
|
||||
full_access_path: string;
|
||||
name: string;
|
||||
value: string;
|
||||
readonly: boolean;
|
||||
doc?: string | null;
|
||||
enum: Record<string, string>;
|
||||
};
|
||||
|
||||
type EnumComponentProps = {
|
||||
attribute: EnumSerialization;
|
||||
interface EnumComponentProps extends SerializedEnum {
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
};
|
||||
changeCallback: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
}
|
||||
|
||||
export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
||||
const { attribute, addNotification, displayName, id } = props;
|
||||
const {
|
||||
full_access_path: fullAccessPath,
|
||||
addNotification,
|
||||
displayName,
|
||||
id,
|
||||
value,
|
||||
doc: docString,
|
||||
full_access_path: fullAccessPath,
|
||||
enum: enumDict,
|
||||
readonly: readOnly
|
||||
} = attribute;
|
||||
doc: docString,
|
||||
readonly: readOnly,
|
||||
changeCallback,
|
||||
} = props;
|
||||
|
||||
let { changeCallback } = props;
|
||||
if (changeCallback === undefined) {
|
||||
changeCallback = (value: SerializedValue) => {
|
||||
setEnumValue(() => {
|
||||
return String(value.value);
|
||||
});
|
||||
};
|
||||
}
|
||||
const renderCount = useRef(0);
|
||||
const [enumValue, setEnumValue] = useState(value);
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setEnumValue(() => {
|
||||
return value;
|
||||
});
|
||||
addNotification(`${fullAccessPath} changed to ${value}.`);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={'component enumComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className={"component enumComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<Row>
|
||||
<Col className="d-flex align-items-center">
|
||||
<InputGroup.Text>
|
||||
@@ -70,11 +46,9 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
||||
// Display the Form.Control when readOnly is true
|
||||
<Form.Control
|
||||
style={
|
||||
attribute.type == 'ColouredEnum'
|
||||
? { backgroundColor: enumDict[enumValue] }
|
||||
: {}
|
||||
props.type == "ColouredEnum" ? { backgroundColor: enumDict[value] } : {}
|
||||
}
|
||||
value={attribute.type == 'ColouredEnum' ? enumValue : enumDict[enumValue]}
|
||||
value={props.type == "ColouredEnum" ? value : enumDict[value]}
|
||||
name={fullAccessPath}
|
||||
disabled={true}
|
||||
/>
|
||||
@@ -82,27 +56,25 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
||||
// Display the Form.Select when readOnly is false
|
||||
<Form.Select
|
||||
aria-label="example-select"
|
||||
value={enumValue}
|
||||
value={value}
|
||||
name={fullAccessPath}
|
||||
style={
|
||||
attribute.type == 'ColouredEnum'
|
||||
? { backgroundColor: enumDict[enumValue] }
|
||||
: {}
|
||||
props.type == "ColouredEnum" ? { backgroundColor: enumDict[value] } : {}
|
||||
}
|
||||
onChange={(event) =>
|
||||
changeCallback({
|
||||
type: attribute.type,
|
||||
name: attribute.name,
|
||||
type: props.type,
|
||||
name: props.name,
|
||||
enum: enumDict,
|
||||
value: event.target.value,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: attribute.readonly,
|
||||
doc: attribute.doc
|
||||
readonly: props.readonly,
|
||||
doc: props.doc,
|
||||
})
|
||||
}>
|
||||
{Object.entries(enumDict).map(([key, val]) => (
|
||||
<option key={key} value={key}>
|
||||
{attribute.type == 'ColouredEnum' ? key : val}
|
||||
{props.type == "ColouredEnum" ? key : val}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
@@ -111,4 +83,6 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, propsAreEqual);
|
||||
|
||||
EnumComponent.displayName = "EnumComponent";
|
||||
|
||||
@@ -1,62 +1,34 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { ButtonComponent } from './ButtonComponent';
|
||||
import { NumberComponent } from './NumberComponent';
|
||||
import { SliderComponent } from './SliderComponent';
|
||||
import { EnumComponent, EnumSerialization } from './EnumComponent';
|
||||
import { MethodComponent } from './MethodComponent';
|
||||
import { AsyncMethodComponent } from './AsyncMethodComponent';
|
||||
import { StringComponent } from './StringComponent';
|
||||
import { ListComponent } from './ListComponent';
|
||||
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
|
||||
import { DeviceConnectionComponent } from './DeviceConnection';
|
||||
import { ImageComponent } from './ImageComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import { updateValue } from '../socket';
|
||||
import { DictComponent } from './DictComponent';
|
||||
import { parseFullAccessPath } from '../utils/stateUtils';
|
||||
import React, { useContext } from "react";
|
||||
import { ButtonComponent } from "./ButtonComponent";
|
||||
import { NumberComponent, NumberObject } from "./NumberComponent";
|
||||
import { SliderComponent } from "./SliderComponent";
|
||||
import { EnumComponent } from "./EnumComponent";
|
||||
import { MethodComponent } from "./MethodComponent";
|
||||
import { AsyncMethodComponent } from "./AsyncMethodComponent";
|
||||
import { StringComponent } from "./StringComponent";
|
||||
import { ListComponent } from "./ListComponent";
|
||||
import { DataServiceComponent, DataServiceJSON } from "./DataServiceComponent";
|
||||
import { DeviceConnectionComponent } from "./DeviceConnection";
|
||||
import { ImageComponent } from "./ImageComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { getIdFromFullAccessPath } from "../utils/stringUtils";
|
||||
import { WebSettingsContext } from "../WebSettings";
|
||||
import { updateValue } from "../socket";
|
||||
import { DictComponent } from "./DictComponent";
|
||||
import { parseFullAccessPath } from "../utils/stateUtils";
|
||||
import { SerializedEnum, SerializedObject } from "../types/SerializedObject";
|
||||
|
||||
type AttributeType =
|
||||
| 'str'
|
||||
| 'bool'
|
||||
| 'float'
|
||||
| 'int'
|
||||
| 'Quantity'
|
||||
| 'None'
|
||||
| 'list'
|
||||
| 'dict'
|
||||
| 'method'
|
||||
| 'DataService'
|
||||
| 'DeviceConnection'
|
||||
| 'Enum'
|
||||
| 'NumberSlider'
|
||||
| 'Image'
|
||||
| 'ColouredEnum';
|
||||
|
||||
type ValueType = boolean | string | number | Record<string, unknown>;
|
||||
export type SerializedValue = {
|
||||
type: AttributeType;
|
||||
full_access_path: string;
|
||||
name?: string;
|
||||
value?: ValueType | ValueType[];
|
||||
readonly: boolean;
|
||||
doc?: string | null;
|
||||
async?: boolean;
|
||||
frontend_render?: boolean;
|
||||
enum?: Record<string, string>;
|
||||
};
|
||||
type GenericComponentProps = {
|
||||
attribute: SerializedValue;
|
||||
interface GenericComponentProps {
|
||||
attribute: SerializedObject;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const getPathFromPathParts = (pathParts: string[]): string => {
|
||||
let path = '';
|
||||
let path = "";
|
||||
for (const pathPart of pathParts) {
|
||||
if (!pathPart.startsWith('[') && path !== '') {
|
||||
path += '.';
|
||||
if (!pathPart.startsWith("[") && path !== "") {
|
||||
path += ".";
|
||||
}
|
||||
path += pathPart;
|
||||
}
|
||||
@@ -69,13 +41,20 @@ const createDisplayNameFromAccessPath = (fullAccessPath: string): string => {
|
||||
for (let i = parsedFullAccessPath.length - 1; i >= 0; i--) {
|
||||
const item = parsedFullAccessPath[i];
|
||||
displayNameParts.unshift(item);
|
||||
if (!item.startsWith('[')) {
|
||||
if (!item.startsWith("[")) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return getPathFromPathParts(displayNameParts);
|
||||
};
|
||||
|
||||
function changeCallback(
|
||||
value: SerializedObject,
|
||||
callback: (ack: unknown) => void = () => {},
|
||||
) {
|
||||
updateValue(value, callback);
|
||||
}
|
||||
|
||||
export const GenericComponent = React.memo(
|
||||
({ attribute, isInstantUpdate, addNotification }: GenericComponentProps) => {
|
||||
const { full_access_path: fullAccessPath } = attribute;
|
||||
@@ -93,14 +72,7 @@ export const GenericComponent = React.memo(
|
||||
}
|
||||
}
|
||||
|
||||
function changeCallback(
|
||||
value: SerializedValue,
|
||||
callback: (ack: unknown) => void = undefined
|
||||
) {
|
||||
updateValue(value, callback);
|
||||
}
|
||||
|
||||
if (attribute.type === 'bool') {
|
||||
if (attribute.type === "bool") {
|
||||
return (
|
||||
<ButtonComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
@@ -113,7 +85,7 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'float' || attribute.type === 'int') {
|
||||
} else if (attribute.type === "float" || attribute.type === "int") {
|
||||
return (
|
||||
<NumberComponent
|
||||
type={attribute.type}
|
||||
@@ -128,15 +100,15 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Quantity') {
|
||||
} else if (attribute.type === "Quantity") {
|
||||
return (
|
||||
<NumberComponent
|
||||
type="Quantity"
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={Number(attribute.value['magnitude'])}
|
||||
unit={attribute.value['unit']}
|
||||
value={Number(attribute.value["magnitude"])}
|
||||
unit={attribute.value["unit"]}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
@@ -144,16 +116,16 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'NumberSlider') {
|
||||
} else if (attribute.type === "NumberSlider") {
|
||||
return (
|
||||
<SliderComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.value['value'].doc}
|
||||
docString={attribute.value["value"].doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={attribute.value['value']}
|
||||
min={attribute.value['min']}
|
||||
max={attribute.value['max']}
|
||||
stepSize={attribute.value['step_size']}
|
||||
value={attribute.value["value"] as NumberObject}
|
||||
min={attribute.value["min"] as NumberObject}
|
||||
max={attribute.value["max"] as NumberObject}
|
||||
stepSize={attribute.value["step_size"] as NumberObject}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
@@ -161,17 +133,17 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Enum' || attribute.type === 'ColouredEnum') {
|
||||
} else if (attribute.type === "Enum" || attribute.type === "ColouredEnum") {
|
||||
return (
|
||||
<EnumComponent
|
||||
attribute={attribute as EnumSerialization}
|
||||
{...(attribute as SerializedEnum)}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'method') {
|
||||
} else if (attribute.type === "method") {
|
||||
if (!attribute.async) {
|
||||
return (
|
||||
<MethodComponent
|
||||
@@ -188,7 +160,7 @@ export const GenericComponent = React.memo(
|
||||
<AsyncMethodComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
value={attribute.value as 'RUNNING' | null}
|
||||
value={attribute.value as "RUNNING" | null}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
@@ -196,7 +168,7 @@ export const GenericComponent = React.memo(
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (attribute.type === 'str') {
|
||||
} else if (attribute.type === "str") {
|
||||
return (
|
||||
<StringComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
@@ -210,7 +182,7 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'DataService') {
|
||||
} else if (attribute.type === "DataService") {
|
||||
return (
|
||||
<DataServiceComponent
|
||||
props={attribute.value as DataServiceJSON}
|
||||
@@ -220,7 +192,7 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'DeviceConnection') {
|
||||
} else if (attribute.type === "DeviceConnection") {
|
||||
return (
|
||||
<DeviceConnectionComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
@@ -231,41 +203,42 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'list') {
|
||||
} else if (attribute.type === "list") {
|
||||
return (
|
||||
<ListComponent
|
||||
value={attribute.value as SerializedValue[]}
|
||||
value={attribute.value}
|
||||
docString={attribute.doc}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'dict') {
|
||||
} else if (attribute.type === "dict") {
|
||||
return (
|
||||
<DictComponent
|
||||
value={attribute.value as Record<string, SerializedValue>}
|
||||
value={attribute.value}
|
||||
docString={attribute.doc}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Image') {
|
||||
} else if (attribute.type === "Image") {
|
||||
return (
|
||||
<ImageComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.value['value'].doc}
|
||||
docString={attribute.value["value"].doc}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
addNotification={addNotification}
|
||||
// Add any other specific props for the ImageComponent here
|
||||
value={attribute.value['value']['value'] as string}
|
||||
format={attribute.value['format']['value'] as string}
|
||||
value={attribute.value["value"]["value"] as string}
|
||||
format={attribute.value["format"]["value"] as string}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <div key={fullAccessPath}>{fullAccessPath}</div>;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
GenericComponent.displayName = "GenericComponent";
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Card, Collapse, Image } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, Collapse, Image } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { ChevronDown, ChevronRight } from "react-bootstrap-icons";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { useRenderCount } from "../hooks/useRenderCount";
|
||||
|
||||
type ImageComponentProps = {
|
||||
interface ImageComponentProps {
|
||||
fullAccessPath: string;
|
||||
value: string;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
format: string;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
const { fullAccessPath, value, docString, format, addNotification, displayName, id } =
|
||||
props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath} changed.`);
|
||||
}, [props.value]);
|
||||
@@ -34,7 +31,7 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
<Card>
|
||||
<Card.Header
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{ cursor: 'pointer' }} // Change cursor style on hover
|
||||
style={{ cursor: "pointer" }} // Change cursor style on hover
|
||||
>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
@@ -42,10 +39,10 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
</Card.Header>
|
||||
<Collapse in={open}>
|
||||
<Card.Body>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<p>Render count: {renderCount.current}</p>
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<p>Render count: {renderCount}</p>
|
||||
)}
|
||||
{format === '' && value === '' ? (
|
||||
{format === "" && value === "" ? (
|
||||
<p>No image set in the backend.</p>
|
||||
) : (
|
||||
<Image src={`data:image/${format.toLowerCase()};base64,${value}`}></Image>
|
||||
@@ -56,3 +53,5 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ImageComponent.displayName = "ImageComponent";
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { SerializedValue, GenericComponent } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React from "react";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { GenericComponent } from "./GenericComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import { useRenderCount } from "../hooks/useRenderCount";
|
||||
|
||||
type ListComponentProps = {
|
||||
value: SerializedValue[];
|
||||
docString: string;
|
||||
interface ListComponentProps {
|
||||
value: SerializedObject[];
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ListComponent = React.memo((props: ListComponentProps) => {
|
||||
const { value, docString, isInstantUpdate, addNotification, id } = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
}, [props]);
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
return (
|
||||
<div className={'listComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className={"listComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<DocStringComponent docString={docString} />
|
||||
{value.map((item) => {
|
||||
return (
|
||||
@@ -39,3 +35,5 @@ export const ListComponent = React.memo((props: ListComponentProps) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ListComponent.displayName = "ListComponent";
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { runMethod } from '../socket';
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useRef } from "react";
|
||||
import { runMethod } from "../socket";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { useRenderCount } from "../hooks/useRenderCount";
|
||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||
|
||||
type MethodProps = {
|
||||
interface MethodProps {
|
||||
fullAccessPath: string;
|
||||
docString?: string;
|
||||
docString: string | null;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
render: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const MethodComponent = React.memo((props: MethodProps) => {
|
||||
const { fullAccessPath, docString, addNotification, displayName, id } = props;
|
||||
@@ -21,7 +23,7 @@ export const MethodComponent = React.memo((props: MethodProps) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const formRef = useRef(null);
|
||||
|
||||
const triggerNotification = () => {
|
||||
@@ -37,15 +39,9 @@ export const MethodComponent = React.memo((props: MethodProps) => {
|
||||
triggerNotification();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="component methodComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<Form onSubmit={execute} ref={formRef}>
|
||||
<Button className="component" variant="primary" type="submit">
|
||||
{`${displayName} `}
|
||||
@@ -54,4 +50,6 @@ export const MethodComponent = React.memo((props: MethodProps) => {
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, propsAreEqual);
|
||||
|
||||
MethodComponent.displayName = "MethodComponent";
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React from 'react';
|
||||
import { ToastContainer, Toast } from 'react-bootstrap';
|
||||
import React from "react";
|
||||
import { ToastContainer, Toast } from "react-bootstrap";
|
||||
|
||||
export type LevelName = 'CRITICAL' | 'ERROR' | 'WARNING' | 'INFO' | 'DEBUG';
|
||||
export type Notification = {
|
||||
export type LevelName = "CRITICAL" | "ERROR" | "WARNING" | "INFO" | "DEBUG";
|
||||
export interface Notification {
|
||||
id: number;
|
||||
timeStamp: string;
|
||||
message: string;
|
||||
levelname: LevelName;
|
||||
};
|
||||
}
|
||||
|
||||
type NotificationProps = {
|
||||
interface NotificationProps {
|
||||
showNotification: boolean;
|
||||
notifications: Notification[];
|
||||
removeNotificationById: (id: number) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const Notifications = React.memo((props: NotificationProps) => {
|
||||
const { showNotification, notifications, removeNotificationById } = props;
|
||||
@@ -23,10 +23,10 @@ export const Notifications = React.memo((props: NotificationProps) => {
|
||||
{notifications.map((notification) => {
|
||||
// Determine if the toast should be shown
|
||||
const shouldShow =
|
||||
notification.levelname === 'ERROR' ||
|
||||
notification.levelname === 'CRITICAL' ||
|
||||
notification.levelname === "ERROR" ||
|
||||
notification.levelname === "CRITICAL" ||
|
||||
(showNotification &&
|
||||
['WARNING', 'INFO', 'DEBUG'].includes(notification.levelname));
|
||||
["WARNING", "INFO", "DEBUG"].includes(notification.levelname));
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
@@ -34,31 +34,31 @@ export const Notifications = React.memo((props: NotificationProps) => {
|
||||
|
||||
return (
|
||||
<Toast
|
||||
className={notification.levelname.toLowerCase() + 'Toast'}
|
||||
className={notification.levelname.toLowerCase() + "Toast"}
|
||||
key={notification.id}
|
||||
onClose={() => removeNotificationById(notification.id)}
|
||||
onClick={() => removeNotificationById(notification.id)}
|
||||
onMouseLeave={() => {
|
||||
if (notification.levelname !== 'ERROR') {
|
||||
if (notification.levelname !== "ERROR") {
|
||||
removeNotificationById(notification.id);
|
||||
}
|
||||
}}
|
||||
show={true}
|
||||
autohide={
|
||||
notification.levelname === 'WARNING' ||
|
||||
notification.levelname === 'INFO' ||
|
||||
notification.levelname === 'DEBUG'
|
||||
notification.levelname === "WARNING" ||
|
||||
notification.levelname === "INFO" ||
|
||||
notification.levelname === "DEBUG"
|
||||
}
|
||||
delay={
|
||||
notification.levelname === 'WARNING' ||
|
||||
notification.levelname === 'INFO' ||
|
||||
notification.levelname === 'DEBUG'
|
||||
notification.levelname === "WARNING" ||
|
||||
notification.levelname === "INFO" ||
|
||||
notification.levelname === "DEBUG"
|
||||
? 2000
|
||||
: undefined
|
||||
}>
|
||||
<Toast.Header
|
||||
closeButton={false}
|
||||
className={notification.levelname.toLowerCase() + 'Toast text-right'}>
|
||||
className={notification.levelname.toLowerCase() + "Toast text-right"}>
|
||||
<strong className="me-auto">{notification.levelname}</strong>
|
||||
<small>{notification.timeStamp}</small>
|
||||
</Toast.Header>
|
||||
@@ -69,3 +69,5 @@ export const Notifications = React.memo((props: NotificationProps) => {
|
||||
</ToastContainer>
|
||||
);
|
||||
});
|
||||
|
||||
Notifications.displayName = "Notifications";
|
||||
|
||||
@@ -1,59 +1,58 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Form, InputGroup } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import '../App.css';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Form, InputGroup } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import "../App.css";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import { QuantityMap } from "../types/QuantityMap";
|
||||
import { useRenderCount } from "../hooks/useRenderCount";
|
||||
|
||||
// TODO: add button functionality
|
||||
|
||||
export type QuantityObject = {
|
||||
type: 'Quantity';
|
||||
export interface QuantityObject {
|
||||
type: "Quantity";
|
||||
readonly: boolean;
|
||||
value: {
|
||||
magnitude: number;
|
||||
unit: string;
|
||||
};
|
||||
doc?: string;
|
||||
};
|
||||
export type IntObject = {
|
||||
type: 'int';
|
||||
value: QuantityMap;
|
||||
doc: string | null;
|
||||
}
|
||||
export interface IntObject {
|
||||
type: "int";
|
||||
readonly: boolean;
|
||||
value: number;
|
||||
doc?: string;
|
||||
};
|
||||
export type FloatObject = {
|
||||
type: 'float';
|
||||
doc: string | null;
|
||||
}
|
||||
export interface FloatObject {
|
||||
type: "float";
|
||||
readonly: boolean;
|
||||
value: number;
|
||||
doc?: string;
|
||||
};
|
||||
doc: string | null;
|
||||
}
|
||||
export type NumberObject = IntObject | FloatObject | QuantityObject;
|
||||
|
||||
type NumberComponentProps = {
|
||||
type: 'float' | 'int' | 'Quantity';
|
||||
interface NumberComponentProps {
|
||||
type: "float" | "int" | "Quantity";
|
||||
fullAccessPath: string;
|
||||
value: number;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
unit?: string;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
displayName?: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: highlight the digit that is being changed by setting both selectionStart and
|
||||
// selectionEnd
|
||||
const handleArrowKey = (
|
||||
key: string,
|
||||
value: string,
|
||||
selectionStart: number
|
||||
selectionStart: number,
|
||||
// selectionEnd: number
|
||||
) => {
|
||||
// Split the input value into the integer part and decimal part
|
||||
const parts = value.split('.');
|
||||
const parts = value.split(".");
|
||||
const beforeDecimalCount = parts[0].length; // Count digits before the decimal
|
||||
const afterDecimalCount = parts[1] ? parts[1].length : 0; // Count digits after the decimal
|
||||
|
||||
@@ -69,14 +68,14 @@ const handleArrowKey = (
|
||||
|
||||
// Convert the input value to a number, increment or decrement it based on the
|
||||
// arrow key
|
||||
const numValue = parseFloat(value) + (key === 'ArrowUp' ? increment : -increment);
|
||||
const numValue = parseFloat(value) + (key === "ArrowUp" ? increment : -increment);
|
||||
|
||||
// Convert the resulting number to a string, maintaining the same number of digits
|
||||
// after the decimal
|
||||
const newValue = numValue.toFixed(afterDecimalCount);
|
||||
|
||||
// Check if the length of the integer part of the number string has in-/decreased
|
||||
const newBeforeDecimalCount = newValue.split('.')[0].length;
|
||||
const newBeforeDecimalCount = newValue.split(".")[0].length;
|
||||
if (newBeforeDecimalCount > beforeDecimalCount) {
|
||||
// Move the cursor one position to the right
|
||||
selectionStart += 1;
|
||||
@@ -90,18 +89,18 @@ const handleArrowKey = (
|
||||
const handleBackspaceKey = (
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
selectionEnd: number,
|
||||
) => {
|
||||
if (selectionEnd > selectionStart) {
|
||||
// If there is a selection, delete all characters in the selection
|
||||
return {
|
||||
value: value.slice(0, selectionStart) + value.slice(selectionEnd),
|
||||
selectionStart
|
||||
selectionStart,
|
||||
};
|
||||
} else if (selectionStart > 0) {
|
||||
return {
|
||||
value: value.slice(0, selectionStart - 1) + value.slice(selectionStart),
|
||||
selectionStart: selectionStart - 1
|
||||
selectionStart: selectionStart - 1,
|
||||
};
|
||||
}
|
||||
return { value, selectionStart };
|
||||
@@ -110,18 +109,18 @@ const handleBackspaceKey = (
|
||||
const handleDeleteKey = (
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
selectionEnd: number,
|
||||
) => {
|
||||
if (selectionEnd > selectionStart) {
|
||||
// If there is a selection, delete all characters in the selection
|
||||
return {
|
||||
value: value.slice(0, selectionStart) + value.slice(selectionEnd),
|
||||
selectionStart
|
||||
selectionStart,
|
||||
};
|
||||
} else if (selectionStart < value.length) {
|
||||
return {
|
||||
value: value.slice(0, selectionStart) + value.slice(selectionStart + 1),
|
||||
selectionStart
|
||||
selectionStart,
|
||||
};
|
||||
}
|
||||
return { value, selectionStart };
|
||||
@@ -131,12 +130,12 @@ const handleNumericKey = (
|
||||
key: string,
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
selectionEnd: number,
|
||||
) => {
|
||||
// Check if a number key or a decimal point key is pressed
|
||||
if (key === '.' && value.includes('.')) {
|
||||
if (key === "." && value.includes(".")) {
|
||||
// Check if value already contains a decimal. If so, ignore input.
|
||||
console.warn('Invalid input! Ignoring...');
|
||||
console.warn("Invalid input! Ignoring...");
|
||||
return { value, selectionStart };
|
||||
}
|
||||
|
||||
@@ -166,98 +165,111 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
} = props;
|
||||
|
||||
// Create a state for the cursor position
|
||||
const [cursorPosition, setCursorPosition] = useState(null);
|
||||
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
|
||||
// Create a state for the input string
|
||||
const [inputString, setInputString] = useState(value.toString());
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const { key, target } = event;
|
||||
console.log(typeof key);
|
||||
|
||||
// Typecast
|
||||
const inputTarget = target as HTMLInputElement;
|
||||
if (
|
||||
key === 'F1' ||
|
||||
key === 'F5' ||
|
||||
key === 'F12' ||
|
||||
key === 'Tab' ||
|
||||
key === 'ArrowRight' ||
|
||||
key === 'ArrowLeft'
|
||||
key === "F1" ||
|
||||
key === "F5" ||
|
||||
key === "F12" ||
|
||||
key === "Tab" ||
|
||||
key === "ArrowRight" ||
|
||||
key === "ArrowLeft"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
// Get the current input value and cursor position
|
||||
const { value } = target;
|
||||
let { selectionStart } = target;
|
||||
const { selectionEnd } = target;
|
||||
const { value } = inputTarget;
|
||||
const selectionEnd = inputTarget.selectionEnd ?? 0;
|
||||
let selectionStart = inputTarget.selectionStart ?? 0;
|
||||
|
||||
let newValue: string = value;
|
||||
if (event.ctrlKey && key === 'a') {
|
||||
if (event.ctrlKey && key === "a") {
|
||||
// Select everything when pressing Ctrl + a
|
||||
target.setSelectionRange(0, target.value.length);
|
||||
inputTarget.setSelectionRange(0, value.length);
|
||||
return;
|
||||
} else if (key === '-') {
|
||||
if (selectionStart === 0 && !value.startsWith('-')) {
|
||||
newValue = '-' + value;
|
||||
} else if (key === "-") {
|
||||
if (selectionStart === 0 && !value.startsWith("-")) {
|
||||
newValue = "-" + value;
|
||||
selectionStart++;
|
||||
} else if (value.startsWith('-') && selectionStart === 1) {
|
||||
} else if (value.startsWith("-") && selectionStart === 1) {
|
||||
newValue = value.substring(1); // remove minus sign
|
||||
selectionStart--;
|
||||
} else {
|
||||
return; // Ignore "-" pressed in other positions
|
||||
}
|
||||
} else if (!isNaN(key) && key !== ' ') {
|
||||
} else if (key >= "0" && key <= "9") {
|
||||
// Check if a number key or a decimal point key is pressed
|
||||
({ value: newValue, selectionStart } = handleNumericKey(
|
||||
key,
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === '.' && (type === 'float' || type === 'Quantity')) {
|
||||
} else if (key === "." && (type === "float" || type === "Quantity")) {
|
||||
({ value: newValue, selectionStart } = handleNumericKey(
|
||||
key,
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === 'ArrowUp' || key === 'ArrowDown') {
|
||||
} else if (key === "ArrowUp" || key === "ArrowDown") {
|
||||
({ value: newValue, selectionStart } = handleArrowKey(
|
||||
key,
|
||||
value,
|
||||
selectionStart
|
||||
selectionStart,
|
||||
// selectionEnd
|
||||
));
|
||||
} else if (key === 'Backspace') {
|
||||
} else if (key === "Backspace") {
|
||||
({ value: newValue, selectionStart } = handleBackspaceKey(
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === 'Delete') {
|
||||
} else if (key === "Delete") {
|
||||
({ value: newValue, selectionStart } = handleDeleteKey(
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === 'Enter' && !isInstantUpdate) {
|
||||
let updatedValue: number | Record<string, unknown> = Number(newValue);
|
||||
if (type === 'Quantity') {
|
||||
updatedValue = {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit
|
||||
} else if (key === "Enter" && !isInstantUpdate) {
|
||||
let serializedObject: SerializedObject;
|
||||
if (type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: type,
|
||||
value: Number(newValue),
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
changeCallback({
|
||||
type: type,
|
||||
value: updatedValue,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
});
|
||||
|
||||
changeCallback(serializedObject);
|
||||
return;
|
||||
} else {
|
||||
console.debug(key);
|
||||
@@ -266,20 +278,29 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
|
||||
// Update the input value and maintain the cursor position
|
||||
if (isInstantUpdate) {
|
||||
let updatedValue: number | Record<string, unknown> = Number(newValue);
|
||||
if (type === 'Quantity') {
|
||||
updatedValue = {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit
|
||||
let serializedObject: SerializedObject;
|
||||
if (type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: type,
|
||||
value: Number(newValue),
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
changeCallback({
|
||||
type: type,
|
||||
value: updatedValue,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
});
|
||||
|
||||
changeCallback(serializedObject);
|
||||
}
|
||||
|
||||
setInputString(newValue);
|
||||
@@ -291,26 +312,35 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
const handleBlur = () => {
|
||||
if (!isInstantUpdate) {
|
||||
// If not in "instant update" mode, emit an update when the input field loses focus
|
||||
let updatedValue: number | Record<string, unknown> = Number(inputString);
|
||||
if (type === 'Quantity') {
|
||||
updatedValue = {
|
||||
magnitude: Number(inputString),
|
||||
unit: unit
|
||||
let serializedObject: SerializedObject;
|
||||
if (type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: Number(inputString),
|
||||
unit: unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: type,
|
||||
value: Number(inputString),
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
changeCallback({
|
||||
type: type,
|
||||
value: updatedValue,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
});
|
||||
|
||||
changeCallback(serializedObject);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
// Parse the input string to a number for comparison
|
||||
const numericInputString =
|
||||
type === 'int' ? parseInt(inputString) : parseFloat(inputString);
|
||||
type === "int" ? parseInt(inputString) : parseFloat(inputString);
|
||||
// Only update the inputString if it's different from the prop value
|
||||
if (value !== numericInputString) {
|
||||
setInputString(value.toString());
|
||||
@@ -319,7 +349,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
// emitting notification
|
||||
let notificationMsg = `${fullAccessPath} changed to ${props.value}`;
|
||||
if (unit === undefined) {
|
||||
notificationMsg += '.';
|
||||
notificationMsg += ".";
|
||||
} else {
|
||||
notificationMsg += ` ${unit}.`;
|
||||
}
|
||||
@@ -336,9 +366,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
|
||||
return (
|
||||
<div className="component numberComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<InputGroup>
|
||||
{displayName && (
|
||||
<InputGroup.Text>
|
||||
@@ -354,10 +382,12 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
name={id}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
|
||||
className={isInstantUpdate && !readOnly ? "instantUpdate" : ""}
|
||||
/>
|
||||
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
NumberComponent.displayName = "NumberComponent";
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { Slider } from '@mui/material';
|
||||
import { NumberComponent, NumberObject } from './NumberComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { Slider } from "@mui/material";
|
||||
import { NumberComponent, NumberObject } from "./NumberComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import { QuantityMap } from "../types/QuantityMap";
|
||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||
import { useRenderCount } from "../hooks/useRenderCount";
|
||||
|
||||
type SliderComponentProps = {
|
||||
interface SliderComponentProps {
|
||||
fullAccessPath: string;
|
||||
min: NumberObject;
|
||||
max: NumberObject;
|
||||
value: NumberObject;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
stepSize: NumberObject;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const deconstructNumberDict = (
|
||||
numberDict: NumberObject,
|
||||
): [number, boolean, string | undefined] => {
|
||||
let numberMagnitude = 0;
|
||||
let numberUnit: string | undefined = undefined;
|
||||
const numberReadOnly = numberDict.readonly;
|
||||
|
||||
if (numberDict.type === "int" || numberDict.type === "float") {
|
||||
numberMagnitude = numberDict.value;
|
||||
} else if (numberDict.type === "Quantity") {
|
||||
numberMagnitude = numberDict.value.magnitude;
|
||||
numberUnit = numberDict.value.unit;
|
||||
}
|
||||
|
||||
return [numberMagnitude, numberReadOnly, numberUnit];
|
||||
};
|
||||
|
||||
export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
fullAccessPath,
|
||||
@@ -35,72 +55,83 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath} changed to ${value.value}.`);
|
||||
}, [props.value]);
|
||||
}, [props.value.value]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath}.min changed to ${min.value}.`);
|
||||
}, [props.min]);
|
||||
}, [props.min.value, props.min.type]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath}.max changed to ${max.value}.`);
|
||||
}, [props.max]);
|
||||
}, [props.max.value, props.max.type]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath}.stepSize changed to ${stepSize.value}.`);
|
||||
}, [props.stepSize]);
|
||||
}, [props.stepSize.value, props.stepSize.type]);
|
||||
|
||||
const handleOnChange = (event, newNumber: number | number[]) => {
|
||||
const handleOnChange = (_: Event, newNumber: number | number[]) => {
|
||||
// This will never be the case as we do not have a range slider. However, we should
|
||||
// make sure this is properly handled.
|
||||
if (Array.isArray(newNumber)) {
|
||||
newNumber = newNumber[0];
|
||||
}
|
||||
changeCallback({
|
||||
type: value.type,
|
||||
value: newNumber,
|
||||
full_access_path: `${fullAccessPath}.value`,
|
||||
readonly: value.readonly,
|
||||
doc: docString
|
||||
});
|
||||
|
||||
let serializedObject: SerializedObject;
|
||||
if (value.type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: newNumber,
|
||||
unit: value.value.unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: `${fullAccessPath}.value`,
|
||||
readonly: value.readonly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: value.type,
|
||||
value: newNumber,
|
||||
full_access_path: `${fullAccessPath}.value`,
|
||||
readonly: value.readonly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
changeCallback(serializedObject);
|
||||
};
|
||||
|
||||
const handleValueChange = (
|
||||
newValue: number,
|
||||
name: string,
|
||||
valueObject: NumberObject
|
||||
valueObject: NumberObject,
|
||||
) => {
|
||||
changeCallback({
|
||||
type: valueObject.type,
|
||||
value: newValue,
|
||||
full_access_path: `${fullAccessPath}.${name}`,
|
||||
readonly: valueObject.readonly
|
||||
});
|
||||
};
|
||||
|
||||
const deconstructNumberDict = (
|
||||
numberDict: NumberObject
|
||||
): [number, boolean, string | null] => {
|
||||
let numberMagnitude: number;
|
||||
let numberUnit: string | null = null;
|
||||
const numberReadOnly = numberDict.readonly;
|
||||
|
||||
if (numberDict.type === 'int' || numberDict.type === 'float') {
|
||||
numberMagnitude = numberDict.value;
|
||||
} else if (numberDict.type === 'Quantity') {
|
||||
numberMagnitude = numberDict.value.magnitude;
|
||||
numberUnit = numberDict.value.unit;
|
||||
let serializedObject: SerializedObject;
|
||||
if (valueObject.type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: valueObject.type,
|
||||
value: {
|
||||
magnitude: newValue,
|
||||
unit: valueObject.value.unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: `${fullAccessPath}.${name}`,
|
||||
readonly: valueObject.readonly,
|
||||
doc: null,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: valueObject.type,
|
||||
value: newValue,
|
||||
full_access_path: `${fullAccessPath}.${name}`,
|
||||
readonly: valueObject.readonly,
|
||||
doc: null,
|
||||
};
|
||||
}
|
||||
|
||||
return [numberMagnitude, numberReadOnly, numberUnit];
|
||||
changeCallback(serializedObject);
|
||||
};
|
||||
|
||||
const [valueMagnitude, valueReadOnly, valueUnit] = deconstructNumberDict(value);
|
||||
@@ -110,9 +141,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
|
||||
return (
|
||||
<div className="component sliderComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
|
||||
<Row>
|
||||
<Col xs="auto" xl="auto">
|
||||
@@ -123,7 +152,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
</Col>
|
||||
<Col xs="5" xl>
|
||||
<Slider
|
||||
style={{ margin: '0px 0px 10px 0px' }}
|
||||
style={{ margin: "0px 0px 10px 0px" }}
|
||||
aria-label="Always visible"
|
||||
// valueLabelDisplay="on"
|
||||
disabled={valueReadOnly}
|
||||
@@ -134,7 +163,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
step={stepSizeMagnitude}
|
||||
marks={[
|
||||
{ value: minMagnitude, label: `${minMagnitude}` },
|
||||
{ value: maxMagnitude, label: `${maxMagnitude}` }
|
||||
{ value: maxMagnitude, label: `${maxMagnitude}` },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
@@ -144,12 +173,12 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
fullAccessPath={`${fullAccessPath}.value`}
|
||||
docString={docString}
|
||||
readOnly={valueReadOnly}
|
||||
type="float"
|
||||
type={value.type}
|
||||
value={valueMagnitude}
|
||||
unit={valueUnit}
|
||||
addNotification={() => {}}
|
||||
changeCallback={changeCallback}
|
||||
id={id + '-value'}
|
||||
id={id + "-value"}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="auto">
|
||||
@@ -179,14 +208,14 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
<Form.Group>
|
||||
<Row
|
||||
className="justify-content-center"
|
||||
style={{ paddingTop: '20px', margin: '10px' }}>
|
||||
style={{ paddingTop: "20px", margin: "10px" }}>
|
||||
<Col xs="auto">
|
||||
<Form.Label>Min Value</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={minMagnitude}
|
||||
disabled={minReadOnly}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'min', min)}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), "min", min)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -196,7 +225,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
type="number"
|
||||
value={maxMagnitude}
|
||||
disabled={maxReadOnly}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'max', max)}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), "max", max)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -207,7 +236,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
value={stepSizeMagnitude}
|
||||
disabled={stepSizeReadOnly}
|
||||
onChange={(e) =>
|
||||
handleValueChange(Number(e.target.value), 'step_size', stepSize)
|
||||
handleValueChange(Number(e.target.value), "step_size", stepSize)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
@@ -216,4 +245,6 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, propsAreEqual);
|
||||
|
||||
SliderComponent.displayName = "SliderComponent";
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Form, InputGroup } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import '../App.css';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Form, InputGroup } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import "../App.css";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import { useRenderCount } from "../hooks/useRenderCount";
|
||||
|
||||
// TODO: add button functionality
|
||||
|
||||
type StringComponentProps = {
|
||||
interface StringComponentProps {
|
||||
fullAccessPath: string;
|
||||
value: string;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
const {
|
||||
@@ -28,16 +29,12 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
} = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const [inputString, setInputString] = useState(props.value);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
}, [isInstantUpdate, inputString, renderCount]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only update the inputString if it's different from the prop value
|
||||
if (props.value !== inputString) {
|
||||
@@ -46,21 +43,27 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
addNotification(`${fullAccessPath} changed to ${props.value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const handleChange = (event) => {
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputString(event.target.value);
|
||||
if (isInstantUpdate) {
|
||||
changeCallback(event.target.value);
|
||||
changeCallback({
|
||||
type: "str",
|
||||
value: event.target.value,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter' && !isInstantUpdate) {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && !isInstantUpdate) {
|
||||
changeCallback({
|
||||
type: 'str',
|
||||
type: "str",
|
||||
value: inputString,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
doc: docString,
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -69,20 +72,18 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
const handleBlur = () => {
|
||||
if (!isInstantUpdate) {
|
||||
changeCallback({
|
||||
type: 'str',
|
||||
type: "str",
|
||||
value: inputString,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
doc: docString,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="component stringComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<InputGroup>
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
@@ -96,9 +97,11 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
|
||||
className={isInstantUpdate && !readOnly ? "instantUpdate" : ""}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
StringComponent.displayName = "StringComponent";
|
||||
|
||||
11
frontend/src/hooks/useRenderCount.ts
Normal file
11
frontend/src/hooks/useRenderCount.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
export function useRenderCount() {
|
||||
const count = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
count.current += 1;
|
||||
});
|
||||
|
||||
return count.current;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import App from './App';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from "./App";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
// Importing the Bootstrap CSS
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
|
||||
// Render the App component into the #root div
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { io } from 'socket.io-client';
|
||||
import { SerializedValue } from './components/GenericComponent';
|
||||
import { serializeDict, serializeList } from './utils/serializationUtils';
|
||||
import { io } from "socket.io-client";
|
||||
import { serializeDict, serializeList } from "./utils/serializationUtils";
|
||||
import { SerializedObject } from "./types/SerializedObject";
|
||||
|
||||
export const hostname =
|
||||
process.env.NODE_ENV === 'development' ? `localhost` : window.location.hostname;
|
||||
process.env.NODE_ENV === "development" ? `localhost` : window.location.hostname;
|
||||
export const port =
|
||||
process.env.NODE_ENV === 'development' ? 8001 : window.location.port;
|
||||
process.env.NODE_ENV === "development" ? 8001 : window.location.port;
|
||||
const URL = `ws://${hostname}:${port}/`;
|
||||
console.debug('Websocket: ', URL);
|
||||
console.debug("Websocket: ", URL);
|
||||
|
||||
export const socket = io(URL, { path: '/ws/socket.io', transports: ['websocket'] });
|
||||
export const socket = io(URL, { path: "/ws/socket.io", transports: ["websocket"] });
|
||||
|
||||
export const updateValue = (
|
||||
serializedObject: SerializedValue,
|
||||
callback?: (ack: unknown) => void
|
||||
serializedObject: SerializedObject,
|
||||
callback?: (ack: unknown) => void,
|
||||
) => {
|
||||
if (callback) {
|
||||
socket.emit(
|
||||
'update_value',
|
||||
{ access_path: serializedObject['full_access_path'], value: serializedObject },
|
||||
callback
|
||||
"update_value",
|
||||
{ access_path: serializedObject["full_access_path"], value: serializedObject },
|
||||
callback,
|
||||
);
|
||||
} else {
|
||||
socket.emit('update_value', {
|
||||
access_path: serializedObject['full_access_path'],
|
||||
value: serializedObject
|
||||
socket.emit("update_value", {
|
||||
access_path: serializedObject["full_access_path"],
|
||||
value: serializedObject,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -33,22 +33,22 @@ export const runMethod = (
|
||||
accessPath: string,
|
||||
args: unknown[] = [],
|
||||
kwargs: Record<string, unknown> = {},
|
||||
callback?: (ack: unknown) => void
|
||||
callback?: (ack: unknown) => void,
|
||||
) => {
|
||||
const serializedArgs = serializeList(args);
|
||||
const serializedKwargs = serializeDict(kwargs);
|
||||
|
||||
if (callback) {
|
||||
socket.emit(
|
||||
'trigger_method',
|
||||
"trigger_method",
|
||||
{ access_path: accessPath, args: serializedArgs, kwargs: serializedKwargs },
|
||||
callback
|
||||
callback,
|
||||
);
|
||||
} else {
|
||||
socket.emit('trigger_method', {
|
||||
socket.emit("trigger_method", {
|
||||
access_path: accessPath,
|
||||
args: serializedArgs,
|
||||
kwargs: serializedKwargs
|
||||
kwargs: serializedKwargs,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
4
frontend/src/types/QuantityMap.ts
Normal file
4
frontend/src/types/QuantityMap.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface QuantityMap {
|
||||
magnitude: number;
|
||||
unit: string;
|
||||
}
|
||||
101
frontend/src/types/SerializedObject.ts
Normal file
101
frontend/src/types/SerializedObject.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { QuantityMap } from "./QuantityMap";
|
||||
|
||||
interface SignatureDict {
|
||||
parameters: Record<string, Record<string, unknown>>;
|
||||
return_annotation: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SerializedObjectBase {
|
||||
full_access_path: string;
|
||||
doc: string | null;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
type SerializedInteger = SerializedObjectBase & {
|
||||
value: number;
|
||||
type: "int";
|
||||
};
|
||||
|
||||
type SerializedFloat = SerializedObjectBase & {
|
||||
value: number;
|
||||
type: "float";
|
||||
};
|
||||
|
||||
type SerializedQuantity = SerializedObjectBase & {
|
||||
value: QuantityMap;
|
||||
type: "Quantity";
|
||||
};
|
||||
|
||||
type SerializedBool = SerializedObjectBase & {
|
||||
value: boolean;
|
||||
type: "bool";
|
||||
};
|
||||
|
||||
type SerializedString = SerializedObjectBase & {
|
||||
value: string;
|
||||
type: "str";
|
||||
};
|
||||
|
||||
export type SerializedEnum = SerializedObjectBase & {
|
||||
name: string;
|
||||
value: string;
|
||||
type: "Enum" | "ColouredEnum";
|
||||
enum: Record<string, string>;
|
||||
};
|
||||
|
||||
type SerializedList = SerializedObjectBase & {
|
||||
value: SerializedObject[];
|
||||
type: "list";
|
||||
};
|
||||
|
||||
type SerializedDict = SerializedObjectBase & {
|
||||
value: Record<string, SerializedObject>;
|
||||
type: "dict";
|
||||
};
|
||||
|
||||
type SerializedNoneType = SerializedObjectBase & {
|
||||
value: null;
|
||||
type: "NoneType";
|
||||
};
|
||||
|
||||
type SerializedNoValue = SerializedObjectBase & {
|
||||
value: null;
|
||||
type: "None";
|
||||
};
|
||||
|
||||
type SerializedMethod = SerializedObjectBase & {
|
||||
value: "RUNNING" | null;
|
||||
type: "method";
|
||||
async: boolean;
|
||||
signature: SignatureDict;
|
||||
frontend_render: boolean;
|
||||
};
|
||||
|
||||
type SerializedException = SerializedObjectBase & {
|
||||
name: string;
|
||||
value: string;
|
||||
type: "Exception";
|
||||
};
|
||||
|
||||
type DataServiceTypes = "DataService" | "Image" | "NumberSlider" | "DeviceConnection";
|
||||
|
||||
type SerializedDataService = SerializedObjectBase & {
|
||||
name: string;
|
||||
value: Record<string, SerializedObject>;
|
||||
type: DataServiceTypes;
|
||||
};
|
||||
|
||||
export type SerializedObject =
|
||||
| SerializedBool
|
||||
| SerializedFloat
|
||||
| SerializedInteger
|
||||
| SerializedString
|
||||
| SerializedList
|
||||
| SerializedDict
|
||||
| SerializedNoneType
|
||||
| SerializedMethod
|
||||
| SerializedException
|
||||
| SerializedDataService
|
||||
| SerializedEnum
|
||||
| SerializedQuantity
|
||||
| SerializedNoValue;
|
||||
17
frontend/src/utils/propsAreEqual.ts
Normal file
17
frontend/src/utils/propsAreEqual.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import deepEqual from "deep-equal";
|
||||
|
||||
export const propsAreEqual = <T extends object>(
|
||||
prevProps: T,
|
||||
nextProps: T,
|
||||
): boolean => {
|
||||
for (const key in nextProps) {
|
||||
if (typeof nextProps[key] === "object") {
|
||||
if (!deepEqual(prevProps[key], nextProps[key])) {
|
||||
return false;
|
||||
}
|
||||
} else if (!Object.is(prevProps[key], nextProps[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -1,101 +1,97 @@
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
|
||||
const serializePrimitive = (
|
||||
obj: number | boolean | string | null,
|
||||
accessPath: string
|
||||
) => {
|
||||
let type: string;
|
||||
|
||||
if (typeof obj === 'number') {
|
||||
type = Number.isInteger(obj) ? 'int' : 'float';
|
||||
accessPath: string,
|
||||
): SerializedObject => {
|
||||
if (typeof obj === "number") {
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: obj
|
||||
type: Number.isInteger(obj) ? "int" : "float",
|
||||
value: obj,
|
||||
};
|
||||
} else if (typeof obj === 'boolean') {
|
||||
type = 'bool';
|
||||
} else if (typeof obj === "boolean") {
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: obj
|
||||
type: "bool",
|
||||
value: obj,
|
||||
};
|
||||
} else if (typeof obj === 'string') {
|
||||
type = 'str';
|
||||
} else if (typeof obj === "string") {
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: obj
|
||||
type: "str",
|
||||
value: obj,
|
||||
};
|
||||
} else if (obj === null) {
|
||||
type = 'NoneType';
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: null
|
||||
type: "None",
|
||||
value: null,
|
||||
};
|
||||
} else {
|
||||
throw new Error('Unsupported type for serialization');
|
||||
throw new Error("Unsupported type for serialization");
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeList = (obj: unknown[], accessPath: string = '') => {
|
||||
export const serializeList = (obj: unknown[], accessPath = "") => {
|
||||
const doc = null;
|
||||
const value = obj.map((item, index) => {
|
||||
if (
|
||||
typeof item === 'number' ||
|
||||
typeof item === 'boolean' ||
|
||||
typeof item === 'string' ||
|
||||
typeof item === "number" ||
|
||||
typeof item === "boolean" ||
|
||||
typeof item === "string" ||
|
||||
item === null
|
||||
) {
|
||||
serializePrimitive(
|
||||
item as number | boolean | string | null,
|
||||
`${accessPath}[${index}]`
|
||||
`${accessPath}[${index}]`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
type: 'list',
|
||||
type: "list",
|
||||
value,
|
||||
readonly: false,
|
||||
doc
|
||||
doc,
|
||||
};
|
||||
};
|
||||
export const serializeDict = (
|
||||
obj: Record<string, unknown>,
|
||||
accessPath: string = ''
|
||||
) => {
|
||||
export const serializeDict = (obj: Record<string, unknown>, accessPath = "") => {
|
||||
const doc = null;
|
||||
const value = Object.entries(obj).reduce((acc, [key, val]) => {
|
||||
// Construct the new access path for nested properties
|
||||
const newPath = `${accessPath}["${key}"]`;
|
||||
const value = Object.entries(obj).reduce(
|
||||
(acc, [key, val]) => {
|
||||
// Construct the new access path for nested properties
|
||||
const newPath = `${accessPath}["${key}"]`;
|
||||
|
||||
// Serialize each value in the dictionary and assign to the accumulator
|
||||
if (
|
||||
typeof val === 'number' ||
|
||||
typeof val === 'boolean' ||
|
||||
typeof val === 'string' ||
|
||||
val === null
|
||||
) {
|
||||
acc[key] = serializePrimitive(val as number | boolean | string | null, newPath);
|
||||
}
|
||||
// Serialize each value in the dictionary and assign to the accumulator
|
||||
if (
|
||||
typeof val === "number" ||
|
||||
typeof val === "boolean" ||
|
||||
typeof val === "string" ||
|
||||
val === null
|
||||
) {
|
||||
acc[key] = serializePrimitive(val as number | boolean | string | null, newPath);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, SerializedObject>,
|
||||
);
|
||||
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
type: 'dict',
|
||||
type: "dict",
|
||||
value,
|
||||
readonly: false,
|
||||
doc
|
||||
doc,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { SerializedValue } from '../components/GenericComponent';
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
|
||||
export type State = {
|
||||
export interface State {
|
||||
type: string;
|
||||
value: Record<string, SerializedValue> | null;
|
||||
name: string;
|
||||
value: Record<string, SerializedObject> | null;
|
||||
readonly: boolean;
|
||||
doc: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a full access path into its atomic parts, separating attribute names, numeric
|
||||
@@ -44,7 +45,7 @@ export function parseFullAccessPath(path: string): string[] {
|
||||
*/
|
||||
function parseSerializedKey(serializedKey: string): string | number {
|
||||
// Strip outer brackets if present
|
||||
if (serializedKey.startsWith('[') && serializedKey.endsWith(']')) {
|
||||
if (serializedKey.startsWith("[") && serializedKey.endsWith("]")) {
|
||||
serializedKey = serializedKey.slice(1, -1);
|
||||
}
|
||||
|
||||
@@ -67,12 +68,13 @@ function parseSerializedKey(serializedKey: string): string | number {
|
||||
}
|
||||
|
||||
function getOrCreateItemInContainer(
|
||||
container: Record<string | number, SerializedValue> | SerializedValue[],
|
||||
container: Record<string | number, SerializedObject> | SerializedObject[],
|
||||
key: string | number,
|
||||
allowAddKey: boolean
|
||||
): SerializedValue {
|
||||
allowAddKey: boolean,
|
||||
): SerializedObject {
|
||||
// Check if the key exists and return the item if it does
|
||||
if (key in container) {
|
||||
/* @ts-expect-error Key is in the correct form but converted to type any for some reason */
|
||||
return container[key];
|
||||
}
|
||||
|
||||
@@ -106,10 +108,10 @@ function getOrCreateItemInContainer(
|
||||
* @throws SerializationValueError If the expected structure is incorrect.
|
||||
*/
|
||||
function getContainerItemByKey(
|
||||
container: Record<string, SerializedValue> | SerializedValue[],
|
||||
container: Record<string, SerializedObject> | SerializedObject[],
|
||||
key: string,
|
||||
allowAppend: boolean = false
|
||||
): SerializedValue {
|
||||
allowAppend = false,
|
||||
): SerializedObject {
|
||||
const processedKey = parseSerializedKey(key);
|
||||
|
||||
try {
|
||||
@@ -125,13 +127,13 @@ function getContainerItemByKey(
|
||||
}
|
||||
|
||||
export function setNestedValueByPath(
|
||||
serializationDict: Record<string, SerializedValue>,
|
||||
serializationDict: Record<string, SerializedObject>,
|
||||
path: string,
|
||||
serializedValue: SerializedValue
|
||||
): Record<string, SerializedValue> {
|
||||
serializedValue: SerializedObject,
|
||||
): Record<string, SerializedObject> {
|
||||
const pathParts = parseFullAccessPath(path);
|
||||
const newSerializationDict: Record<string, SerializedValue> = JSON.parse(
|
||||
JSON.stringify(serializationDict)
|
||||
const newSerializationDict: Record<string, SerializedObject> = JSON.parse(
|
||||
JSON.stringify(serializationDict),
|
||||
);
|
||||
|
||||
let currentDict = newSerializationDict;
|
||||
@@ -142,11 +144,11 @@ export function setNestedValueByPath(
|
||||
const nextLevelSerializedObject = getContainerItemByKey(
|
||||
currentDict,
|
||||
pathPart,
|
||||
false
|
||||
false,
|
||||
);
|
||||
currentDict = nextLevelSerializedObject['value'] as Record<
|
||||
currentDict = nextLevelSerializedObject["value"] as Record<
|
||||
string,
|
||||
SerializedValue
|
||||
SerializedObject
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -159,14 +161,15 @@ export function setNestedValueByPath(
|
||||
} catch (error) {
|
||||
console.error(`Error occurred trying to change ${path}: ${error}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function createEmptySerializedObject(): SerializedValue {
|
||||
function createEmptySerializedObject(): SerializedObject {
|
||||
return {
|
||||
full_access_path: '',
|
||||
value: undefined,
|
||||
type: 'None',
|
||||
full_access_path: "",
|
||||
value: null,
|
||||
type: "None",
|
||||
doc: null,
|
||||
readonly: false
|
||||
readonly: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function getIdFromFullAccessPath(fullAccessPath: string) {
|
||||
if (fullAccessPath) {
|
||||
// Replace '].' with a single dash
|
||||
let id = fullAccessPath.replace(/\]\./g, '-');
|
||||
let id = fullAccessPath.replace(/\]\./g, "-");
|
||||
|
||||
// Replace any character that is not a word character or underscore with a dash
|
||||
id = id.replace(/[^\w_]+/g, '-');
|
||||
id = id.replace(/[^\w_]+/g, "-");
|
||||
|
||||
// Remove any trailing dashes
|
||||
id = id.replace(/-+$/, '');
|
||||
id = id.replace(/-+$/, "");
|
||||
|
||||
return id;
|
||||
} else {
|
||||
return 'main';
|
||||
return "main";
|
||||
}
|
||||
}
|
||||
|
||||
31
frontend/tsconfig.app.json
Normal file
31
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
15
frontend/tsconfig.node.json
Normal file
15
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
13
frontend/vite.config.ts
Normal file
13
frontend/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "../src/pydase/frontend",
|
||||
},
|
||||
esbuild: {
|
||||
drop: ["console", "debugger"],
|
||||
},
|
||||
});
|
||||
1160
poetry.lock
generated
1160
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "pydase"
|
||||
version = "0.8.2"
|
||||
version = "0.8.4"
|
||||
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
|
||||
authors = ["Mose Mueller <mosmuell@ethz.ch>"]
|
||||
readme = "README.md"
|
||||
@@ -14,8 +14,7 @@ uvicorn = "^0.27.0"
|
||||
toml = "^0.10.2"
|
||||
python-socketio = "^5.8.0"
|
||||
confz = "^2.0.0"
|
||||
pint = "^0.22"
|
||||
pillow = "^10.0.0"
|
||||
pint = "^0.24"
|
||||
websocket-client = "^1.7.0"
|
||||
aiohttp = "^3.9.3"
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.request import urlopen
|
||||
|
||||
import PIL.Image # type: ignore[import-untyped]
|
||||
|
||||
from pydase.data_service.data_service import DataService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -16,9 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Image(DataService):
|
||||
def __init__(
|
||||
self,
|
||||
) -> None:
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._value: str = ""
|
||||
self._format: str = ""
|
||||
@@ -32,8 +28,14 @@ class Image(DataService):
|
||||
return self._format
|
||||
|
||||
def load_from_path(self, path: Path | str) -> None:
|
||||
with PIL.Image.open(path) as image:
|
||||
self._load_from_pil(image)
|
||||
with open(path, "rb") as image_file:
|
||||
image_data = image_file.read()
|
||||
format_ = self._get_image_format_from_bytes(image_data)
|
||||
if format_ is None:
|
||||
logger.error("Unsupported image format. Skipping...")
|
||||
return
|
||||
value_ = base64.b64encode(image_data)
|
||||
self._load_from_base64(value_, format_)
|
||||
|
||||
def load_from_matplotlib_figure(self, fig: "Figure", format_: str = "png") -> None:
|
||||
buffer = io.BytesIO()
|
||||
@@ -42,12 +44,18 @@ class Image(DataService):
|
||||
self._load_from_base64(value_, format_)
|
||||
|
||||
def load_from_url(self, url: str) -> None:
|
||||
image = PIL.Image.open(urlopen(url))
|
||||
self._load_from_pil(image)
|
||||
with urlopen(url) as response:
|
||||
image_data = response.read()
|
||||
format_ = self._get_image_format_from_bytes(image_data)
|
||||
if format_ is None:
|
||||
logger.error("Unsupported image format. Skipping...")
|
||||
return
|
||||
value_ = base64.b64encode(image_data)
|
||||
self._load_from_base64(value_, format_)
|
||||
|
||||
def load_from_base64(self, value_: bytes, format_: str | None = None) -> None:
|
||||
if format_ is None:
|
||||
format_ = self._get_image_format_from_bytes(value_)
|
||||
format_ = self._get_image_format_from_bytes(base64.b64decode(value_))
|
||||
if format_ is None:
|
||||
logger.warning(
|
||||
"Format of passed byte string could not be determined. Skipping..."
|
||||
@@ -60,19 +68,14 @@ class Image(DataService):
|
||||
self._value = value
|
||||
self._format = format_
|
||||
|
||||
def _load_from_pil(self, image: PIL.Image.Image) -> None:
|
||||
if image.format is not None:
|
||||
format_ = image.format
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format=format_)
|
||||
value_ = base64.b64encode(buffer.getvalue())
|
||||
self._load_from_base64(value_, format_)
|
||||
else:
|
||||
logger.error("Image format is 'None'. Skipping...")
|
||||
|
||||
def _get_image_format_from_bytes(self, value_: bytes) -> str | None:
|
||||
image_data = base64.b64decode(value_)
|
||||
# Create a writable memory buffer for the image
|
||||
image_buffer = io.BytesIO(image_data)
|
||||
# Read the image from the buffer and return format
|
||||
return PIL.Image.open(image_buffer).format
|
||||
format_map = {
|
||||
b"\xff\xd8": "JPEG",
|
||||
b"\x89PNG": "PNG",
|
||||
b"GIF": "GIF",
|
||||
b"RIFF": "WEBP",
|
||||
}
|
||||
for signature, format_name in format_map.items():
|
||||
if value_.startswith(signature):
|
||||
return format_name
|
||||
return None
|
||||
|
||||
@@ -37,8 +37,9 @@ class DataServiceObserver(PropertyObserver):
|
||||
)
|
||||
|
||||
cached_value = cached_value_dict.get("value")
|
||||
if cached_value != dump(value)["value"] and all(
|
||||
part[0] != "_" for part in full_access_path.split(".")
|
||||
if (
|
||||
all(part[0] != "_" for part in full_access_path.split("."))
|
||||
and cached_value != dump(value)["value"]
|
||||
):
|
||||
logger.debug("'%s' changed to '%s'", full_access_path, value)
|
||||
|
||||
|
||||
@@ -21,10 +21,6 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskDefinitionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
RUNNING = "running"
|
||||
|
||||
@@ -107,12 +103,13 @@ class TaskManager:
|
||||
method = getattr(self.service, name)
|
||||
if inspect.iscoroutinefunction(method):
|
||||
if function_has_arguments(method):
|
||||
raise TaskDefinitionError(
|
||||
"Asynchronous functions (tasks) should be defined without "
|
||||
f"arguments. The task '{method.__name__}' has at least one "
|
||||
"argument. Please remove the argument(s) from this function to "
|
||||
"use it."
|
||||
logger.info(
|
||||
"Async function %a is defined with at least one argument. If "
|
||||
"you want to use it as a task, remove the argument(s) from the "
|
||||
"function definition.",
|
||||
method.__name__,
|
||||
)
|
||||
continue
|
||||
|
||||
# create start and stop methods for each coroutine
|
||||
setattr(
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.7ef670d5.css",
|
||||
"main.js": "/static/js/main.57f8ec4c.js",
|
||||
"index.html": "/index.html",
|
||||
"main.7ef670d5.css.map": "/static/css/main.7ef670d5.css.map",
|
||||
"main.57f8ec4c.js.map": "/static/js/main.57f8ec4c.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.7ef670d5.css",
|
||||
"static/js/main.57f8ec4c.js"
|
||||
]
|
||||
}
|
||||
62
src/pydase/frontend/assets/index-C12UM6g5.js
Normal file
62
src/pydase/frontend/assets/index-C12UM6g5.js
Normal file
File diff suppressed because one or more lines are too long
5
src/pydase/frontend/assets/index-D2aktF3W.css
Normal file
5
src/pydase/frontend/assets/index-D2aktF3W.css
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +1,18 @@
|
||||
<!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.57f8ec4c.js"></script><link href="/static/css/main.7ef670d5.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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site displaying a pydase UI." />
|
||||
<script type="module" crossorigin src="/assets/index-C12UM6g5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D2aktF3W.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
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
@@ -1,45 +0,0 @@
|
||||
/*!
|
||||
Copyright (c) 2018 Jed Watson.
|
||||
Licensed under the MIT License (MIT), see
|
||||
http://jedwatson.github.io/classnames
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
101
src/pydase/observer_pattern/observable/decorators.py
Normal file
101
src/pydase/observer_pattern/observable/decorators.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydase.observer_pattern.observable.observable import Observable
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
def validate_set(
|
||||
*, timeout: float = 0.1, precision: float | None = None
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""
|
||||
Decorator marking a property setter to read back the set value using the property
|
||||
getter and check against the desired value.
|
||||
|
||||
Args:
|
||||
timeout (float):
|
||||
The maximum time (in seconds) to wait for the value to be within the
|
||||
precision boundary.
|
||||
precision (float | None):
|
||||
The acceptable deviation from the desired value. If None, the value must be
|
||||
exact.
|
||||
"""
|
||||
|
||||
def validate_set_decorator(func: Callable[P, R]) -> Callable[P, R]:
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._validate_kwargs = { # type: ignore
|
||||
"timeout": timeout,
|
||||
"precision": precision,
|
||||
}
|
||||
|
||||
return wrapper
|
||||
|
||||
return validate_set_decorator
|
||||
|
||||
|
||||
def has_validate_set_decorator(prop: property) -> bool:
|
||||
"""
|
||||
Checks if a property setter has been decorated with the `validate_set` decorator.
|
||||
|
||||
Args:
|
||||
prop (property):
|
||||
The property to check.
|
||||
|
||||
Returns:
|
||||
bool:
|
||||
True if the property setter has the `validate_set` decorator, False
|
||||
otherwise.
|
||||
"""
|
||||
|
||||
property_setter = prop.fset
|
||||
return hasattr(property_setter, "_validate_kwargs")
|
||||
|
||||
|
||||
def _validate_value_was_correctly_set(
|
||||
*,
|
||||
obj: "Observable",
|
||||
name: str,
|
||||
value: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Validates if the property `name` of `obj` attains the desired `value` within the
|
||||
specified `precision` and time `timeout`.
|
||||
|
||||
Args:
|
||||
obj (Observable):
|
||||
The instance of the class containing the property.
|
||||
name (str):
|
||||
The name of the property to validate.
|
||||
value (Any):
|
||||
The desired value to check against.
|
||||
|
||||
Raises:
|
||||
ValueError:
|
||||
If the property value does not match the desired value within the specified
|
||||
precision and timeout.
|
||||
"""
|
||||
|
||||
prop: property = getattr(type(obj), name)
|
||||
|
||||
timeout = prop.fset._validate_kwargs["timeout"] # type: ignore
|
||||
precision = prop.fset._validate_kwargs["precision"] # type: ignore
|
||||
if precision is None:
|
||||
precision = 0.0
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
current_value = obj.__getattribute__(name)
|
||||
# This check is faster than rounding and comparing to 0
|
||||
if abs(current_value - value) <= precision:
|
||||
return
|
||||
time.sleep(0.01)
|
||||
raise ValueError(
|
||||
f"Failed to set value to {value} within {timeout} seconds. Current value: "
|
||||
f"{current_value}."
|
||||
)
|
||||
@@ -1,6 +1,10 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydase.observer_pattern.observable.decorators import (
|
||||
_validate_value_was_correctly_set,
|
||||
has_validate_set_decorator,
|
||||
)
|
||||
from pydase.observer_pattern.observable.observable_object import ObservableObject
|
||||
from pydase.utils.helpers import is_property_attribute
|
||||
|
||||
@@ -35,7 +39,12 @@ class Observable(ObservableObject):
|
||||
|
||||
super().__setattr__(name, value)
|
||||
|
||||
self._notify_changed(name, value)
|
||||
if is_property_attribute(self, name) and has_validate_set_decorator(
|
||||
getattr(type(self), name)
|
||||
):
|
||||
_validate_value_was_correctly_set(obj=self, name=name, value=value)
|
||||
else:
|
||||
self._notify_changed(name, value)
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
if is_property_attribute(self, name):
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import weakref
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, SupportsIndex
|
||||
|
||||
from pydase.utils.helpers import parse_serialized_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from pydase.observer_pattern.observer.observer import Observer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ObservableObject(ABC):
|
||||
_list_mapping: ClassVar[dict[int, "_ObservableList"]] = {}
|
||||
_dict_mapping: ClassVar[dict[int, "_ObservableDict"]] = {}
|
||||
_list_mapping: ClassVar[dict[int, weakref.ReferenceType[_ObservableList]]] = {}
|
||||
_dict_mapping: ClassVar[dict[int, weakref.ReferenceType[_ObservableDict]]] = {}
|
||||
|
||||
def __init__(self) -> None:
|
||||
if not hasattr(self, "_observers"):
|
||||
self._observers: dict[str, list["ObservableObject | Observer"]] = {}
|
||||
self._observers: dict[str, list[ObservableObject | Observer]] = {}
|
||||
|
||||
def add_observer(
|
||||
self, observer: "ObservableObject | Observer", attr_name: str = ""
|
||||
self, observer: ObservableObject | Observer, attr_name: str = ""
|
||||
) -> None:
|
||||
if attr_name not in self._observers:
|
||||
self._observers[attr_name] = []
|
||||
if observer not in self._observers[attr_name]:
|
||||
self._observers[attr_name].append(observer)
|
||||
|
||||
def _remove_observer(self, observer: "ObservableObject", attribute: str) -> None:
|
||||
def _remove_observer(self, observer: ObservableObject, attribute: str) -> None:
|
||||
if attribute in self._observers:
|
||||
self._observers[attribute].remove(observer)
|
||||
|
||||
# remove attribute key from observers dict if list of observers is empty
|
||||
if not self._observers[attribute]:
|
||||
del self._observers[attribute]
|
||||
|
||||
@abstractmethod
|
||||
def _remove_observer_if_observable(self, name: str) -> None:
|
||||
"""Removes the current object as an observer from an observable attribute.
|
||||
@@ -88,19 +96,23 @@ class ObservableObject(ABC):
|
||||
if isinstance(value, list):
|
||||
if id(value) in self._list_mapping:
|
||||
# If the list `value` was already referenced somewhere else
|
||||
new_value = self._list_mapping[id(value)]
|
||||
new_value = self._list_mapping[id(value)]()
|
||||
else:
|
||||
# convert the builtin list into a ObservableList
|
||||
new_value = _ObservableList(original_list=value)
|
||||
self._list_mapping[id(value)] = new_value
|
||||
|
||||
# Use weakref to allow the GC to collect unused objects
|
||||
self._list_mapping[id(value)] = weakref.ref(new_value)
|
||||
elif isinstance(value, dict):
|
||||
if id(value) in self._dict_mapping:
|
||||
# If the dict `value` was already referenced somewhere else
|
||||
new_value = self._dict_mapping[id(value)]
|
||||
new_value = self._dict_mapping[id(value)]()
|
||||
else:
|
||||
# convert the builtin list into a ObservableList
|
||||
# convert the builtin dict into a ObservableDict
|
||||
new_value = _ObservableDict(original_dict=value)
|
||||
self._dict_mapping[id(value)] = new_value
|
||||
|
||||
# Use weakref to allow the GC to collect unused objects
|
||||
self._dict_mapping[id(value)] = weakref.ref(new_value)
|
||||
if isinstance(new_value, ObservableObject):
|
||||
new_value.add_observer(self, attr_name_or_key)
|
||||
return new_value
|
||||
@@ -139,6 +151,9 @@ class _ObservableList(ObservableObject, list[Any]):
|
||||
for i, item in enumerate(self._original_list):
|
||||
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
|
||||
|
||||
def __del__(self) -> None:
|
||||
self._list_mapping.pop(id(self._original_list))
|
||||
|
||||
def __setitem__(self, key: int, value: Any) -> None: # type: ignore[override]
|
||||
if hasattr(self, "_observers"):
|
||||
self._remove_observer_if_observable(f"[{key}]")
|
||||
@@ -151,8 +166,7 @@ class _ObservableList(ObservableObject, list[Any]):
|
||||
|
||||
def append(self, __object: Any) -> None:
|
||||
self._notify_change_start("")
|
||||
self._initialise_new_objects(f"[{len(self)}]", __object)
|
||||
super().append(__object)
|
||||
super().append(self._initialise_new_objects(f"[{len(self)}]", __object))
|
||||
self._notify_changed("", self)
|
||||
|
||||
def clear(self) -> None:
|
||||
@@ -237,6 +251,9 @@ class _ObservableDict(ObservableObject, dict[str, Any]):
|
||||
for key, value in self._original_dict.items():
|
||||
self.__setitem__(key, self._initialise_new_objects(f'["{key}"]', value))
|
||||
|
||||
def __del__(self) -> None:
|
||||
self._dict_mapping.pop(id(self._original_dict))
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
if not isinstance(key, str):
|
||||
raise ValueError(
|
||||
|
||||
@@ -184,6 +184,4 @@ def function_has_arguments(func: Callable[..., Any]) -> bool:
|
||||
parameters.pop("self", None)
|
||||
|
||||
# Check if there are any parameters left which would indicate additional arguments.
|
||||
if len(parameters) > 0:
|
||||
return True
|
||||
return False
|
||||
return len(parameters) > 0
|
||||
|
||||
@@ -7,7 +7,6 @@ import pytest
|
||||
from pydase import DataService
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pydase.data_service.task_manager import TaskDefinitionError
|
||||
from pydase.utils.decorators import FunctionDefinitionError, frontend
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
@@ -37,7 +36,8 @@ def test_unexpected_type_change_warning(caplog: LogCaptureFixture) -> None:
|
||||
|
||||
|
||||
def test_basic_inheritance_warning(caplog: LogCaptureFixture) -> None:
|
||||
class SubService(DataService): ...
|
||||
class SubService(DataService):
|
||||
...
|
||||
|
||||
class SomeEnum(Enum):
|
||||
HI = 0
|
||||
@@ -57,9 +57,11 @@ def test_basic_inheritance_warning(caplog: LogCaptureFixture) -> None:
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def some_method(self) -> None: ...
|
||||
def some_method(self) -> None:
|
||||
...
|
||||
|
||||
async def some_task(self) -> None: ...
|
||||
async def some_task(self) -> None:
|
||||
...
|
||||
|
||||
ServiceClass()
|
||||
|
||||
@@ -118,14 +120,7 @@ def test_protected_and_private_attribute_warning(caplog: LogCaptureFixture) -> N
|
||||
) not in caplog.text
|
||||
|
||||
|
||||
def test_exposing_methods() -> None:
|
||||
class ClassWithTask(pydase.DataService):
|
||||
async def some_task(self, sleep_time: int) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(TaskDefinitionError):
|
||||
ClassWithTask()
|
||||
|
||||
def test_exposing_methods(caplog: LogCaptureFixture) -> None:
|
||||
with pytest.raises(FunctionDefinitionError):
|
||||
|
||||
class ClassWithMethod(pydase.DataService):
|
||||
@@ -133,6 +128,18 @@ def test_exposing_methods() -> None:
|
||||
def some_method(self, *args: Any) -> str:
|
||||
return "some method"
|
||||
|
||||
class ClassWithTask(pydase.DataService):
|
||||
async def some_task(self, sleep_time: int) -> None:
|
||||
pass
|
||||
|
||||
ClassWithTask()
|
||||
|
||||
assert (
|
||||
"Async function 'some_task' is defined with at least one argument. If you want "
|
||||
"to use it as a task, remove the argument(s) from the function definition."
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_dynamically_added_attribute(caplog: LogCaptureFixture) -> None:
|
||||
class MyService(DataService):
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pydase
|
||||
import pytest
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pydase.utils.serialization.serializer import SerializationError
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
@@ -122,3 +124,25 @@ def test_dynamic_list_entry_with_property(caplog: pytest.LogCaptureFixture) -> N
|
||||
|
||||
assert "'list_attr[0].name' changed to 'Hello'" not in caplog.text
|
||||
assert "'list_attr[0].name' changed to 'Hoooo'" in caplog.text
|
||||
|
||||
|
||||
def test_private_attribute_does_not_have_to_be_serializable() -> None:
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.publ_attr: Any = 1
|
||||
self.__priv_attr = (1,)
|
||||
|
||||
def change_publ_attr(self) -> None:
|
||||
self.publ_attr = (2,) # cannot be serialized
|
||||
|
||||
def change_priv_attr(self) -> None:
|
||||
self.__priv_attr = (2,)
|
||||
|
||||
service_instance = MyService()
|
||||
pydase.Server(service_instance)
|
||||
|
||||
with pytest.raises(SerializationError):
|
||||
service_instance.change_publ_attr()
|
||||
|
||||
service_instance.change_priv_attr()
|
||||
|
||||
128
tests/observer_pattern/observable/test_decorators.py
Normal file
128
tests/observer_pattern/observable/test_decorators.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pydase
|
||||
import pytest
|
||||
from pydase.observer_pattern.observable.decorators import validate_set
|
||||
|
||||
|
||||
def linspace(start: float, stop: float, n: int):
|
||||
if n == 1:
|
||||
yield stop
|
||||
return
|
||||
h = (stop - start) / (n - 1)
|
||||
for i in range(n):
|
||||
yield start + h * i
|
||||
|
||||
|
||||
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_forever()
|
||||
|
||||
|
||||
def test_validate_set_precision(caplog: pytest.LogCaptureFixture) -> None:
|
||||
class Service(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._value_1 = 0.0
|
||||
self._value_2 = 0.0
|
||||
|
||||
@property
|
||||
def value_1(self) -> float:
|
||||
return self._value_1
|
||||
|
||||
@value_1.setter
|
||||
@validate_set(precision=None)
|
||||
def value_1(self, value: float) -> None:
|
||||
self._value_1 = round(value, 1)
|
||||
|
||||
@property
|
||||
def value_2(self) -> float:
|
||||
return self._value_2
|
||||
|
||||
@value_2.setter
|
||||
@validate_set(precision=1e-1)
|
||||
def value_2(self, value: float) -> None:
|
||||
self._value_2 = round(value, 1)
|
||||
|
||||
service_instance = Service()
|
||||
pydase.Server(service_instance) # needed to initialise observer
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
service_instance.value_1 = 1.12
|
||||
assert "Failed to set value to 1.12 within 1 second. Current value: 1.1" in str(
|
||||
exc_info
|
||||
)
|
||||
|
||||
caplog.clear()
|
||||
|
||||
service_instance.value_2 = 1.12 # no assertion raised
|
||||
assert service_instance.value_2 == 1.1 # noqa
|
||||
|
||||
assert "'value_2' changed to '1.1'" in caplog.text
|
||||
|
||||
|
||||
def test_validate_set_timeout(caplog: pytest.LogCaptureFixture) -> None:
|
||||
class RemoteDevice:
|
||||
def __init__(self) -> None:
|
||||
self._value = 0.0
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self._lock = asyncio.Lock()
|
||||
self.thread = threading.Thread(
|
||||
target=asyncio_loop_thread, args=(self.loop,), daemon=True
|
||||
)
|
||||
self.thread.start()
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||
self.thread.join()
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
future = asyncio.run_coroutine_threadsafe(self._get_value(), self.loop)
|
||||
return future.result()
|
||||
|
||||
async def _get_value(self) -> float:
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, value: float) -> None:
|
||||
self.loop.create_task(self.set_value(value))
|
||||
|
||||
async def set_value(self, value) -> None:
|
||||
for i in linspace(self._value, value, 10):
|
||||
self._value = i
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
class Service(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._driver = RemoteDevice()
|
||||
|
||||
@property
|
||||
def value_1(self) -> float:
|
||||
return self._driver.value
|
||||
|
||||
@value_1.setter
|
||||
@validate_set(timeout=0.5)
|
||||
def value_1(self, value: float) -> None:
|
||||
self._driver.value = value
|
||||
|
||||
@property
|
||||
def value_2(self) -> float:
|
||||
return self._driver.value
|
||||
|
||||
@value_2.setter
|
||||
@validate_set(timeout=1)
|
||||
def value_2(self, value: float) -> None:
|
||||
self._driver.value = value
|
||||
|
||||
service_instance = Service()
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
service_instance.value_1 = 2.0
|
||||
assert "Failed to set value to 2.0 within 0.5 seconds. Current value:" in str(
|
||||
exc_info
|
||||
)
|
||||
|
||||
service_instance.value_2 = 3.0 # no assertion raised
|
||||
@@ -138,7 +138,6 @@ def test_removed_observer_on_class_dict_attr(caplog: pytest.LogCaptureFixture) -
|
||||
caplog.clear()
|
||||
|
||||
assert nested_instance._observers == {
|
||||
'["nested"]': [],
|
||||
"nested_attr": [instance],
|
||||
}
|
||||
|
||||
@@ -172,7 +171,6 @@ def test_removed_observer_on_instance_dict_attr(
|
||||
caplog.clear()
|
||||
|
||||
assert nested_instance._observers == {
|
||||
'["nested"]': [],
|
||||
"nested_attr": [instance],
|
||||
}
|
||||
|
||||
@@ -211,6 +209,6 @@ def test_pop(caplog: pytest.LogCaptureFixture) -> None:
|
||||
instance = MyObservable()
|
||||
MyObserver(instance)
|
||||
assert instance.dict_attr.pop("nested") == nested_instance
|
||||
assert nested_instance._observers == {'["nested"]': []}
|
||||
assert nested_instance._observers == {}
|
||||
|
||||
assert f"'dict_attr' changed to '{instance.dict_attr}'" in caplog.text
|
||||
|
||||
@@ -81,11 +81,21 @@ def test_removed_observer_on_class_list_attr(caplog: pytest.LogCaptureFixture) -
|
||||
|
||||
instance = MyObservable()
|
||||
MyObserver(instance)
|
||||
|
||||
assert nested_instance._observers == {
|
||||
"[0]": [instance.changed_list_attr],
|
||||
"nested_attr": [instance],
|
||||
}
|
||||
|
||||
instance.changed_list_attr[0] = "Ciao"
|
||||
|
||||
assert "'changed_list_attr[0]' changed to 'Ciao'" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
assert nested_instance._observers == {
|
||||
"nested_attr": [instance],
|
||||
}
|
||||
|
||||
instance.nested_attr.name = "Hi"
|
||||
|
||||
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
|
||||
@@ -115,6 +125,10 @@ def test_removed_observer_on_instance_list_attr(
|
||||
assert "'changed_list_attr[0]' changed to 'Ciao'" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
assert nested_instance._observers == {
|
||||
"nested_attr": [instance],
|
||||
}
|
||||
|
||||
instance.nested_attr.name = "Hi"
|
||||
|
||||
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
|
||||
@@ -311,3 +325,51 @@ def test_list_remove(caplog: pytest.LogCaptureFixture) -> None:
|
||||
# checks if observer key was updated correctly (was index 1)
|
||||
other_observable_instance_2.greeting = "Ciao"
|
||||
assert "'my_list[0].greeting' changed to 'Ciao'" in caplog.text
|
||||
|
||||
|
||||
def test_list_garbage_collection() -> None:
|
||||
"""Makes sure that the GC collects lists that are not referenced anymore."""
|
||||
|
||||
import gc
|
||||
import json
|
||||
|
||||
list_json = """
|
||||
[1]
|
||||
"""
|
||||
|
||||
class MyObservable(Observable):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.list_attr = json.loads(list_json)
|
||||
|
||||
observable = MyObservable()
|
||||
list_mapping_length = len(observable._list_mapping)
|
||||
observable.list_attr = json.loads(list_json)
|
||||
|
||||
gc.collect()
|
||||
assert len(observable._list_mapping) <= list_mapping_length
|
||||
|
||||
|
||||
def test_dict_garbage_collection() -> None:
|
||||
"""Makes sure that the GC collects dicts that are not referenced anymore."""
|
||||
|
||||
import gc
|
||||
import json
|
||||
|
||||
dict_json = """
|
||||
{
|
||||
"foo": "bar"
|
||||
}
|
||||
"""
|
||||
|
||||
class MyObservable(Observable):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.dict_attr = json.loads(dict_json)
|
||||
|
||||
observable = MyObservable()
|
||||
dict_mapping_length = len(observable._dict_mapping)
|
||||
observable.dict_attr = json.loads(dict_json)
|
||||
|
||||
gc.collect()
|
||||
assert len(observable._dict_mapping) <= dict_mapping_length
|
||||
|
||||
@@ -16,6 +16,7 @@ def test_inherited_property_dependency_resolution() -> None:
|
||||
_name = "DerivedObservable"
|
||||
|
||||
class MyObserver(PropertyObserver):
|
||||
def on_change(self, full_access_path: str, value: Any) -> None: ...
|
||||
def on_change(self, full_access_path: str, value: Any) -> None:
|
||||
...
|
||||
|
||||
assert MyObserver(DerivedObservable()).property_deps_dict == {"_name": ["name"]}
|
||||
|
||||
@@ -476,7 +476,8 @@ def test_derived_data_service_serialization() -> None:
|
||||
def name(self, value: str) -> None:
|
||||
self._name = value
|
||||
|
||||
class DerivedService(BaseService): ...
|
||||
class DerivedService(BaseService):
|
||||
...
|
||||
|
||||
base_service_serialization = dump(BaseService())
|
||||
derived_service_serialization = dump(DerivedService())
|
||||
|
||||
Reference in New Issue
Block a user