@iobroker/adapter-react-v5
Version:
React components to develop ioBroker interfaces with react.
842 lines (835 loc) • 31.6 kB
JavaScript
/**
* Copyright 2018-2024 Denis Haev (bluefox) <dogafox@gmail.com>
*
* MIT License
*
*/
import React from 'react';
import { PROGRESS, Connection } from '@iobroker/socket-client';
import * as Sentry from '@sentry/browser';
import { Snackbar, IconButton } from '@mui/material';
import { Close as IconClose } from '@mui/icons-material';
import { printPrompt } from './Prompt';
import { Theme } from './Theme';
import { Loader } from './Components/Loader';
import { Router } from './Components/Router';
import { Utils } from './Components/Utils';
import { SaveCloseButtons } from './Components/SaveCloseButtons';
import { DialogConfirm } from './Dialogs/Confirm';
import { I18n } from './i18n';
import { DialogError } from './Dialogs/Error';
import { dictionary } from './dictionary';
// import './index.css';
const cssStyle = `
html {
height: 100%;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
width: 100%;
height: 100%;
overflow: hidden;
}
/* scrollbar */
::-webkit-scrollbar-track {
background-color: #ccc;
border-radius: 5px;
}
::-webkit-scrollbar {
width: 5px;
height: 5px;
background-color: #ccc;
}
::-webkit-scrollbar-thumb {
background-color: #575757;
border-radius: 5px;
}
#root {
height: 100%;
}
.App {
height: 100%;
}
glow {
from {
background-color: initial;
}
to {
background-color: #58c458;
}
}
`;
function isIFrame() {
try {
return window.self !== window.top;
}
catch {
return true;
}
}
export class GenericApp extends Router {
socket;
isIFrame = isIFrame();
instance;
adapterName;
instanceId;
newReact;
encryptedFields;
sentryDSN;
alertDialogRendered;
_secret;
_systemConfig;
// it is not readonly
savedNative;
common = null;
sentryStarted = false;
sentryInited = false;
resizeTimer = null;
constructor(props, settings) {
const ConnectionClass = (props.Connection ||
settings?.Connection ||
Connection);
// const ConnectionClass = props.Connection === 'admin' || settings.Connection = 'admin' ? AdminConnection : (props.Connection || settings.Connection || Connection);
if (!window.document.getElementById('generic-app-iobroker-component')) {
const style = window.document.createElement('style');
style.setAttribute('id', 'generic-app-iobroker-component');
style.innerHTML = cssStyle;
window.document.head.appendChild(style);
}
// Remove `!Connection.isWeb() && window.adapterName !== 'material'` when iobroker.socket will support native ws
if (!GenericApp.isWeb() && window.io && window.location.port === '3000') {
try {
const io = new window.SocketClient();
delete window.io;
window.io = io;
}
catch {
// ignore
}
}
super(props);
printPrompt();
const query = (window.location.search || '').replace(/^\?/, '').replace(/#.*$/, '');
const args = {};
query
.trim()
.split('&')
.filter(t => t.trim())
.forEach(b => {
const parts = b.split('=');
args[parts[0]] = parts.length === 2 ? parts[1] : true;
if (args[parts[0]] === 'true') {
args[parts[0]] = true;
}
else if (args[parts[0]] === 'false') {
args[parts[0]] = false;
}
});
// extract instance from URL
this.instance =
settings?.instance ??
props.instance ??
(args.instance !== undefined
? parseInt(args.instance, 10) || 0
: parseInt(window.location.search.slice(1), 10) || 0);
// extract adapter name from URL
const tmp = window.location.pathname.split('/');
this.adapterName =
settings?.adapterName || props.adapterName || window.adapterName || tmp[tmp.length - 2] || 'iot';
this.instanceId = `system.adapter.${this.adapterName}.${this.instance}`;
this.newReact = args.newReact === true; // it is admin5
const location = Router.getLocation();
location.tab =
location.tab ||
(window._localStorage || window.localStorage).getItem(`${this.adapterName}-adapter`) ||
'';
const themeInstance = this.createTheme();
this.state = Object.assign(this.state || {}, // keep the existing state
{
selectedTab: (window._localStorage || window.localStorage).getItem(`${this.adapterName}-adapter`) || '',
selectedTabNum: -1,
native: {},
errorText: '',
changed: false,
connected: false,
loaded: false,
isConfigurationError: '',
expertMode: false,
toast: '',
theme: themeInstance,
themeName: this.getThemeName(themeInstance),
themeType: this.getThemeType(themeInstance),
bottomButtons: (settings && settings.bottomButtons) === false ? false : props?.bottomButtons !== false,
width: GenericApp.getWidth(),
confirmClose: false,
_alert: false,
_alertType: 'info',
_alertMessage: '',
});
// init translations
const translations = dictionary;
// merge together
if (settings?.translations) {
Object.keys(settings.translations).forEach(lang => {
if (settings.translations) {
translations[lang] = Object.assign(translations[lang], settings.translations[lang] || {});
}
});
}
else if (props.translations) {
Object.keys(props.translations).forEach(lang => {
if (props.translations) {
translations[lang] = Object.assign(translations[lang], props.translations[lang] || {});
}
});
}
I18n.setTranslations(translations);
this.savedNative = {}; // to detect if the config changed
this.encryptedFields = props.encryptedFields || settings?.encryptedFields || [];
this.sentryDSN = (settings && settings.sentryDSN) || props.sentryDSN;
if (window.socketUrl) {
if (window.socketUrl.startsWith(':')) {
window.socketUrl = `${window.location.protocol}//${window.location.hostname}${window.socketUrl}`;
}
else if (!window.socketUrl.startsWith('http://') && !window.socketUrl.startsWith('https://')) {
window.socketUrl = `${window.location.protocol}//${window.socketUrl}`;
}
}
this.alertDialogRendered = false;
if (!window.iobOldAlert) {
window.iobOldAlert = window.alert;
}
window.alert = message => {
if (!this.alertDialogRendered) {
window.iobOldAlert(message);
return;
}
if (message?.toString().toLowerCase().includes('error')) {
console.error(message);
this.showAlert(message.toString(), 'error');
}
else {
console.log(message);
this.showAlert(message.toString(), 'info');
}
};
// @ts-expect-error either make props in ConnectionProps required or the constructor needs to accept than as they are (means adapt socket-client)
this.socket = new ConnectionClass({
...(props?.socket || settings?.socket),
name: this.adapterName,
doNotLoadAllObjects: settings?.doNotLoadAllObjects,
onProgress: (progress) => {
if (progress === PROGRESS.CONNECTING) {
this.setState({ connected: false });
}
else if (progress === PROGRESS.READY) {
this.setState({ connected: true });
}
else {
this.setState({ connected: true });
}
},
onReady: ( /* objects, scripts */) => {
I18n.setLanguage(this.socket.systemLang);
// subscribe because of language and expert mode
this.socket
.subscribeObject('system.config', this.onSystemConfigChanged)
.then(() => this.getSystemConfig())
.then(obj => {
this._secret =
(typeof obj !== 'undefined' && obj.native && obj.native.secret) || 'Zgfr56gFe87jJOM';
this._systemConfig = obj?.common || {};
return this.socket.getObject(this.instanceId);
})
.then(async (obj) => {
let waitPromise;
const instanceObj = obj;
const sentryPluginEnabled = (await this.socket.getState(`${this.instanceId}.plugins.sentry.enabled`))?.val;
const sentryEnabled = sentryPluginEnabled !== false &&
this._systemConfig?.diag !== 'none' &&
instanceObj?.common &&
instanceObj.common.name &&
instanceObj.common.version &&
// @ts-expect-error will be extended in js-controller TODO: (BF: 2024.05.30) this is redundant to state `${this.instanceId}.plugins.sentry.enabled`, remove this in future when admin sets the state correctly
!instanceObj.common.disableDataReporting &&
window.location.host !== 'localhost:3000';
// activate sentry plugin
if (!this.sentryStarted && this.sentryDSN && sentryEnabled) {
this.sentryStarted = true;
Sentry.init({
dsn: this.sentryDSN,
release: `iobroker.${instanceObj.common.name}@${instanceObj.common.version}`,
integrations: [Sentry.dedupeIntegration()],
});
console.log('Sentry initialized');
}
// read UUID and init sentry with it.
// for backward compatibility it will be processed separately from the above logic: some adapters could still have this.sentryDSN as undefined
if (!this.sentryInited && sentryEnabled) {
this.sentryInited = true;
waitPromise = this.socket.getObject('system.meta.uuid').then(uuidObj => {
if (uuidObj && uuidObj.native && uuidObj.native.uuid) {
const scope = Sentry.getCurrentScope();
scope.setUser({ id: uuidObj.native.uuid });
}
});
}
waitPromise = waitPromise instanceof Promise ? waitPromise : Promise.resolve();
void waitPromise.then(() => {
if (instanceObj) {
this.common = instanceObj?.common;
this.onPrepareLoad(instanceObj.native, instanceObj.encryptedNative); // decode all secrets
this.savedNative = JSON.parse(JSON.stringify(instanceObj.native));
this.setState({ native: instanceObj.native, loaded: true, expertMode: this.getExpertMode() }, () => this.onConnectionReady && this.onConnectionReady());
}
else {
console.warn('Cannot load instance settings');
this.setState({
native: {},
loaded: true,
expertMode: this.getExpertMode(),
}, () => this.onConnectionReady && this.onConnectionReady());
}
});
})
.catch(e => window.alert(`Cannot settings: ${e}`));
},
onError: (err) => {
console.error(err);
this.showError(err);
},
});
}
/**
* Checks if this connection is running in a web adapter and not in an admin.
*
* @returns True if running in a web adapter or in a socketio adapter.
*/
static isWeb() {
return window.socketUrl !== undefined;
}
showAlert(message, type) {
if (type !== 'error' && type !== 'warning' && type !== 'info' && type !== 'success') {
type = 'info';
}
this.setState({
_alert: true,
_alertType: type,
_alertMessage: message,
});
}
renderAlertSnackbar() {
this.alertDialogRendered = true;
return (React.createElement(Snackbar, { style: this.state._alertType === 'error'
? { backgroundColor: '#f44336' }
: this.state._alertType === 'success'
? { backgroundColor: '#4caf50' }
: undefined, open: this.state._alert, autoHideDuration: 6000, onClose: (_e, reason) => reason !== 'clickaway' && this.setState({ _alert: false }), message: this.state._alertMessage }));
}
onSystemConfigChanged = (id, obj) => {
if (obj && id === 'system.config') {
if (this.socket.systemLang !== obj?.common.language) {
this.socket.systemLang = obj?.common.language || 'en';
I18n.setLanguage(this.socket.systemLang);
}
if (this._systemConfig?.expertMode !== !!obj?.common?.expertMode) {
this._systemConfig =
obj?.common || {};
this.setState({ expertMode: this.getExpertMode() });
}
else {
this._systemConfig =
obj?.common || {};
}
}
};
/**
* Called immediately after a component is mounted. Setting state here will trigger re-rendering.
*/
componentDidMount() {
window.addEventListener('resize', this.onResize, true);
window.addEventListener('message', this.onReceiveMessage, false);
super.componentDidMount();
}
/**
* Called immediately before a component is destroyed.
*/
componentWillUnmount() {
window.removeEventListener('resize', this.onResize, true);
window.removeEventListener('message', this.onReceiveMessage, false);
// restore window.alert
if (window.iobOldAlert) {
window.alert = window.iobOldAlert;
delete window.iobOldAlert;
}
super.componentWillUnmount();
}
onReceiveMessage = (message) => {
if (message?.data) {
if (message.data === 'updateTheme') {
const newThemeName = Utils.getThemeName();
Utils.setThemeName(Utils.getThemeName());
const newTheme = this.createTheme(newThemeName);
this.setState({
theme: newTheme,
themeName: this.getThemeName(newTheme),
themeType: this.getThemeType(newTheme),
}, () => {
this.props.onThemeChange && this.props.onThemeChange(newThemeName);
this.onThemeChanged && this.onThemeChanged(newThemeName);
});
}
else if (message.data === 'updateExpertMode') {
this.onToggleExpertMode && this.onToggleExpertMode(this.getExpertMode());
}
else if (message.data !== 'chartReady') {
// if not "echart ready" message
console.debug(`Received unknown message: "${JSON.stringify(message.data)}". May be it will be processed later`);
}
}
};
onResize = () => {
this.resizeTimer && clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
this.resizeTimer = null;
this.setState({ width: GenericApp.getWidth() });
}, 200);
};
/**
* Gets the width depending on the window inner width.
*/
static getWidth() {
/**
* innerWidth |xs sm md lg xl
* |-------|-------|-------|-------|------>
* width | xs | sm | md | lg | xl
*/
const SIZES = {
xs: 0,
sm: 600,
md: 960,
lg: 1280,
xl: 1920,
};
const width = window.innerWidth;
const keys = Object.keys(SIZES).reverse();
const widthComputed = keys.find(key => width >= SIZES[key]);
return widthComputed || 'xs';
}
/**
* Get a theme
*
* @param name Theme name
*/
// eslint-disable-next-line class-methods-use-this
createTheme(name) {
return Theme(Utils.getThemeName(name));
}
/**
* Get the theme name
*/
// eslint-disable-next-line class-methods-use-this
getThemeName(currentTheme) {
return currentTheme.name;
}
/**
* Get the theme type
*/
// eslint-disable-next-line class-methods-use-this
getThemeType(currentTheme) {
return currentTheme.palette.mode;
}
// eslint-disable-next-line class-methods-use-this
onThemeChanged(_newThemeName) { }
// eslint-disable-next-line class-methods-use-this
onToggleExpertMode(_expertMode) { }
/**
* Changes the current theme
*/
toggleTheme(newThemeName) {
const themeName = this.state.themeName;
// dark => blue => colored => light => dark
newThemeName =
newThemeName ||
(themeName === 'dark'
? 'light'
: themeName === 'blue'
? 'light'
: themeName === 'colored'
? 'light'
: 'dark');
if (newThemeName !== themeName) {
Utils.setThemeName(newThemeName);
const newTheme = this.createTheme(newThemeName);
this.setState({
theme: newTheme,
themeName: this.getThemeName(newTheme),
themeType: this.getThemeType(newTheme),
}, () => {
this.props.onThemeChange && this.props.onThemeChange(newThemeName || 'light');
this.onThemeChanged && this.onThemeChanged(newThemeName || 'light');
});
}
}
/**
* Gets the system configuration.
*/
getSystemConfig() {
return this.socket.getSystemConfig();
}
/**
* Get current expert mode
*/
getExpertMode() {
return window.sessionStorage.getItem('App.expertMode') === 'true' || !!this._systemConfig?.expertMode;
}
/**
* Gets called when the socket.io connection is ready.
* You can overload this function to execute own commands.
*/
// eslint-disable-next-line class-methods-use-this
onConnectionReady() { }
/**
* Encrypts a string.
*/
encrypt(value) {
let result = '';
if (this._secret) {
for (let i = 0; i < value.length; i++) {
result += String.fromCharCode(this._secret[i % this._secret.length].charCodeAt(0) ^ value.charCodeAt(i));
}
}
return result;
}
/**
* Decrypts a string.
*/
decrypt(value) {
let result = '';
if (this._secret) {
for (let i = 0; i < value.length; i++) {
result += String.fromCharCode(this._secret[i % this._secret.length].charCodeAt(0) ^ value.charCodeAt(i));
}
}
return result;
}
/**
* Gets called when the navigation hash changes.
* You may override this if needed.
*/
onHashChanged() {
const location = Router.getLocation();
if (location.tab !== this.state.selectedTab) {
this.selectTab(location.tab);
}
}
/**
* Selects the given tab.
*/
selectTab(tab, index) {
(window._localStorage || window.localStorage).setItem(`${this.adapterName}-adapter`, tab);
this.setState({ selectedTab: tab, selectedTabNum: index });
}
/**
* Gets called before the settings are saved.
* You may override this if needed.
*/
onPrepareSave(settings) {
// here you can encode values
this.encryptedFields?.forEach(attr => {
if (settings[attr]) {
settings[attr] = this.encrypt(settings[attr]);
}
});
return true;
}
/**
* Gets called after the settings are loaded.
* You may override this if needed.
*
* @param settings instance settings from native part
* @param encryptedNative optional list of fields to be decrypted
*/
onPrepareLoad(settings, encryptedNative) {
// here you can encode values
this.encryptedFields?.forEach(attr => {
if (settings[attr]) {
settings[attr] = this.decrypt(settings[attr]);
}
});
encryptedNative?.forEach(attr => {
this.encryptedFields = this.encryptedFields || [];
!this.encryptedFields.includes(attr) && this.encryptedFields.push(attr);
if (settings[attr]) {
settings[attr] = this.decrypt(settings[attr]);
}
});
}
/**
* Gets the extendable instances.
*/
async getExtendableInstances() {
try {
const instances = await this.socket.getObjectViewSystem('instance', 'system.adapter.', 'system.adapter.\u9999');
return Object.values(instances).filter(instance => !!instance?.common?.webExtendable);
}
catch {
return [];
}
}
/**
* Gets the IP addresses of the given host.
*/
async getIpAddresses(host) {
const ips = await this.socket.getHostByIp(host || this.common?.host || '');
// translate names
const ip4 = ips.find(ip => ip.address === '0.0.0.0');
if (ip4) {
ip4.name = `[IPv4] 0.0.0.0 - ${I18n.t('ra_Listen on all IPs')}`;
}
const ip6 = ips.find(ip => ip.address === '::');
if (ip6) {
ip6.name = `[IPv4] :: - ${I18n.t('ra_Listen on all IPs')}`;
}
return ips;
}
/**
* Saves the settings to the server.
*
* @param isClose True if the user is closing the dialog.
*/
onSave(isClose) {
let oldObj;
if (this.state.isConfigurationError) {
this.setState({ errorText: this.state.isConfigurationError });
return;
}
this.socket
.getObject(this.instanceId)
.then(_oldObj => {
oldObj = (_oldObj || {});
for (const a in this.state.native) {
if (Object.prototype.hasOwnProperty.call(this.state.native, a)) {
if (this.state.native[a] === null) {
oldObj.native[a] = null;
}
else if (this.state.native[a] !== undefined) {
oldObj.native[a] = JSON.parse(JSON.stringify(this.state.native[a]));
}
else {
delete oldObj.native[a];
}
}
}
if (this.state.common) {
for (const b in this.state.common) {
if (this.state.common[b] === null) {
oldObj.common[b] = null;
}
else if (this.state.common[b] !== undefined) {
oldObj.common[b] = JSON.parse(JSON.stringify(this.state.common[b]));
}
else {
delete oldObj.common[b];
}
}
}
if (this.onPrepareSave(oldObj.native) !== false) {
return this.socket.setObject(this.instanceId, oldObj);
}
return Promise.reject(new Error('Invalid configuration'));
})
.then(() => {
this.savedNative = oldObj.native;
globalThis.changed = false;
try {
window.parent.postMessage('nochange', '*');
}
catch {
// ignore
}
this.setState({ changed: false }, () => {
isClose && GenericApp.onClose();
});
})
.catch(e => console.error(`Cannot save configuration: ${e}`));
}
/**
* Renders the toast.
*/
renderToast() {
if (!this.state.toast) {
return null;
}
return (React.createElement(Snackbar, { anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
}, open: !0, autoHideDuration: 6000, onClose: () => this.setState({ toast: '' }), ContentProps: { 'aria-describedby': 'message-id' }, message: React.createElement("span", { id: "message-id" }, this.state.toast), action: [
React.createElement(IconButton, { key: "close", "aria-label": "Close", color: "inherit", onClick: () => this.setState({ toast: '' }), size: "large" },
React.createElement(IconClose, null)),
] }));
}
/**
* Closes the dialog.
*/
static onClose() {
if (typeof window.parent !== 'undefined' && window.parent) {
try {
if (window.parent.$iframeDialog && typeof window.parent.$iframeDialog.close === 'function') {
window.parent.$iframeDialog.close();
}
else {
window.parent.postMessage('close', '*');
}
}
catch {
window.parent.postMessage('close', '*');
}
}
}
/**
* Renders the error dialog.
*/
renderError() {
if (!this.state.errorText) {
return null;
}
return (React.createElement(DialogError, { text: this.state.errorText, onClose: () => this.setState({ errorText: '' }) }));
}
/**
* Checks if the configuration has changed.
*
* @param native the new state
*/
getIsChanged(native) {
native = native || this.state.native;
const isChanged = JSON.stringify(native) !== JSON.stringify(this.savedNative);
globalThis.changed = isChanged;
return isChanged;
}
/**
* Gets called when loading the configuration.
*
* @param newNative The new configuration object.
*/
onLoadConfig(newNative) {
if (JSON.stringify(newNative) !== JSON.stringify(this.state.native)) {
this.setState({ native: newNative, changed: this.getIsChanged(newNative) });
}
}
/**
* Sets the configuration error.
*/
setConfigurationError(errorText) {
if (this.state.isConfigurationError !== errorText) {
this.setState({ isConfigurationError: errorText });
}
}
/**
* Renders the save and close buttons.
*/
renderSaveCloseButtons() {
if (!this.state.confirmClose && !this.state.bottomButtons) {
return null;
}
return (React.createElement(React.Fragment, null,
this.state.bottomButtons ? (React.createElement(SaveCloseButtons, { theme: this.state.theme, newReact: this.newReact, noTextOnButtons: this.state.width === 'xs' || this.state.width === 'sm' || this.state.width === 'md', changed: this.state.changed, onSave: (isClose) => this.onSave(isClose), onClose: () => {
if (this.state.changed) {
this.setState({ confirmClose: true });
}
else {
GenericApp.onClose();
}
}, error: !!this.state.isConfigurationError })) : null,
this.state.confirmClose ? (React.createElement(DialogConfirm, { title: I18n.t('ra_Please confirm'), text: I18n.t('ra_Some data are not stored. Discard?'), ok: I18n.t('ra_Discard'), cancel: I18n.t('ra_Cancel'), onClose: (isYes) => this.setState({ confirmClose: false }, () => isYes && GenericApp.onClose()) })) : null));
}
_updateNativeValue(obj, attrs, value) {
if (typeof attrs !== 'object') {
attrs = attrs.split('.');
}
const attr = attrs.shift() || '';
if (!attrs.length) {
if (value && typeof value === 'object') {
if (JSON.stringify(obj[attr]) !== JSON.stringify(value)) {
obj[attr] = value;
return true;
}
return false;
}
if (obj[attr] !== value) {
obj[attr] = value;
return true;
}
return false;
}
obj[attr] = obj[attr] || {};
if (typeof obj[attr] !== 'object') {
throw new Error(`attribute ${attr} is no object, but ${typeof obj[attr]}`);
}
return this._updateNativeValue(obj[attr], attrs, value);
}
/**
* Update the native value
*
* @param attr The attribute name with dots as delimiter.
* @param value The new value.
* @param cb Callback which will be called upon completion.
*/
updateNativeValue(attr, value, cb) {
const native = JSON.parse(JSON.stringify(this.state.native));
if (this._updateNativeValue(native, attr, value)) {
const changed = this.getIsChanged(native);
if (changed !== this.state.changed) {
try {
window.parent.postMessage(changed ? 'change' : 'nochange', '*');
}
catch {
// ignore
}
}
this.setState({ native, changed }, cb);
}
}
/**
* Set the error text to be shown.
*/
showError(text) {
this.setState({ errorText: text });
}
/**
* Sets the toast to be shown.
*
* @param toast Text to be shown.
*/
showToast(toast) {
this.setState({ toast });
}
/**
* Renders helper dialogs
*/
renderHelperDialogs() {
return (React.createElement(React.Fragment, null,
this.renderError(),
this.renderToast(),
this.renderSaveCloseButtons(),
this.renderAlertSnackbar()));
}
/**
* Renders this component.
*/
render() {
if (!this.state.loaded) {
return React.createElement(Loader, { themeType: this.state.themeType });
}
return (React.createElement("div", { className: "App" },
this.renderError(),
this.renderToast(),
this.renderSaveCloseButtons(),
this.renderAlertSnackbar()));
}
}
//# sourceMappingURL=GenericApp.js.map