diff --git a/README.md b/README.md index 0782b309c..d59e80e43 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,12 @@ The icons may not be reused in other projects without the proper flaticon licens +### **WORK IN PROGRESS** + +- (@oweitman) Implemented better table view +- (@GermanBluefox) Extended DM with device type +- (@GermanBluefox) Showed the tabs in JSONConfig on narrow displays as menu + ### 7.4.14 (2025-01-15) - (@GermanBluefox) Extended DM with device type diff --git a/packages/adapter-react-v5/src/Components/ObjectBrowser.tsx b/packages/adapter-react-v5/src/Components/ObjectBrowser.tsx index d1be5131f..11c51b004 100644 --- a/packages/adapter-react-v5/src/Components/ObjectBrowser.tsx +++ b/packages/adapter-react-v5/src/Components/ObjectBrowser.tsx @@ -1630,7 +1630,11 @@ function buildTree( const common = obj.common; const role = common?.role; if (role && !info.roles.find(it => it.role === role)) { - info.roles.push({ role, type: common.type }); + if (typeof role !== 'string') { + console.warn(`Invalid role type "${typeof role}" in "${obj._id}"`); + } else { + info.roles.push({ role, type: common.type }); + } } else if (id.startsWith('enum.rooms.')) { info.roomEnums.push(id); info.enums.push(id); diff --git a/packages/adapter-react-v5/src/GenericApp.tsx b/packages/adapter-react-v5/src/GenericApp.tsx index 2fb8ee595..ccfa17691 100644 --- a/packages/adapter-react-v5/src/GenericApp.tsx +++ b/packages/adapter-react-v5/src/GenericApp.tsx @@ -43,7 +43,7 @@ declare global { SocketClient: any; adapterName: undefined | string; socketUrl: undefined | string; - oldAlert: any; + iobOldAlert: any; changed: boolean; $iframeDialog: { close?: () => void; @@ -272,13 +272,15 @@ export class GenericApp< this.alertDialogRendered = false; - window.oldAlert = window.alert; + if (!window.iobOldAlert) { + window.iobOldAlert = window.alert; + } window.alert = message => { if (!this.alertDialogRendered) { - window.oldAlert(message); + window.iobOldAlert(message); return; } - if (message && message.toString().toLowerCase().includes('error')) { + if (message?.toString().toLowerCase().includes('error')) { console.error(message); this.showAlert(message.toString(), 'error'); } else { @@ -468,6 +470,13 @@ export class GenericApp< componentWillUnmount(): void { 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(); } diff --git a/packages/adapter-react-v5/src/i18n/de.json b/packages/adapter-react-v5/src/i18n/de.json index b1b122f1d..253d6bcce 100644 --- a/packages/adapter-react-v5/src/i18n/de.json +++ b/packages/adapter-react-v5/src/i18n/de.json @@ -3,6 +3,7 @@ "ra_%s object(s) processed": "%s Objekt(e) verarbeitet", "ra_%s was imported": "%s wurde importiert", "ra_Accept license": "Lizenz akzeptieren", + "ra_Actions": "Aktionen", "ra_Add new child object to selected parent": "Dem ausgewählten übergeordneten Objekt ein neues untergeordnetes Objekt hinzufügen", "ra_Add objects tree from JSON file": "Objektbaum aus JSON-Datei hinzufügen", "ra_Add row": "Zeile hinzufügen", @@ -89,6 +90,7 @@ "ra_Fallback custom certificates": "Benutzerdefinierte Fallback-Zertifikate", "ra_File is too big. Max %sk allowed. Try use SVG.": "Datei ist zu groß. Max %sk erlaubt. Versuchen Sie, SVG zu verwenden.", "ra_Filter": "Filter", + "ra_Filter and Data Actions": "Filter- und Datenaktionen", "ra_Filter files": "Dateien filtern", "ra_Folder name": "Ordnernamen", "ra_Folder → Channel → State": "Ordner → Kanal → Zustand", diff --git a/packages/adapter-react-v5/src/i18n/en.json b/packages/adapter-react-v5/src/i18n/en.json index 4b47fe2c5..a779bd9de 100644 --- a/packages/adapter-react-v5/src/i18n/en.json +++ b/packages/adapter-react-v5/src/i18n/en.json @@ -3,6 +3,7 @@ "ra_%s object(s) processed": "%s object(s) processed", "ra_%s was imported": "%s was imported", "ra_Accept license": "Accept license", + "ra_Actions": "Actions", "ra_Add new child object to selected parent": "Add new child object to selected parent", "ra_Add objects tree from JSON file": "Add objects tree from JSON file", "ra_Add row": "Add row", @@ -89,6 +90,7 @@ "ra_Fallback custom certificates": "Fallback custom certificates", "ra_File is too big. Max %sk allowed. Try use SVG.": "File is too big. Max %sk allowed. Try use SVG.", "ra_Filter": "Filter", + "ra_Filter and Data Actions": "Filter and Data Actions", "ra_Filter files": "Filter files", "ra_Folder name": "Folder name", "ra_Folder → Channel → State": "Folder → Channel → State", diff --git a/packages/adapter-react-v5/src/i18n/es.json b/packages/adapter-react-v5/src/i18n/es.json index 6d61ee2bd..2b4c15ae4 100644 --- a/packages/adapter-react-v5/src/i18n/es.json +++ b/packages/adapter-react-v5/src/i18n/es.json @@ -3,6 +3,7 @@ "ra_%s object(s) processed": "%s objeto(s) procesados", "ra_%s was imported": "%s fue importado", "ra_Accept license": "Aceptar licencia", + "ra_Actions": "Comportamiento", "ra_Add new child object to selected parent": "Agregar nuevo objeto hijo al padre seleccionado", "ra_Add objects tree from JSON file": "Agregar árbol de objetos desde el archivo JSON", "ra_Add row": "Añadir fila", @@ -89,6 +90,7 @@ "ra_Fallback custom certificates": "Certificados personalizados alternativos", "ra_File is too big. Max %sk allowed. Try use SVG.": "El archivo es demasiado grande. Máximo de %sk permitido. Intenta usar SVG.", "ra_Filter": "Filtrar", + "ra_Filter and Data Actions": "Acciones de filtrado y datos", "ra_Filter files": "Filtrar archivos", "ra_Folder name": "Nombre de la carpeta", "ra_Folder → Channel → State": "Carpeta → Canal → Estado", diff --git a/packages/adapter-react-v5/src/i18n/fr.json b/packages/adapter-react-v5/src/i18n/fr.json index fe5591949..b124ac30d 100644 --- a/packages/adapter-react-v5/src/i18n/fr.json +++ b/packages/adapter-react-v5/src/i18n/fr.json @@ -3,6 +3,7 @@ "ra_%s object(s) processed": "%s objet(s) traité(s)", "ra_%s was imported": "%s a été importé", "ra_Accept license": "Accepter la licence", + "ra_Actions": "Actes", "ra_Add new child object to selected parent": "Ajouter un nouvel objet enfant au parent sélectionné", "ra_Add objects tree from JSON file": "Ajouter une arborescence d'objets à partir d'un fichier JSON", "ra_Add row": "Ajouter une rangée", @@ -89,6 +90,7 @@ "ra_Fallback custom certificates": "Certificats personnalisés de secours", "ra_File is too big. Max %sk allowed. Try use SVG.": "Le fichier est trop volumineux. Max %sk autorisé. Essayez d'utiliser SVG.", "ra_Filter": "Filtre", + "ra_Filter and Data Actions": "Actions sur les filtres et les données", "ra_Filter files": "Filtrer les fichiers", "ra_Folder name": "Nom de dossier", "ra_Folder → Channel → State": "Dossier → Chaîne → État", diff --git a/packages/adapter-react-v5/src/i18n/it.json b/packages/adapter-react-v5/src/i18n/it.json index 542dfaa75..e69d54aea 100644 --- a/packages/adapter-react-v5/src/i18n/it.json +++ b/packages/adapter-react-v5/src/i18n/it.json @@ -3,6 +3,7 @@ "ra_%s object(s) processed": "%s oggetti elaborati", "ra_%s was imported": "%s è stato importato", "ra_Accept license": "Accetta licenza", + "ra_Actions": "Azioni", "ra_Add new child object to selected parent": "Aggiungi un nuovo oggetto figlio al genitore selezionato", "ra_Add objects tree from JSON file": "Aggiungi l'albero degli oggetti dal file JSON", "ra_Add row": "Aggiungi riga", @@ -89,6 +90,7 @@ "ra_Fallback custom certificates": "Certificati personalizzati di fallback", "ra_File is too big. Max %sk allowed. Try use SVG.": "Il file è troppo grande. Max %sk consentito. Prova a usare SVG.", "ra_Filter": "Filtro", + "ra_Filter and Data Actions": "Azioni sui filtri e sui dati", "ra_Filter files": "Filtra i file", "ra_Folder name": "Nome della cartella", "ra_Folder → Channel → State": "Cartella → Canale → Stato", diff --git a/packages/adapter-react-v5/src/i18n/nl.json b/packages/adapter-react-v5/src/i18n/nl.json index d1176537a..768008b37 100644 --- a/packages/adapter-react-v5/src/i18n/nl.json +++ b/packages/adapter-react-v5/src/i18n/nl.json @@ -3,6 +3,7 @@ "ra_%s object(s) processed": "%s object(en) verwerkt", "ra_%s was imported": "%s is geïmporteerd", "ra_Accept license": "Accepteer licentie", + "ra_Actions": "Acties", "ra_Add new child object to selected parent": "Voeg een nieuw kindobject toe aan het geselecteerde bovenliggende object", "ra_Add objects tree from JSON file": "Objectenboom toevoegen vanuit JSON-bestand", "ra_Add row": "Voeg een rij toe", @@ -89,6 +90,7 @@ "ra_Fallback custom certificates": "Fallback aangepaste certificaten", "ra_File is too big. Max %sk allowed. Try use SVG.": "Bestand is te groot. Max. %sk toegestaan. Probeer SVG te gebruiken.", "ra_Filter": "Filter", + "ra_Filter and Data Actions": "Filter- en gegevensacties", "ra_Filter files": "Bestanden filteren", "ra_Folder name": "Naam van de map", "ra_Folder → Channel → State": "Map → Kanaal → Staat", diff --git a/packages/adapter-react-v5/src/i18n/pl.json b/packages/adapter-react-v5/src/i18n/pl.json index 48b5e6bf0..82f817147 100644 --- a/packages/adapter-react-v5/src/i18n/pl.json +++ b/packages/adapter-react-v5/src/i18n/pl.json @@ -3,6 +3,7 @@ "ra_%s object(s) processed": "Przetworzono %s obiektów", "ra_%s was imported": "%s został zaimportowany", "ra_Accept license": "Zaakceptuj licencję", + "ra_Actions": "Akcje", "ra_Add new child object to selected parent": "Dodaj nowy obiekt potomny do wybranego rodzica", "ra_Add objects tree from JSON file": "Dodaj drzewo obiektów z pliku JSON", "ra_Add row": "Dodaj wiersz", @@ -89,6 +90,7 @@ "ra_Fallback custom certificates": "Niestandardowe certyfikaty zastępcze", "ra_File is too big. Max %sk allowed. Try use SVG.": "Plik jest za duży. Maksymalna dozwolona liczba %sk. Spróbuj użyć SVG.", "ra_Filter": "Filtr", + "ra_Filter and Data Actions": "Akcje filtrów i danych", "ra_Filter files": "Filtruj pliki", "ra_Folder name": "Nazwa folderu", "ra_Folder → Channel → State": "Folder → Kanał → Stan", diff --git a/packages/adapter-react-v5/src/i18n/pt.json b/packages/adapter-react-v5/src/i18n/pt.json index 007187caa..a0e871877 100644 --- a/packages/adapter-react-v5/src/i18n/pt.json +++ b/packages/adapter-react-v5/src/i18n/pt.json @@ -3,6 +3,7 @@ "ra_%s object(s) processed": "%s objeto(s) processado(s)", "ra_%s was imported": "%s foi importado", "ra_Accept license": "Aceitar licença", + "ra_Actions": "Ações", "ra_Add new child object to selected parent": "Adicionar novo objeto filho ao pai selecionado", "ra_Add objects tree from JSON file": "Adicionar árvore de objetos do arquivo JSON", "ra_Add row": "Adicionar linha", @@ -89,6 +90,7 @@ "ra_Fallback custom certificates": "Certificados personalizados alternativos", "ra_File is too big. Max %sk allowed. Try use SVG.": "O arquivo é muito grande. Max %sk permitido. Tente usar SVG.", "ra_Filter": "Filtro", + "ra_Filter and Data Actions": "Ações de filtro e dados", "ra_Filter files": "Filtrar arquivos", "ra_Folder name": "Nome da pasta", "ra_Folder → Channel → State": "Pasta → Canal → Estado", diff --git a/packages/adapter-react-v5/src/i18n/ru.json b/packages/adapter-react-v5/src/i18n/ru.json index 8f3743905..0c8107b91 100644 --- a/packages/adapter-react-v5/src/i18n/ru.json +++ b/packages/adapter-react-v5/src/i18n/ru.json @@ -3,6 +3,7 @@ "ra_%s object(s) processed": "Объектов обработано: %s", "ra_%s was imported": "%s был импортирован", "ra_Accept license": "Принять лицензию", + "ra_Actions": "Действия", "ra_Add new child object to selected parent": "Добавить новый дочерний объект к выбранному родительскому объекту", "ra_Add objects tree from JSON file": "Добавить дерево объектов из файла JSON", "ra_Add row": "Добавить ряд", @@ -89,6 +90,7 @@ "ra_Fallback custom certificates": "Резервные пользовательские сертификаты", "ra_File is too big. Max %sk allowed. Try use SVG.": "Файл слишком большой. Разрешено максимальное количество %sk. Попробуйте использовать SVG.", "ra_Filter": "Фильтр", + "ra_Filter and Data Actions": "Фильтрация и действия с данными", "ra_Filter files": "Фильтровать файлы", "ra_Folder name": "Имя папки", "ra_Folder → Channel → State": "Папка → Канал → Состояние", diff --git a/packages/adapter-react-v5/src/i18n/uk.json b/packages/adapter-react-v5/src/i18n/uk.json index 212862700..21ff56e52 100644 --- a/packages/adapter-react-v5/src/i18n/uk.json +++ b/packages/adapter-react-v5/src/i18n/uk.json @@ -3,6 +3,7 @@ "ra_%s object(s) processed": "Оброблено %s об’єктів", "ra_%s was imported": "%s було імпортовано", "ra_Accept license": "Прийняти ліцензію", + "ra_Actions": "Дії", "ra_Add new child object to selected parent": "Додати новий дочірній об’єкт до вибраного батьківського", "ra_Add objects tree from JSON file": "Додайте дерево об’єктів із файлу JSON", "ra_Add row": "Додати рядок", @@ -89,6 +90,7 @@ "ra_Fallback custom certificates": "Запасні спеціальні сертифікати", "ra_File is too big. Max %sk allowed. Try use SVG.": "Файл завеликий. Максимально дозволено %sk. Спробуйте використовувати SVG.", "ra_Filter": "фільтр", + "ra_Filter and Data Actions": "Фільтр і дії з даними", "ra_Filter files": "Фільтр файлів", "ra_Folder name": "Назва папки", "ra_Folder → Channel → State": "Папка → Канал → Стан", diff --git a/packages/adapter-react-v5/src/i18n/zh-cn.json b/packages/adapter-react-v5/src/i18n/zh-cn.json index 42e0f0438..4ba058e5d 100644 --- a/packages/adapter-react-v5/src/i18n/zh-cn.json +++ b/packages/adapter-react-v5/src/i18n/zh-cn.json @@ -3,6 +3,7 @@ "ra_%s object(s) processed": "已处理%s个对象", "ra_%s was imported": "对象已导入", "ra_Accept license": "接受许可", + "ra_Actions": "操作", "ra_Add new child object to selected parent": "将新的子对象添加到选定的父对象", "ra_Add objects tree from JSON file": "从JSON文件添加对象树", "ra_Add row": "添加行", @@ -89,6 +90,7 @@ "ra_Fallback custom certificates": "后备自定义证书", "ra_File is too big. Max %sk allowed. Try use SVG.": "文件太大。允许的最大字节%s数。尝试使用 SVG。", "ra_Filter": "筛选", + "ra_Filter and Data Actions": "过滤和数据操作", "ra_Filter files": "过滤文件", "ra_Folder name": "文件夹名称", "ra_Folder → Channel → State": "文件夹→频道→状态", diff --git a/packages/adapter-react-v5/src/types.d.ts b/packages/adapter-react-v5/src/types.d.ts index 68320a0b5..1da2765f3 100644 --- a/packages/adapter-react-v5/src/types.d.ts +++ b/packages/adapter-react-v5/src/types.d.ts @@ -31,7 +31,7 @@ export interface ConnectionProps { /** Automatically subscribe to logging. */ autoSubscribeLog?: boolean; /** The protocol to use for the socket.io connection. */ - protocol?: string; + protocol?: 'http:' | 'https:'; /** The host name to use for the socket.io connection. */ host?: string; /** The port to use for the socket.io connection. */ diff --git a/packages/admin/src-admin/package.json b/packages/admin/src-admin/package.json index 4e82d26ff..63ad3d54d 100644 --- a/packages/admin/src-admin/package.json +++ b/packages/admin/src-admin/package.json @@ -103,5 +103,5 @@ } ] ], - "version": "7.4.13" + "version": "7.4.14" } \ No newline at end of file diff --git a/packages/admin/src-admin/src/App.tsx b/packages/admin/src-admin/src/App.tsx index 26f390546..48f7c7f84 100644 --- a/packages/admin/src-admin/src/App.tsx +++ b/packages/admin/src-admin/src/App.tsx @@ -2787,21 +2787,20 @@ class App extends Router { {this.state.drawerState !== DrawerStates.opened && !this.state.expertMode && - window.innerWidth > 400 && ( + window.innerWidth > 450 && ( - {(!this.state.user || this.props.width === 'xs' || this.props.width === 'sm') && ( + {!this.state.user ? ( admin {!this.adminGuiConfig.icon && this.state.versionAdmin && ( @@ -2815,7 +2814,7 @@ class App extends Router { )} - )} + ) : null} {installedFrom && !installedFrom.startsWith(`iobroker.${adapterName}@`) && ( = { height: 30, margin: 'auto 0', position: 'relative', - marginRight: 10, borderRadius: 3, background: '#FFFFFF', padding: 2, @@ -61,6 +60,7 @@ const styles: Record = { '@media screen and (max-width: 710px)': { display: 'none', }, + marginLeft: '8px', }, width: { width: '100%', diff --git a/packages/admin/src-admin/src/components/Instances/InstanceGeneric.tsx b/packages/admin/src-admin/src/components/Instances/InstanceGeneric.tsx index 32892969e..bd1332b29 100644 --- a/packages/admin/src-admin/src/components/Instances/InstanceGeneric.tsx +++ b/packages/admin/src-admin/src/components/Instances/InstanceGeneric.tsx @@ -1368,7 +1368,7 @@ export default abstract class InstanceGeneric< return ( } - tooltip={this.props.context.t('events')} + // tooltip={this.props.context.t('events')} >
= { verticalAlign: 'middle', }, button: { - marginRight: 5, width: 36, height: 36, }, @@ -188,6 +190,7 @@ interface ConfigState { adapterDocLangs?: ioBroker.Languages[]; extension?: boolean | null; showLogLevelDialog: boolean; + showMore: HTMLButtonElement | null; } class Config extends Component { @@ -211,6 +214,7 @@ class Config extends Component { logLevelValue: 'info', tempLogLevel: 'info', showLogLevelDialog: false, + showMore: null, }; this.refIframe = null; @@ -425,34 +429,23 @@ class Config extends Component { renderHelpButton(): JSX.Element | null { if (this.props.jsonConfig) { return ( -
- { + const lang = this.state.adapterDocLangs?.includes(this.props.lang) ? this.props.lang : 'en'; + window.open( + AdminUtils.getDocsLinkForAdapter({ lang, adapterName: this.props.adapter }), + 'help', + ); + }} > - { - const lang = this.state.adapterDocLangs?.includes(this.props.lang) - ? this.props.lang - : 'en'; - window.open( - AdminUtils.getDocsLinkForAdapter({ lang, adapterName: this.props.adapter }), - 'help', - ); - }} - > - - - -
+ + +
); } return null; @@ -605,6 +598,124 @@ class Config extends Component { } render(): JSX.Element { + const buttons = [ + this.props.version ? ( + + v{this.props.version} + + ) : null, + + + { + event.stopPropagation(); + event.preventDefault(); + if ( + this.state.running && + `${this.props.adapter}.${this.props.instance}` === this.props.adminInstance + ) { + this.setState({ showStopAdminDialog: true, showMore: null }); + } else { + this.setState({ showMore: null }); + this.props.socket + .extendObject(`system.adapter.${this.props.adapter}.${this.props.instance}`, { + common: { enabled: !this.state.running }, + }) + .catch(error => window.alert(`Cannot set log level: ${error}`)); + } + }} + onFocus={event => event.stopPropagation()} + sx={{ + ...styles.buttonControl, + ...(this.state.canStart + ? this.state.running + ? styles.enabled + : styles.disabled + : styles.hide), + }} + > + {this.state.running ? : } + + + , + + + { + this.setState({ showMore: null }); + event.stopPropagation(); + this.props.socket + .extendObject(`system.adapter.${this.props.adapter}.${this.props.instance}`, {}) + .catch(error => window.alert(`Cannot set log level: ${error}`)); + }} + onFocus={event => event.stopPropagation()} + style={{ + ...styles.buttonControl, + ...(!this.state.canStart ? styles.hide : undefined), + }} + disabled={!this.state.running} + > + + + + , + this.state.tempLogLevel !== this.state.logLevel ? ( + + {this.state.tempLogLevel} + + ) : null, + + + {this.state.tempLogLevel !== this.state.logLevel ? `/ ${this.state.logLevel}` : this.state.logLevel} + + , + + { + event.stopPropagation(); + this.setState({ showLogLevelDialog: true, showMore: null }); + }} + onFocus={event => event.stopPropagation()} + style={{ + ...styles.buttonControl, + ...(!this.state.canStart ? styles.hide : undefined), + width: 34, + height: 34, + }} + > + + + , + ]; + return ( { > { style={styles.instanceIcon} /> ) : null} - {`${this.props.t('Instance settings')}: ${this.props.adapter}.${this.props.instance}`} - {this.props.version ? ( - - v{this.props.version} - - ) : null} - - - { - event.stopPropagation(); - event.preventDefault(); - if ( - this.state.running && - `${this.props.adapter}.${this.props.instance}` === - this.props.adminInstance - ) { - this.setState({ showStopAdminDialog: true }); - } else { - this.props.socket - .extendObject( - `system.adapter.${this.props.adapter}.${this.props.instance}`, - { common: { enabled: !this.state.running } }, - ) - .catch(error => window.alert(`Cannot set log level: ${error}`)); - } - }} - onFocus={event => event.stopPropagation()} - sx={{ - ...styles.buttonControl, - ...(this.state.canStart - ? this.state.running - ? styles.enabled - : styles.disabled - : styles.hide), - }} - > - {this.state.running ? : } - - - - ({ + [theme.breakpoints.down('md')]: { + display: 'none', + }, + marginRight: '8px', + })} > - - { - event.stopPropagation(); - this.props.socket - .extendObject( - `system.adapter.${this.props.adapter}.${this.props.instance}`, - {}, - ) - .catch(error => window.alert(`Cannot set log level: ${error}`)); - }} - onFocus={event => event.stopPropagation()} - style={{ - ...styles.buttonControl, - ...(!this.state.canStart ? styles.hide : undefined), - }} - disabled={!this.state.running} - > - - - - - {this.state.tempLogLevel !== this.state.logLevel ? ( - - {this.state.tempLogLevel} - - ) : null} - + {`${this.props.adapter}.${this.props.instance}`} + ({ + [theme.breakpoints.down('sm')]: { + display: 'none', + }, + })} > - - {this.state.tempLogLevel !== this.state.logLevel - ? `/ ${this.state.logLevel}` - : this.state.logLevel} - - - + ({ + [theme.breakpoints.up('sm')]: { + display: 'none', + }, + })} > { - event.stopPropagation(); - this.setState({ showLogLevelDialog: true }); - }} - onFocus={event => event.stopPropagation()} - style={{ - ...styles.buttonControl, - ...(!this.state.canStart ? styles.hide : undefined), - width: 34, - height: 34, - }} + style={{ marginLeft: 8 }} + onClick={e => this.setState({ showMore: e.currentTarget })} > - + - + {this.state.showMore ? ( + this.setState({ showMore: null })} + > + {buttons} + + ) : null} + {/* @@ -785,6 +825,7 @@ class Config extends Component { */} +
{this.renderHelpButton()} diff --git a/packages/dm-gui-components/src/DeviceStatus.tsx b/packages/dm-gui-components/src/DeviceStatus.tsx index a4afa578c..79fc08858 100644 --- a/packages/dm-gui-components/src/DeviceStatus.tsx +++ b/packages/dm-gui-components/src/DeviceStatus.tsx @@ -23,7 +23,7 @@ import { } from '@mui/icons-material'; import type { DeviceStatus, DeviceAction, ActionBase, ConfigConnectionType } from '@iobroker/dm-utils'; -import { type IobTheme } from '@iobroker/adapter-react-v5'; +import { type IobTheme, ThemeType } from '@iobroker/adapter-react-v5'; import { getTranslation } from './Utils'; import Switch from './Switch'; @@ -132,6 +132,20 @@ interface DeviceStatusProps { theme: IobTheme; } +function rssiColor(signal: number, themeType: ThemeType): string { + if (signal < -80) { + return themeType === 'dark' ? '#ff5c5c' : '#aa0000'; + } + if (signal < -60) { + return themeType === 'dark' ? '#fa8547' : '#ae5c00'; + } + if (signal < -50) { + return themeType === 'dark' ? '#cdff4f' : '#7b9500'; + } + + return themeType === 'dark' ? '#5cff5c' : '#008500'; +} + /** * Device Status component * @@ -335,7 +349,7 @@ export default function DeviceStatus(props: DeviceStatusProps): React.JSX.Elemen slotProps={{ popper: { sx: styles.tooltip } }} >
- +

{status.rssi}

diff --git a/packages/jsonConfig/src/JsonConfigComponent/ConfigTable.tsx b/packages/jsonConfig/src/JsonConfigComponent/ConfigTable.tsx index 749cf8304..85f001751 100644 --- a/packages/jsonConfig/src/JsonConfigComponent/ConfigTable.tsx +++ b/packages/jsonConfig/src/JsonConfigComponent/ConfigTable.tsx @@ -2,11 +2,16 @@ import React, { createRef, type JSX, type RefObject } from 'react'; import Dropzone from 'react-dropzone'; import { + Accordion, + AccordionDetails, + AccordionSummary, Button, + Card, Dialog, DialogActions, DialogContent, DialogTitle, + Grid2, IconButton, InputAdornment, Paper, @@ -37,6 +42,7 @@ import { Warning as ErrorIcon, UploadFile as ImportIcon, Close as IconClose, + ExpandMore as ExpandMoreIcon, } from '@mui/icons-material'; import { I18n } from '@iobroker/adapter-react-v5'; @@ -253,6 +259,7 @@ interface ConfigTableState extends ConfigGenericState { customObj: Record; uploadFile: boolean | 'dragging'; icon: boolean; + width: number; } function encrypt(secret: string, value: string): string { @@ -262,6 +269,7 @@ function encrypt(secret: string, value: string): string { } return result; } + function decrypt(secret: string, value: string): string { let result = ''; for (let i = 0; i < value.length; i++) { @@ -275,8 +283,12 @@ class ConfigTable extends ConfigGeneric { private typingTimer: ReturnType | null = null; + private resizeTimeout: ReturnType | null = null; + private secret: string = 'Zgfr56gFe87jJOM'; + private readonly refDiv: React.RefObject; + constructor(props: ConfigTableProps) { super(props); this.filterRefs = {}; @@ -286,6 +298,8 @@ class ConfigTable extends ConfigGeneric { this.filterRefs[el.attr] = createRef(); } }); + + this.refDiv = React.createRef(); } /** @@ -333,6 +347,7 @@ class ConfigTable extends ConfigGeneric { order: 'asc', iteration: 0, filterOn: [], + width: 0, }, () => this.validateUniqueProps(), ); @@ -343,6 +358,10 @@ class ConfigTable extends ConfigGeneric { clearTimeout(this.typingTimer); this.typingTimer = null; } + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = null; + } super.componentWillUnmount(); } @@ -448,14 +467,15 @@ class ConfigTable extends ConfigGeneric { handleRequestSort = (property: string, orderCheck: boolean = false): void => { const { order, orderBy } = this.state; - if (orderBy) { - const isAsc = orderBy === property && order === 'asc'; - const newOrder = orderCheck ? order : isAsc ? 'desc' : 'asc'; - const newValue = this.stableSort(newOrder, property); - this.setState({ order: newOrder, orderBy: property, iteration: this.state.iteration + 10000 }, () => - this.applyFilter(false, newValue), - ); - } + //if (orderBy || 'asc') { + const isAsc = orderBy === property && order === 'asc'; + const newOrder = orderCheck ? order : isAsc ? 'desc' : 'asc'; + const newValue = this.stableSort(newOrder, property); + this.setState( + { value: newValue, order: newOrder, orderBy: property, iteration: this.state.iteration + 10000 }, + () => this.applyFilter(false, newValue), + ); + //} }; stableSort = (order: 'desc' | 'asc', orderBy: string): Record[] => { @@ -474,127 +494,112 @@ class ConfigTable extends ConfigGeneric { return stabilizedThis.map(el => el.el); }; + renderShowHideFilter(headCell: ConfigItemTableIndexed): React.JSX.Element | null { + if (!headCell.filter) { + return null; + } + return ( + { + const filterOn = [...this.state.filterOn]; + const pos = this.state.filterOn.indexOf(headCell.attr); + if (pos === -1) { + filterOn.push(headCell.attr); + } else { + filterOn.splice(pos, 1); + } + this.setState({ filterOn }, () => { + if (pos && ConfigTable.getFilterValue(this.filterRefs[headCell.attr])) { + ConfigTable.setFilterValue(this.filterRefs[headCell.attr], ''); + this.applyFilter(); + } + }); + }} + > + {this.state.filterOn.includes(headCell.attr) ? : } + + ); + } + + renderImportExportButtons(schema: ConfigItemTable): React.JSX.Element { + return ( + <> + {!schema.noDelete && schema.import ? ( + + this.setState({ showImportDialog: true })} + > + + + + ) : null} + {schema.export ? ( + + this.onExport()} + > + + + + ) : null} + + + + + ); + } + + renderAddButton(doAnyFilterSet: boolean): React.JSX.Element { + return ( + + + + + + + + ); + } + enhancedTableHead(buttonsWidth: number, doAnyFilterSet: boolean): JSX.Element { const { schema } = this.props; const { order, orderBy } = this.state; return ( - {schema.items && - schema.items.map((headCell: ConfigItemTableIndexed, i: number) => ( - -
- {!i && !schema.noDelete ? ( - - - - - - - - ) : null} - {headCell.sort && ( - this.handleRequestSort(headCell.attr)} - /> - )} - {headCell.filter && this.state.filterOn.includes(headCell.attr) ? ( - this.applyFilter()} - title={I18n.t('ra_You can filter entries by entering here some text')} - slotProps={{ - input: { - endAdornment: ConfigTable.getFilterValue( - this.filterRefs[headCell.attr], - ) && ( - - { - ConfigTable.setFilterValue( - this.filterRefs[headCell.attr], - '', - ); - this.applyFilter(); - }} - > - - - - ), - }, - }} - fullWidth - placeholder={this.getText(headCell.title)} - /> - ) : ( - {this.getText(headCell.title)} - )} - {headCell.filter ? ( - { - const filterOn = [...this.state.filterOn]; - const pos = this.state.filterOn.indexOf(headCell.attr); - if (pos === -1) { - filterOn.push(headCell.attr); - } else { - filterOn.splice(pos, 1); - } - this.setState({ filterOn }, () => { - if ( - pos && - ConfigTable.getFilterValue(this.filterRefs[headCell.attr]) - ) { - ConfigTable.setFilterValue(this.filterRefs[headCell.attr], ''); - this.applyFilter(); - } - }); - }} - > - {this.state.filterOn.includes(headCell.attr) ? ( - - ) : ( - - )} - - ) : null} -
-
- ))} + {schema.items?.map((headCell: ConfigItemTableIndexed, i: number) => + this.renderOneFilter({ + schema, + style: { width: headCell.width }, + showAddButton: !i && !schema.noDelete, + headCell, + order, + orderBy, + index: i, + doAnyFilterSet, + }), + )} {!schema.noDelete && ( { }} padding="checkbox" > - {schema.import ? ( - this.setState({ showImportDialog: true })} - title={I18n.t('ra_import data from %s file', 'CSV')} - > - - - ) : null} - {schema.export ? ( - this.onExport()} - title={I18n.t('ra_Export data to %s file', 'CSV')} - > - - - ) : null} - - - + {this.renderImportExportButtons(schema)} )}
@@ -1088,13 +1068,309 @@ class ConfigTable extends ConfigGeneric { ); } - renderItem(/* error, disabled, defaultValue */): JSX.Element | null { + renderOneFilter(props: { + schema: ConfigItemTable; + doAnyFilterSet: boolean; + headCell: ConfigItemTableIndexed; + index: number; + orderBy: string; + order: 'asc' | 'desc'; + showAddButton: boolean; + style: React.CSSProperties; + }): React.JSX.Element { + return ( + +
+ {props.showAddButton ? this.renderAddButton(props.doAnyFilterSet) : null} + {props.headCell.sort && ( + this.handleRequestSort(props.headCell.attr)} + /> + )} + {props.headCell.filter && this.state.filterOn.includes(props.headCell.attr) ? ( + this.applyFilter()} + title={I18n.t('ra_You can filter entries by entering here some text')} + slotProps={{ + input: { + endAdornment: ConfigTable.getFilterValue(this.filterRefs[props.headCell.attr]) && ( + + { + ConfigTable.setFilterValue( + this.filterRefs[props.headCell.attr], + '', + ); + this.applyFilter(); + }} + > + + + + ), + }, + }} + fullWidth + placeholder={this.getText(props.headCell.title)} + /> + ) : ( + {this.getText(props.headCell.title)} + )} + {this.renderShowHideFilter(props.headCell)} +
+
+ ); + } + + enhancedFilterCard(): JSX.Element { const { schema } = this.props; - let { visibleValue } = this.state; + const { order, orderBy } = this.state; + let tdStyle: React.CSSProperties | undefined; + if (this.props.schema.compact) { + tdStyle = { paddingTop: 1, paddingBottom: 1 }; + } - if (!this.state.value || !Array.isArray(this.state.value)) { - return null; + return ( + + + + + }> + {I18n.t('ra_Filter and Data Actions')} + + + + + {schema.items?.map( + (headCell: ConfigItemTableIndexed, i: number): React.JSX.Element => ( + + {this.renderOneFilter({ + schema, + style: tdStyle, + showAddButton: false, + headCell, + order, + orderBy, + index: i, + doAnyFilterSet: false, + })} + + ), + )} + + + {I18n.t('ra_Actions')} + + + {this.renderImportExportButtons(schema)} + + + +
+
+
+
+
+
+ ); + } + + enhancedBottomCard(): JSX.Element { + const { schema } = this.props; + let tdStyle: React.CSSProperties | undefined; + if (this.props.schema.compact) { + tdStyle = { paddingTop: 1, paddingBottom: 1 }; + } + const doAnyFilterSet = this.isAnyFilterSet(); + return ( + + + + + + + + {this.renderAddButton(doAnyFilterSet)} + + + +
+
+
+
+ ); + } + + renderCards(): JSX.Element | null { + const { schema } = this.props; + let { visibleValue } = this.state; + let tdStyle: React.CSSProperties | undefined; + if (this.props.schema.compact) { + tdStyle = { paddingTop: 1, paddingBottom: 1 }; } + visibleValue = visibleValue || this.state.value.map((_, i) => i); + + const doAnyFilterSet = this.isAnyFilterSet(); + + return ( + + {this.showImportDialog()} + {this.showTypeOfImportDialog()} + {this.enhancedFilterCard()} + {visibleValue.map((idx, i) => ( + + + + + + {schema.items?.map((headCell: ConfigItemTableIndexed) => ( + + + + {this.getText(headCell.title)} + + + + {this.itemTable(headCell.attr, this.state.value[idx], idx)} + + + ))} + + + {this.getText('Actions')} + + + {!doAnyFilterSet && !this.state.orderBy ? ( + + + this.onMoveUp(idx)} + disabled={i === 0} + > + + + + + ) : null} + {!doAnyFilterSet && !this.state.orderBy ? ( + + + this.onMoveDown(idx)} + disabled={i === visibleValue.length - 1} + > + + + + + ) : null} + + + + + + {this.props.schema.clone ? ( + + + + + + ) : null} + + + +
+
+
+
+ ))} + {this.enhancedBottomCard()} +
+ ); + } + + renderTable(): JSX.Element | null { + const { schema } = this.props; + let { visibleValue } = this.state; visibleValue = visibleValue || this.state.value.map((_, i) => i); @@ -1138,16 +1414,15 @@ class ConfigTable extends ConfigGeneric { hover key={`${idx}_${i}`} > - {schema.items && - schema.items.map((headCell: ConfigItemTableIndexed) => ( - - {this.itemTable(headCell.attr, this.state.value[idx], idx)} - - ))} + {schema.items?.map((headCell: ConfigItemTableIndexed) => ( + + {this.itemTable(headCell.attr, this.state.value[idx], idx)} + + ))} {!schema.noDelete && ( { colSpan={schema.items.length + 1} style={{ ...tdStyle }} > - - - - - - - + {this.renderAddButton(doAnyFilterSet)} ) : null} @@ -1282,6 +1539,63 @@ class ConfigTable extends ConfigGeneric { ); } + + componentDidUpdate(): void { + if (this.refDiv.current?.clientWidth && this.refDiv.current.clientWidth !== this.state.width) { + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + } + this.resizeTimeout = setTimeout(() => { + this.resizeTimeout = null; + this.setState({ width: this.refDiv.current?.clientWidth }); + }, 50); + } + } + + getCurrentBreakpoint(): 'xs' | 'sm' | 'md' | 'lg' | 'xl' { + if (!this.state.width) { + return 'md'; + } + if (this.state.width < 600) { + return 'xs'; + } + if (this.state.width < 900) { + return 'sm'; + } + if (this.state.width < 1200) { + return 'md'; + } + if (this.state.width < 1536) { + return 'lg'; + } + return 'xl'; + } + + renderItem(/* error, disabled, defaultValue */): JSX.Element | null { + const { schema } = this.props; + + if (!this.state.value || !Array.isArray(this.state.value)) { + return null; + } + + const currentBreakpoint = this.getCurrentBreakpoint(); + let content: React.JSX.Element; + + if (currentBreakpoint && (schema.useCardFor || ['xs']).includes(currentBreakpoint)) { + content = this.renderCards(); + } else { + content = this.renderTable(); + } + + return ( +
+ {content} +
+ ); + } } export default ConfigTable; diff --git a/packages/jsonConfig/src/JsonConfigComponent/ConfigTabs.tsx b/packages/jsonConfig/src/JsonConfigComponent/ConfigTabs.tsx index 0daafd2ce..f0d898ea0 100644 --- a/packages/jsonConfig/src/JsonConfigComponent/ConfigTabs.tsx +++ b/packages/jsonConfig/src/JsonConfigComponent/ConfigTabs.tsx @@ -1,6 +1,7 @@ import React, { type JSX } from 'react'; -import { Tabs, Tab } from '@mui/material'; +import { Tabs, Tab, IconButton, Toolbar, Menu, MenuItem, ListItemIcon } from '@mui/material'; +import { Menu as MenuIcon } from '@mui/icons-material'; import type { ConfigItemTabs } from '#JC/types'; import ConfigGeneric, { type ConfigGenericProps, type ConfigGenericState } from './ConfigGeneric'; @@ -30,9 +31,15 @@ interface ConfigTabsProps extends ConfigGenericProps { interface ConfigTabsState extends ConfigGenericState { tab?: string; + width: number; + openMenu: HTMLButtonElement | null; } class ConfigTabs extends ConfigGeneric { + private resizeTimeout: ReturnType | null = null; + + private readonly refDiv: React.RefObject; + constructor(props: ConfigTabsProps) { super(props); let tab: string | undefined; @@ -66,11 +73,16 @@ class ConfigTabs extends ConfigGeneric { tab = Object.keys(this.props.schema.items)[0]; } } + this.refDiv = React.createRef(); - Object.assign(this.state, { tab }); + Object.assign(this.state, { tab, width: 0, openMenu: null }); } componentWillUnmount(): void { + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = null; + } window.removeEventListener('hashchange', this.onHashTabsChanged, false); super.componentWillUnmount(); } @@ -98,91 +110,180 @@ class ConfigTabs extends ConfigGeneric { } }; + getCurrentBreakpoint(): 'xs' | 'sm' | 'md' | 'lg' | 'xl' { + if (!this.state.width) { + return 'md'; + } + if (this.state.width < 600) { + return 'xs'; + } + if (this.state.width < 900) { + return 'sm'; + } + if (this.state.width < 1200) { + return 'md'; + } + if (this.state.width < 1536) { + return 'lg'; + } + return 'xl'; + } + + componentDidUpdate(): void { + if (this.refDiv.current?.clientWidth && this.refDiv.current.clientWidth !== this.state.width) { + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + } + this.resizeTimeout = setTimeout(() => { + this.resizeTimeout = null; + this.setState({ width: this.refDiv.current?.clientWidth }); + }, 50); + } + } + + onMenuChange(tab: string): void { + (((window as any)._localStorage as Storage) || window.localStorage).setItem( + `${this.props.dialogName || 'App'}.${this.props.oContext.adapterName}`, + tab, + ); + this.setState({ tab }, () => { + if (this.props.root) { + const hash = (window.location.hash || '').split('/'); + if (hash.length >= 3 && hash[1] === 'config') { + hash[3] = this.state.tab; + window.location.hash = hash.join('/'); + } + } + }); + } + render(): JSX.Element { const items = this.props.schema.items; let withIcons = false; + const elements: { icon: React.JSX.Element | null; label: string; name: string; disabled: boolean }[] = []; - return ( -
+ Object.keys(items).map(name => { + let disabled: boolean; + if (this.props.custom) { + const hidden = this.executeCustom( + items[name].hidden, + this.props.data, + this.props.customObj, + this.props.oContext.instanceObj, + this.props.index, + this.props.globalData, + ); + if (hidden) { + return; + } + disabled = this.executeCustom( + items[name].disabled, + this.props.data, + this.props.customObj, + this.props.oContext.instanceObj, + this.props.index, + this.props.globalData, + ) as boolean; + } else { + const hidden: boolean = this.execute( + items[name].hidden, + false, + this.props.data, + this.props.index, + this.props.globalData, + ) as boolean; + if (hidden) { + return; + } + disabled = this.execute( + items[name].disabled, + false, + this.props.data, + this.props.index, + this.props.globalData, + ) as boolean; + } + const icon = this.getIcon(items[name].icon); + withIcons = withIcons || !!icon; + elements.push({ icon, disabled, label: this.getText(items[name].label), name }); + }); + + const currentBreakpoint = this.getCurrentBreakpoint(); + let tabs: React.JSX.Element; + if (currentBreakpoint === 'xs' || currentBreakpoint === 'sm') { + tabs = ( + + ) => + this.setState({ openMenu: event.currentTarget }) + } + > + + + {this.state.openMenu ? ( + this.setState({ openMenu: null })} + > + {elements.map(el => { + return ( + { + this.setState({ openMenu: null }, () => this.onMenuChange(el.name)); + }} + selected={el.name === this.state.tab} + > + {withIcons ? {el.icon} : null} + {el.label} + + ); + })} + + ) : null} + + ); + } else { + tabs = ( { - (((window as any)._localStorage as Storage) || window.localStorage).setItem( - `${this.props.dialogName || 'App'}.${this.props.oContext.adapterName}`, - tab, - ); - this.setState({ tab }, () => { - if (this.props.root) { - const hash = (window.location.hash || '').split('/'); - if (hash.length >= 3 && hash[1] === 'config') { - hash[3] = this.state.tab; - window.location.hash = hash.join('/'); - } - } - }); - }} + onChange={(_e, tab: string): void => this.onMenuChange(tab)} > - {Object.keys(items).map(name => { - let disabled: boolean; - if (this.props.custom) { - const hidden = this.executeCustom( - items[name].hidden, - this.props.data, - this.props.customObj, - this.props.oContext.instanceObj, - this.props.index, - this.props.globalData, - ); - if (hidden) { - return null; - } - disabled = this.executeCustom( - items[name].disabled, - this.props.data, - this.props.customObj, - this.props.oContext.instanceObj, - this.props.index, - this.props.globalData, - ) as boolean; - } else { - const hidden: boolean = this.execute( - items[name].hidden, - false, - this.props.data, - this.props.index, - this.props.globalData, - ) as boolean; - if (hidden) { - return null; - } - disabled = this.execute( - items[name].disabled, - false, - this.props.data, - this.props.index, - this.props.globalData, - ) as boolean; - } - const icon = this.getIcon(items[name].icon); - withIcons = withIcons || !!icon; - + {elements.map(el => { return ( ); })} + ); + } + + return ( +
+ {tabs} ; adapterName: string; + dateFormat: string; + forceUpdate: (attr: string | string[], data: any) => void; + instance: number; + isFloatComma: boolean; + socket: AdminConnection; + systemConfig: ioBroker.SystemConfigCommon; + theme: IobTheme; + themeType: ThemeType; + _themeName: ThemeName; + + DeviceManager?: React.FC; changeLanguage?: () => void; customs?: Record; - dateFormat: string; embedded?: boolean; expertMode?: boolean; - forceUpdate: (attr: string | string[], data: any) => void; imagePrefix?: string; - instance: number; instanceObj?: ioBroker.InstanceObject; - isFloatComma: boolean; /** If true, this field edits multiple data points at once and thus contains an array, should not be saved if not changed */ multiEdit?: boolean; /** Backend request to refresh data */ @@ -1025,11 +1034,6 @@ export type JsonConfigContext = { onCommandRunning: (commandRunning: boolean) => void; onValueChange?: (attr: string, value: any, saveConfig: boolean) => void; registerOnForceUpdate?: (attr: string, cb?: (data: any) => void) => void; - socket: AdminConnection; - systemConfig: ioBroker.SystemConfigCommon; - theme: IobTheme; - themeType: ThemeType; - _themeName: ThemeName; }; // Notification GUI