Help ReactJS classes for adapter config
You can find demo on https://github.com/ioBroker/adapter-react-demo
Getting started
If you want to create the configuration page with ReactJS:
- Create github repo for adapter.
- execute
npx create-react-app src
. It will take a while.
cd src
- Modify package.json file in src directory:
- Change
name
from src
to ADAPTERNAME-admin
(Of course replace ADAPTERNAME
with yours)
- Add to devDependencies:
"@iobroker/adapter-react": "^4.0.10",
Versions can be higher.
So your src/package.json should look like:
{
"name": "ADAPTERNAME-admin",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.6.0",
"react-scripts": "^5.0.1",
"@iobroker/adapter-react-v5": "^3.2.7",
"del": "^6.1.1",
"gulp": "^4.0.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"homepage": ".",
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}
- Call in
src
: npm install
- Copy gulpfile.js into
src
: cp node_modules/@iobroker/adapter-react/gulpfile.js gulpfile.js
- Start your dummy application
npm run start
for developing or build with npm run build
and
copy files in build
directory to www
or to admin
. In the admin you must rename index.html
to index_m.html
.
- You can do that with
gulp
tasks: gulp build
, gulp copy
, gulp renameIndex
or gulp renameTab
Development
- Add
socket.io
to public/index.html
.
After
<link rel="manifest" href="https://github.com/ioBroker/adapter-react-v5/blob/main/%PUBLIC_URL%/manifest.json" />
insert
<script>
var script = document.createElement('script');
window.registerSocketOnLoad = function (cb) {
window.socketLoadedHandler = cb;
};
const parts = (window.location.search || '').replace(/^\?/, '').split('&');
const query = {};
parts.forEach(item => {
const [name, val] = item.split('=');
query[decodeURIComponent(name)] = val !== undefined ? decodeURIComponent(val) : true;
});
script.onload = function () { typeof window.socketLoadedHandler === 'function' && window.socketLoadedHandler(); };
script.src = window.location.port === '3000' ? window.location.protocol + '//' + (query.host || window.location.hostname) + ':' + (query.port || 8081) + '/lib/js/socket.io.js' : '%PUBLIC_URL%/../../lib/js/socket.io.js';
document.head.appendChild(script);
</script>
-
Add to App.js constructor initialization for I18n:
class App extends GenericApp {
constructor(props) {
const extendedProps = {...props};
extendedProps.encryptedFields = ['pass']; // this parameter will be encrypted and decrypted automatically
extendedProps.translations = {
'en': require('./i18n/en'),
'de': require('./i18n/de'),
'ru': require('./i18n/ru'),
'pt': require('./i18n/pt'),
'nl': require('./i18n/nl'),
'fr': require('./i18n/fr'),
'it': require('./i18n/it'),
'es': require('./i18n/es'),
'pl': require('./i18n/pl'),
'uk': require('./i18n/uk'),
'zh-cn': require('./i18n/zh-cn'),
};
// get actual admin port
extendedProps.socket = {port: parseInt(window.location.port, 10)};
// Only if close, save buttons are not required at the bottom (e.g. if admin tab)
// extendedProps.bottomButtons = false;
// only for debug purposes
if (extendedProps.socket.port === 3000) {
extendedProps.socket.port = 8081;
}
// allow to manage GenericApp the sentry initialisation or do not set the sentryDSN if no sentry available
extendedProps.sentryDSN = 'https://yyy@sentry.iobroker.net/xx';
super(extendedProps);
}
...
}
-
Replace index.js
with the following code to support themes:
import React from 'react';
import ReactDOM from 'react-dom';
import { MuiThemeProvider} from '@material-ui/core/styles';
import * as serviceWorker from './serviceWorker';
import './index.css';
import App from './App';
import { version } from '../package.json';
import theme from '@iobroker/adapter-react/Theme';
console.log('iobroker.scenes@' + version);
let themeName = window.localStorage ? window.localStorage.getItem('App.theme') || 'light' : 'light';
function build() {
return ReactDOM.render(<MuiThemeProvider theme={ theme(themeName) }>
<App onThemeChange={_theme => {
themeName = _theme;
build();
}}/>
, document.getElementById('root'));
}
build();
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();
5. Add to App.js encoding and decoding of values:
class App extend GenericApp {
...
onPrepareLoad(settings) {
settings.pass = this.decode(settings.pass);
}
onPrepareSave(settings) {
settings.pass = this.encode(settings.pass);
}
}
6. The optional step is to validate the data to be saved:
onPrepareSave(settings) {
super.onPrepareSave(settings);
if (DATA_INVALID) {
return false; // configuration will not be saved
} else {
return true;
}
}
## Components
### Connection.tsx
This is a non-react class to provide the communication for socket connection with the server.
### GenericApp.tsx
### i18n.ts
### Theme.tsx
### Dialogs
Some dialogs are predefined and could be used out of the box.
#### Confirm.tsx
<!-- TODO: Provide screenshot here -->
Usage:
import React from 'react';
import ConfirmDialog from '@iobroker/adapter-react/Dialogs/Confirm'
import I18n from '@iobroker/adapter-react/i18n';
class ExportImportDialog extends React.Component {
constructor(props) {
super(props);
this.state = {
confirmDialog: false,
};
}
renderConfirmDialog() {
if (!this.state.confirmDialog) {
return null;
}
return <ConfirmDialog
title={ I18n.t('Scene will be overwritten.') }
text={ I18n.t('All data will be lost. Confirm?') }
ok={ I18n.t('Yes') }
cancel={ I18n.t('Cancel') }
suppressQuestionMinutes={5}
dialogName="myConfirmDialogThatCouldBeSuppressed"
suppressText={I18n.t('Suppress question for next %s minutes', 5)}
onClose={isYes => {
this.setState({ confirmDialog: false} );
}}
/>;
}
render() {
return <div>
<Button onClick={ () => this.setState({confirmDialog: true}) }>Click</Button>
{ this.renderConfirmDialog() }
</div>
}
}
export default ExportImportDialog;
#### Error.tsx
<!-- TODO: Provide screenshot here -->
#### Message.tsx
<!-- TODO: Provide screenshot here -->
renderMessage() {
if (this.state.showMessage) {
return <Message
text={this.state.showMessage}
onClose={() => this.setState({showMessage: false})}
/>;
} else {
return null;
}
}
#### SelectID.tsx
![Logo](img/selectID.png)
import DialogSelectID from '@iobroker/adapter-react/Dialogs/SelectID';
class MyComponent extends Component {
constructor(props) {
super(props);
this.state = {
showSelectId: false,
};
}
renderSelectIdDialog() {
if (this.state.showSelectId) {
return <DialogSelectID
key="tableSelect"
imagePrefix="../.."
dialogName={this.props.adapterName}
themeType={this.props.themeType}
socket={this.props.socket}
statesOnly={true}
selected={this.state.selectIdValue}
onClose={() => this.setState({showSelectId: false})}
onOk={(selected, name) => {
this.setState({showSelectId: false, selectIdValue: selected});
}}
/>;
} else {
return null;
}
}
render() {
return renderSelectIdDialog();
}
}
#### Cron
Include `"react-text-mask": "^5.4.3",` in package.json.
<!-- TODO: Provide screenshot here -->
function renderCron() {
if (!showCron) {
return null;
} else {
return <DialogCron
key="dialogCron1"
cron={this.state.cronValue || ' *'}
onClose={() => this.setState({ showCron: false })}
onOk={cronValue => {
this.setState({ cronValue })
}}
/>;
}
}
### Components
#### Utils.tsx
##### getObjectNameFromObj
`getObjectNameFromObj(obj, settings, options, isDesc)`
Get object name from a single object.
Usage: `Utils.getObjectNameFromObj(this.objects[id], null, {language: I18n.getLanguage()})`
##### getObjectIcon
`getObjectIcon(id, obj)`
Get icon from the object.
Usage:
const icon = Utils.getObjectIcon(id, this.objects[id]);
return (
);
##### isUseBright
`isUseBright(color, defaultValue)`
Usage: `
#### Loader.tsx
![Logo](img/loader.png)
render() {
if (!this.state.loaded) {
return
;
}
// render loaded data
}
```
#### Logo.tsx
![Logo](img/logo.png)
```
render() {
return ;
}
```
#### Router.tsx
#### ObjectBrowser.js
It is better to use `Dialog/SelectID`, but if you want:
![Logo](img/objectBrowser.png)
```
{
this.filters = filterConfig;
window.localStorage.setItem(this.dialogName, JSON.stringify(filterConfig));
}}
onSelect={(selected, name, isDouble) => {
if (JSON.stringify(selected) !== JSON.stringify(this.state.selected)) {
this.setState({selected, name}, () =>
isDouble && this.handleOk());
} else if (isDouble) {
this.handleOk();
}
}}
/>
```
#### TreeTable.ts
![Logo](img/tableTree.png)
```
// STYLES
const styles = theme => ({
tableDiv: {
width: '100%',
overflow: 'hidden',
height: 'calc(100% - 48px)',
},
});
class MyComponent extends Component {
constructor(props) {
super(props);
this.state = {
data: [
{
id: 'UniqueID1' // required
fieldIdInData: 'Name1',
myType: 'number',
},
{
id: 'UniqueID2' // required
fieldIdInData: 'Name12',
myType: 'string',
},
],
};
this.columns = [
{
title: 'Name of field', // required, else it will be "field"
field: 'fieldIdInData', // required
editable: false, // or true [default - true]
cellStyle: { // CSS style - // optional
maxWidth: '12rem',
overflow: 'hidden',
wordBreak: 'break-word',
},
lookup: { // optional => edit will be automatically "SELECT"
'value1': 'text1',
'value2': 'text2',
}
},
{
title: 'Type', // required, else it will be "field"
field: 'myType', // required
editable: true, // or true [default - true]
lookup: { // optional => edit will be automatically "SELECT"
'number': 'Number',
'string': 'String',
'boolean': 'Boolean',
},
type: 'number/string/color/oid/icon/boolean', // oid=ObjectID,icon=base64-icon
editComponent: props =>
Prefix{
,
},
];
}
// renderTable
render() {
return
{
const data = JSON.parse(JSON.stringify(this.state.data));
// Added new line
if (newData === true) {
// find unique ID
let i = 1;
let id = 'line_' + i;
// eslint-disable-next-line
while(this.state.data.find(item => item.id === id)) {
i++;
id = 'line_' + i;
}
data.push({
id,
name: I18n.t('New resource') + '_' + i,
color: '',
icon: '',
unit: '',
price: 0,
});
} else {
// existing line was modifed
const pos = this.state.data.indexOf(oldData);
if (pos !== -1) {
Object.keys(newData).forEach(attr => data[pos][attr] = newData[attr]);
}
}
this.setState({data});
}}
onDelete={oldData => {
console.log('Delete: ' + JSON.stringify(oldData));
const pos = this.state.data.indexOf(oldData);
if (pos !== -1) {
const data = JSON.parse(JSON.stringify(this.state.data));
data.splice(pos, 1);
this.setState({data});
}
}}
/>
;
}
}
```
#### Toast
Toast is not a part of `adapter-react` but it is an example how to use toast in application:
```
import Snackbar from '@material-ui/core/Snackbar';
class MyComponent {
constructor(props) {
super(props);
this.state = {
// ....
toast: '',
};
}
// ...
renderToast() {
if (!this.state.toast) {
return null;
}
return this.setState({toast: ''})}
ContentProps={{'aria-describedby': 'message-id'}}
message={{this.state.toast}}
action={[
this.setState({toast: ''})}
>
,
]}
/>;
}
render() {
return
{this.renderToast()}
;
}
}
```
## List of adapters that use adapter-react
- Admin
- Backitup
- iot
- echarts
- text2command
- scenes
- javascript
- devices
- eventlist
- cameras
- web
- vis-2
- vis-2-widgets-xxx
- fullcalendar
- openweathermap
## Usability
In dialogs, the OK button is first (on the left) and the cancel button is last (on the right)
## Used icons
This project uses icons from [Flaticon](https://www.flaticon.com/).
ioBroker GmbH has a valid license for all the used icons.
The icons may not be reused in other projects without the proper flaticon license or flaticon subscription.
## Migration from adapter-react to adapter-react-v5
### In src/package.json => dependencies
- `"@iobroker/adapter-react": "^2.0.22",` => `"@iobroker/adapter-react-v5": "^3.1.34",`
- `"@material-ui/core": "^4.12.3",` => `"@mui/material": "^5.10.9",`
- `"@material-ui/icons": "^4.11.2",` => `"@mui/icons-material": "^5.10.9",`
- Add `"@mui/styles": "^5.10.9",`
- Add `"babel-eslint": "^10.1.0",`
### In Source files
- All `@iobroker/adapter-react/...` => `@iobroker/adapter-react-v5/...`
- All `@material-ui/icons/...` => `@mui/icons-material/...`
- Change `import { withStyles } from '@material-ui/core/styles';` => `import { withStyles } from '@mui/styles';`
- Change `import { makeStyles } from '@mui/material/styles';` => `import { makeStyles } from '@mui/styles';`
- Change `import withWidth from '@material-ui/core/withWidth';` => `import { withWidth } from '@iobroker/adapter-react-v5';`
- All `@material-ui/core...` => `@mui/material...`
- Change `import { MuiThemeProvider } from '@material-ui/core/styles';` => `import { ThemeProvider, StyledEngineProvider } from '@mui/material/styles';`
- Change all `` to ``
- Rename in styles `theme.palette.type` => `theme.palette.mode`
- Add to all `TextField`, `Select`, `FormControl` the property `variant="standard"`
- Add to all `Button` that do not have `color` property: `color="grey"`
- Replace by `TextField` the `readOnly` attribute (if exists) with `InputProps={{readOnly: true}}`
- Remove px by all `theme.spacing`: `calc(100% - ${theme.spacing(4)}px)` => `calc(100% - ${theme.spacing(4)})`
- Replace `this.selectTab(e.target.parentNode.dataset.name, index)` => `this.selectTab(e.target.dataset.name, index)`
If you still have questions, try to find an answer [here](https://mui.com/guides/migration-v4/).
## Migration from adapter-react-v5@3.x to adapter-react-v5@4.x
- Look for getObjectView socket requests and replace `socket.getObjectView('startKey', 'endKey', 'instance')` to `socket.getObjectViewSystem('instance', 'startKey', 'endKey')`
- Look for calls of custom like
```
this.props.socket._socket.emit('getObjectView', 'system', 'custom', {startKey: '', endKey:'\u9999'}, (err, objs) => {
(objs?.rows || [])
.forEach(item => console.log(item.id, item.value));
});
```
to
```
socket.getObjectViewCustom('custom', 'state', 'startKey', 'endKey')
.then(objects => {
Object.keys(objects).forEach(obj => console.log(obj._id));
});
```
- Replace all `socket.log.error('text')` to `socket.log('text', 'error')`
- Add to App.js `import { AdminConnection } from '@iobroker/adapter-react-v5';` and `super(props, { Connection: AdminConnection });` if run in admin
## Migration from adapter-react-v5@4.x to adapter-react-v5@5.x
- `Theme` is renamed to IobTheme. It is an object with classes inside. `Theme` is still inside and it same as mui `createTheme`.
- adapter-react-v5 has all types exported. So you can use `import { type IobTheme, Theme } from '@iobroker/adapter-react-v5';` and `const theme: IobTheme = Theme('light');`
- Json-Config is now an external package and must be included as dependency separately.
- Use type `Translate` for `t(word: string, ...args: any[]) => string`
- All components for admin JsonConfig must be changed:
Before `adapter-react-v5@5.x`:
```
import { ConfigGeneric, I18n } from '@iobroker/adapter-react-v5';
class JsonComponent extends ConfigGeneric {
...
}
```
With `adapter-react-v5@5.x`:
```
import { I18n } from '@iobroker/adapter-react-v5';
import { ConfigGeneric } from '@iobroker/json-config';
class JsonComponent extends ConfigGeneric {
...
}
```
## Migration from v5 to v6
The main change is that the `withStyles` was removed. So you have to replace all `withStyles` with `sx` or `style` properties.
You can read more about sx [here](https://mui.com/system/getting-started/the-sx-prop/).
- Remove at start of the file `import { withStyles } from '@mui/styles';`
- Replace at the very end of the file `export default withStyles(styles)(MyComponent);` with `export default MyComponent;`
- Modify `const styles`:
Before:
```
const styles: Record = (theme: IobTheme) => ({
dialog: {
height: `calc(100% - ${theme.mixins.toolbar.minHeight}px)`,
padding: theme.spacing(1),
margin: theme.spacing(2),
gap: 5,
borderRadius: 5,
marginLeft: 10, // marginTop, marginRight, marginBottom
paddingLeft: 10, // paddingTop, paddingRight, paddingBottom
},
...
});
```
After:
```
const styles: Record = {
dialog: (theme: IobTheme) => ({
height: `calc(100% - ${theme => theme.mixins.toolbar.minHeight}px)`,
p: 1, // or 8px, padding is OK too
m: '16px', // or 2, margin is OK too
gap: '5px',
borderRadius: '5px',
ml: '10px', // mt, mr, mb, but marginLeft, marginRight, marginBottom is OK too
pl: '10px', // pt, pr, pb, but paddingTop, paddingRight, paddingBottom is OK too
}),
};
```
- Modify `className`:
Before: ``
After: `
`
Before: ``
After: ``
Or if no one style is a function: ``
Do not use `sx` if the style is not dynamic (not a function). Use `style` instead.
Be aware, that all paddings and margins are now in `theme.spacing(1)` format.
So you have to replace all `padding: 8` with `padding: 1` or with `padding: '8px'`.
The best practice is to replace `padding` with `p` and `margin` with `m`, so you will see immediately that it is a padding or margin for `sx` property.
- Modify `classes`:
Before: `