UNPKG

@iobroker/adapter-react-v5

Version:

React components to develop ioBroker interfaces with react.

842 lines (835 loc) 31.6 kB
/** * 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%; } @keyframes 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