diff --git a/.gitignore b/.gitignore index c6cb358e8..e3283c440 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ words-found-verbose.txt # sonarlint compile_commands.json +package.json diff --git a/CHANGELOG_LATEST.md b/CHANGELOG_LATEST.md index 02dc06d9b..04a924280 100644 --- a/CHANGELOG_LATEST.md +++ b/CHANGELOG_LATEST.md @@ -36,13 +36,14 @@ For more details go to [www.emsesp.org](https://www.emsesp.org/). - RT800 remote emulation [#1867](https://github.com/emsesp/EMS-ESP32/issues/1867) - RC310 cooling parameters [#1857](https://github.com/emsesp/EMS-ESP32/issues/1857) - command `api/device/entities` [#1897](https://github.com/emsesp/EMS-ESP32/issues/1897) -- switchprogmode [#1903] +- switchprogmode [#1903](https://github.com/emsesp/EMS-ESP32/discussions/1903) - autodetect and download firmware upgrades via the WebUI - command 'show log' that lists out the current weblog buffer, showing last messages. - default web log buffer to 25 lines for ESP32s with no PSRAM - try and determine correct board profile if none is set during boot - auto Scroll in WebLog UI - reduced delay so incoming logs are faster -- uploading custom support info for Guest users [#2054] +- uploading custom support info, shown to Guest users in Help page [#2054](https://github.com/emsesp/EMS-ESP32/issues/2054) +- feature: Dashboard showing all data (favorites, sensors, custom) [#1958](https://github.com/emsesp/EMS-ESP32/issues/1958) ## Fixed @@ -69,7 +70,7 @@ For more details go to [www.emsesp.org](https://www.emsesp.org/). - Change key-names in JSON to be compliant and consistent [#1860](https://github.com/emsesp/EMS-ESP32/issues/1860) - Updates to webUI [#1920](https://github.com/emsesp/EMS-ESP32/issues/1920) - Correct firmware naming #1933 [#1933](https://github.com/emsesp/EMS-ESP32/issues/1933) -- Don't start Serial console if not connected to a Serial port. Will initiate manually after a CTRL-C +- Don't start Serial console if not connected to a Serial port. Will initiate manually after a CTRL-C/CTRL-S - WebLog UI matches color schema of the terminal console correctly - Updated Web libraries, ArduinoJson - Help page doesn't show detailed tech info if the user is not 'admin' role [#2054](https://github.com/emsesp/EMS-ESP32/issues/2054) diff --git a/interface/eslint.config.js b/interface/eslint.config.js index 281c28372..c53ece12c 100644 --- a/interface/eslint.config.js +++ b/interface/eslint.config.js @@ -16,7 +16,15 @@ export default tseslint.config( } }, { - ignores: ['dist/*', 'build/*', '*.js', '**/*.cjs', '**/unpack.ts', 'i18n*.*'] + ignores: [ + 'dist/*', + '*.mjs', + 'build/*', + '*.js', + '**/*.cjs', + '**/unpack.ts', + 'i18n*.*' + ] }, { rules: { diff --git a/interface/package.json b/interface/package.json index 01082ee23..d2cfa7ac4 100644 --- a/interface/package.json +++ b/interface/package.json @@ -21,13 +21,13 @@ "lint": "eslint . --fix" }, "dependencies": { - "@alova/adapter-xhr": "2.0.7", + "@alova/adapter-xhr": "2.0.8", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", - "@mui/icons-material": "^6.1.2", - "@mui/material": "^6.1.2", + "@mui/icons-material": "^6.1.3", + "@mui/material": "^6.1.3", "@table-library/react-table-library": "4.1.7", - "alova": "3.0.17", + "alova": "3.1.0", "async-validator": "^4.2.5", "jwt-decode": "^4.0.0", "mime-types": "^2.1.35", @@ -35,21 +35,21 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", - "react-router-dom": "^6.26.2", - "react-toastify": "^10.0.5", + "react-router-dom": "^6.27.0", + "react-toastify": "^10.0.6", "typesafe-i18n": "^5.26.2", - "typescript": "^5.6.2" + "typescript": "^5.6.3" }, "devDependencies": { - "@babel/core": "^7.25.7", + "@babel/core": "^7.25.8", "@eslint/js": "^9.12.0", "@preact/compat": "^18.3.1", "@preact/preset-vite": "^2.9.1", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/formidable": "^3", - "@types/node": "^22.7.4", + "@types/node": "^22.7.5", "@types/react": "^18.3.11", - "@types/react-dom": "^18.3.0", + "@types/react-dom": "^18.3.1", "@types/react-router-dom": "^5.3.3", "concurrently": "^9.0.1", "eslint": "^9.12.0", @@ -58,7 +58,7 @@ "prettier": "^3.3.3", "rollup-plugin-visualizer": "^5.12.0", "terser": "^5.34.1", - "typescript-eslint": "8.8.0", + "typescript-eslint": "8.8.1", "vite": "^5.4.8", "vite-plugin-imagemin": "^0.6.1", "vite-tsconfig-paths": "^5.0.1" diff --git a/interface/src/App.tsx b/interface/src/App.tsx index f6c9d49be..f9f3a9a91 100644 --- a/interface/src/App.tsx +++ b/interface/src/App.tsx @@ -25,7 +25,7 @@ const App = () => { { return ( + } /> } /> } /> } /> diff --git a/interface/src/api/app.ts b/interface/src/api/app.ts index a856d2096..dae9038f1 100644 --- a/interface/src/api/app.ts +++ b/interface/src/api/app.ts @@ -5,6 +5,7 @@ import type { Action, Activity, CoreData, + DashboardItem, DeviceData, DeviceEntity, Entities, @@ -19,7 +20,13 @@ import type { WriteTemperatureSensor } from '../app/main/types'; -// DashboardDevices +// Dashboard +export const readDashboard = () => + alovaInstance.Get('/rest/dashboardData', { + responseType: 'arraybuffer' // uses msgpack + }); + +// Devices export const readCoreData = () => alovaInstance.Get(`/rest/coreData`); export const readDeviceData = (id: number) => alovaInstance.Get('/rest/deviceData', { diff --git a/interface/src/app/main/CustomEntities.tsx b/interface/src/app/main/CustomEntities.tsx index 31a0811d7..68fac89d2 100644 --- a/interface/src/app/main/CustomEntities.tsx +++ b/interface/src/app/main/CustomEntities.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useBlocker } from 'react-router-dom'; import { toast } from 'react-toastify'; @@ -27,6 +27,7 @@ import { useLayoutTitle } from 'components'; import { useI18nContext } from 'i18n/i18n-react'; +import { useInterval } from 'utils'; import { readCustomEntities, writeCustomEntities } from '../../api/app'; import SettingsCustomEntitiesDialog from './CustomEntitiesDialog'; @@ -52,17 +53,11 @@ const CustomEntities = () => { initialData: [] }); - useEffect(() => { - const timer = setInterval(async () => { - if (dialogOpen || numChanges > 0) { - return; - } - await fetchEntities(); - }, 2000); - return () => { - clearInterval(timer); - }; - }); + useInterval(() => { + if (!dialogOpen && !numChanges) { + void fetchEntities(); + } + }, 3000); const { send: writeEntities } = useRequest( (data: Entities) => writeCustomEntities(data), @@ -295,7 +290,7 @@ const CustomEntities = () => { {blocker ? : null} - {LL.ENTITIES_HELP_1()} + {LL.ENTITIES_HELP_1()} {renderEntity()} diff --git a/interface/src/app/main/Customizations.tsx b/interface/src/app/main/Customizations.tsx index 653c28e42..72babe728 100644 --- a/interface/src/app/main/Customizations.tsx +++ b/interface/src/app/main/Customizations.tsx @@ -427,7 +427,7 @@ const Customizations = () => { const renderDeviceList = () => ( <> - {LL.CUSTOMIZATIONS_HELP_1()}. + {LL.CUSTOMIZATIONS_HELP_1()}. {rename ? ( diff --git a/interface/src/app/main/Dashboard.tsx b/interface/src/app/main/Dashboard.tsx new file mode 100644 index 000000000..dda4a907f --- /dev/null +++ b/interface/src/app/main/Dashboard.tsx @@ -0,0 +1,362 @@ +import { useContext, useEffect, useState } from 'react'; +import { IconContext } from 'react-icons/lib'; +import { toast } from 'react-toastify'; + +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import EditIcon from '@mui/icons-material/Edit'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import UnfoldLessIcon from '@mui/icons-material/UnfoldLess'; +import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; +import { + Box, + IconButton, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography +} from '@mui/material'; +import Grid from '@mui/material/Grid2'; + +import { Body, Cell, Row, Table } from '@table-library/react-table-library/table'; +import { useTheme } from '@table-library/react-table-library/theme'; +import { CellTree, useTree } from '@table-library/react-table-library/tree'; +import { useRequest } from 'alova/client'; +import { FormLoader, SectionContent, useLayoutTitle } from 'components'; +import { AuthenticatedContext } from 'contexts/authentication'; +import { useI18nContext } from 'i18n/i18n-react'; +import { useInterval } from 'utils'; + +import { readDashboard, writeDeviceValue } from '../../api/app'; +import DeviceIcon from './DeviceIcon'; +import DevicesDialog from './DevicesDialog'; +import { formatValue } from './deviceValue'; +import { + type DashboardItem, + DeviceEntityMask, + DeviceType, + type DeviceValue +} from './types'; +import { deviceValueItemValidation } from './validators'; + +const Dashboard = () => { + const { LL } = useI18nContext(); + const { me } = useContext(AuthenticatedContext); + + useLayoutTitle(LL.DASHBOARD()); + + const [firstLoad, setFirstLoad] = useState(true); + const [showAll, setShowAll] = useState(true); + + const [deviceValueDialogOpen, setDeviceValueDialogOpen] = useState(false); + + const [selectedDashboardItem, setSelectedDashboardItem] = + useState(); + + const { + data, + send: fetchDashboard, + error, + loading + } = useRequest(readDashboard, { + initialData: [] + }); + + const { loading: submitting, send: sendDeviceValue } = useRequest( + (data: { id: number; c: string; v: unknown }) => writeDeviceValue(data), + { + immediate: false + } + ); + + const deviceValueDialogSave = async (devicevalue: DeviceValue) => { + if (!selectedDashboardItem) { + return; + } + const id = selectedDashboardItem.parentNode.id; // this is the parent ID + await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v }) + .then(() => { + toast.success(LL.WRITE_CMD_SENT()); + }) + .catch((error: Error) => { + toast.error(error.message); + }) + .finally(() => { + setDeviceValueDialogOpen(false); + setSelectedDashboardItem(undefined); + }); + }; + + const dashboard_theme = useTheme({ + Table: ` + --data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px; + `, + BaseRow: ` + font-size: 14px; + .td { + height: 28px; + } + `, + Row: ` + cursor: pointer; + background-color: #1e1e1e; + &:hover .td { + background-color: #177ac9; + } + `, + BaseCell: ` + &:nth-of-type(2) { + text-align: right; + } + &:nth-of-type(3) { + text-align: right; + } + ` + }); + + const tree = useTree( + { nodes: data }, + { + onChange: undefined // not used but needed + }, + { + treeIcon: { + margin: '4px', + iconDefault: null, + iconRight: ( + + ), + iconDown: ( + + ) + }, + indentation: 45 + } + ); + + useInterval(() => { + if (!deviceValueDialogOpen) { + void fetchDashboard(); + } + }, 3000); + + // auto expand on first load + useEffect(() => { + if (firstLoad && Array.isArray(data) && data.length && !tree.state.ids.length) { + tree.fns.onToggleAll({}); + setFirstLoad(false); + } + }, [loading]); + + const showType = (n?: string, t?: number) => { + // if we have a name show it + if (n) { + return n; + } + if (t) { + // otherwise pick translation based on type + switch (t) { + case DeviceType.CUSTOM: + return LL.CUSTOM_ENTITIES(0); + case DeviceType.ANALOGSENSOR: + return LL.ANALOG_SENSORS(); + case DeviceType.TEMPERATURESENSOR: + return LL.TEMP_SENSORS(); + case DeviceType.SCHEDULER: + return LL.SCHEDULER(); + default: + break; + } + } + return ''; + }; + + const showName = (di: DashboardItem) => { + if (di.id < 100) { + // if its a device (parent node) and has entities + if (di.nodes?.length) { + return ( + <> + + +   {showType(di.n, di.t)} + +  ({di.nodes?.length}) + + ); + } + } + if (di.dv) { + return {di.dv.id.slice(2)}; + } + }; + + const hasMask = (id: string, mask: number) => + (parseInt(id.slice(0, 2), 16) & mask) === mask; + + const editDashboardValue = (di: DashboardItem) => { + if (me.admin && di.dv?.c) { + setSelectedDashboardItem(di); + setDeviceValueDialogOpen(true); + } + }; + + const handleShowAll = ( + event: React.MouseEvent, + toggle: boolean | null + ) => { + if (toggle !== null) { + tree.fns.onToggleAll({}); + setShowAll(toggle); + } + }; + + const renderContent = () => { + if (!data) { + return ; + } + + return ( + <> + + + + + {LL.DASHBOARD_1()} + + + + + + + + + + + + + + + + + + + {!loading && data.length === 0 ? ( + + {LL.NO_DATA()} + + ) : ( + + {(tableList: DashboardItem[]) => ( + + {tableList.map((di: DashboardItem) => ( + editDashboardValue(di)} + > + {di.id > 99 ? ( + <> + {showName(di)} + + + + {formatValue(LL, di.dv?.v, di.dv?.u)} + + + + + + {me.admin && + di.dv?.c && + !hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && ( + editDashboardValue(di)} + > + + + )} + + + ) : ( + <> + {showName(di)} + + + + )} + + ))} + + )} +
+ )} +
+
+ + ); + }; + + return ( + + {renderContent()} + {selectedDashboardItem && selectedDashboardItem.dv && ( + setDeviceValueDialogOpen(false)} + onSave={deviceValueDialogSave} + selectedItem={selectedDashboardItem.dv} + writeable={true} + validator={deviceValueItemValidation(selectedDashboardItem.dv)} + progress={submitting} + /> + )} + + ); +}; + +export default Dashboard; diff --git a/interface/src/app/main/DeviceIcon.tsx b/interface/src/app/main/DeviceIcon.tsx index a9429b67c..b216cbacb 100644 --- a/interface/src/app/main/DeviceIcon.tsx +++ b/interface/src/app/main/DeviceIcon.tsx @@ -2,59 +2,52 @@ import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ import { CgSmartHomeBoiler } from 'react-icons/cg'; import { FaSolarPanel } from 'react-icons/fa'; import { GiHeatHaze, GiTap } from 'react-icons/gi'; +import { MdPlaylistAdd } from 'react-icons/md'; +import { MdMoreTime } from 'react-icons/md'; import { MdOutlineDevices, MdOutlinePool, MdOutlineSensors, MdThermostatAuto } from 'react-icons/md'; -import { TiFlowSwitch } from 'react-icons/ti'; +import { PiFan, PiGauge } from 'react-icons/pi'; +import { TiFlowSwitch, TiThermometer } from 'react-icons/ti'; import { VscVmConnect } from 'react-icons/vsc'; -import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd'; +import type { SvgIconProps } from '@mui/material'; import { DeviceType } from './types'; +const deviceIconLookup: { + [key in DeviceType]: React.ComponentType | undefined; +} = { + [DeviceType.TEMPERATURESENSOR]: TiThermometer, + [DeviceType.ANALOGSENSOR]: PiGauge, + [DeviceType.BOILER]: CgSmartHomeBoiler, + [DeviceType.HEATSOURCE]: CgSmartHomeBoiler, + [DeviceType.THERMOSTAT]: MdThermostatAuto, + [DeviceType.MIXER]: AiOutlineControl, + [DeviceType.SOLAR]: FaSolarPanel, + [DeviceType.HEATPUMP]: GiHeatHaze, + [DeviceType.GATEWAY]: AiOutlineGateway, + [DeviceType.SWITCH]: TiFlowSwitch, + [DeviceType.CONTROLLER]: VscVmConnect, + [DeviceType.CONNECT]: VscVmConnect, + [DeviceType.ALERT]: AiOutlineAlert, + [DeviceType.EXTENSION]: MdOutlineDevices, + [DeviceType.WATER]: GiTap, + [DeviceType.POOL]: MdOutlinePool, + [DeviceType.CUSTOM]: MdPlaylistAdd, + [DeviceType.UNKNOWN]: MdOutlineSensors, + [DeviceType.SYSTEM]: undefined, + [DeviceType.SCHEDULER]: MdMoreTime, + [DeviceType.GENERIC]: MdOutlineSensors, + [DeviceType.VENTILATION]: PiFan +}; + const DeviceIcon = ({ type_id }: { type_id: DeviceType }) => { - switch (type_id) { - case DeviceType.TEMPERATURESENSOR: - case DeviceType.ANALOGSENSOR: - return ; - case DeviceType.BOILER: - case DeviceType.HEATSOURCE: - return ; - case DeviceType.THERMOSTAT: - return ; - case DeviceType.MIXER: - return ; - case DeviceType.SOLAR: - return ; - case DeviceType.HEATPUMP: - return ; - case DeviceType.GATEWAY: - return ; - case DeviceType.SWITCH: - return ; - case DeviceType.CONTROLLER: - case DeviceType.CONNECT: - return ; - case DeviceType.ALERT: - return ; - case DeviceType.EXTENSION: - return ; - case DeviceType.WATER: - return ; - case DeviceType.POOL: - return ; - case DeviceType.CUSTOM: - return ( - - ); - default: - return null; - } + const Icon = deviceIconLookup[type_id]; + return Icon ? : null; }; export default DeviceIcon; diff --git a/interface/src/app/main/Devices.tsx b/interface/src/app/main/Devices.tsx index fe306dbda..ddd30fe39 100644 --- a/interface/src/app/main/Devices.tsx +++ b/interface/src/app/main/Devices.tsx @@ -60,10 +60,11 @@ import { useRequest } from 'alova/client'; import { MessageBox, SectionContent, useLayoutTitle } from 'components'; import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; +import { useInterval } from 'utils'; import { readCoreData, readDeviceData, writeDeviceValue } from '../../api/app'; import DeviceIcon from './DeviceIcon'; -import DashboardDevicesDialog from './DevicesDialog'; +import DevicesDialog from './DevicesDialog'; import { formatValue } from './deviceValue'; import { DeviceEntityMask, DeviceType, DeviceValueUOM_s } from './types'; import type { Device, DeviceValue } from './types'; @@ -77,7 +78,7 @@ const Devices = () => { const [selectedDeviceValue, setSelectedDeviceValue] = useState(); const [onlyFav, setOnlyFav] = useState(false); const [deviceValueDialogOpen, setDeviceValueDialogOpen] = useState(false); - const [showDeviceInfo, setShowDeviceInfo] = useState(false); + const [showDeviceInfo, setShowDeviceInfo] = useState(false); const [selectedDevice, setSelectedDevice] = useState(); const navigate = useNavigate(); @@ -95,7 +96,7 @@ const Devices = () => { (id: number) => readDeviceData(id), { initialData: { - data: [] + nodes: [] }, immediate: false } @@ -147,22 +148,15 @@ const Devices = () => { } `, Row: ` - background-color: #1E1E1E; - position: relative; cursor: pointer; + background-color: #1E1E1E; .td { padding: 8px; - border-top: 1px solid #565656; - border-bottom: 1px solid #565656; } &.tr.tr-body.row-select.row-select-single-selected { - background-color: #3d4752; + background-color: #177ac9; font-weight: normal; } - &:hover .td { - border-top: 1px solid #177ac9; - border-bottom: 1px solid #177ac9; - } ` }); @@ -170,17 +164,21 @@ const Devices = () => { common_theme, { Table: ` - --data-table-library_grid-template-columns: 40px repeat(1, minmax(0, 1fr)) 130px; + --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px; `, BaseRow: ` - .td { - height: 42px; - } + // .td { + // height: 42px; + // } `, HeaderRow: ` .th { padding: 8px; height: 36px; + `, + Row: ` + &:hover .td { + background-color: #177ac9; ` } ]); @@ -221,7 +219,10 @@ const Devices = () => { Row: ` &:nth-of-type(odd) .td { background-color: #303030; - } + }, + &:hover .td { + background-color: #177ac9; + } ` } ]); @@ -251,7 +252,7 @@ const Devices = () => { }; const dv_sort = useSort( - { nodes: deviceData.data }, + { nodes: deviceData.nodes }, {}, { sortIcon: { @@ -383,8 +384,8 @@ const Devices = () => { ]; const data = onlyFav - ? deviceData.data.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)) - : deviceData.data; + ? deviceData.nodes.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)) + : deviceData.nodes; const csvData = data.reduce( (csvString: string, rowItem: DeviceValue) => @@ -418,17 +419,11 @@ const Devices = () => { downloadBlob(new Blob([csvData], { type: 'text/csv;charset:utf-8' })); }; - useEffect(() => { - const timer = setInterval(() => { - if (deviceValueDialogOpen) { - return; - } + useInterval(() => { + if (!deviceValueDialogOpen) { selectedDevice ? void sendDeviceData(selectedDevice) : void sendCoreData(); - }, 2000); - return () => { - clearInterval(timer); - }; - }); + } + }, 3000); const deviceValueDialogSave = async (devicevalue: DeviceValue) => { const id = Number(device_select.state.id); @@ -527,57 +522,57 @@ const Devices = () => { }; const renderCoreData = () => ( - - {!coreData.connected && ( - - )} + <> + + {!coreData.connected && ( + + )} - {coreData.connected && ( - - {(tableList: Device[]) => ( - <> -
- - - {LL.DESCRIPTION()} - {LL.TYPE(0)} - -
- - {tableList.length === 0 && ( - - )} - {tableList.map((device: Device) => ( - - - - - - {device.n} - -   ({device.e}) - - - {device.tn} - - ))} - - - )} -
- )} -
+ {coreData.connected && ( + + {(tableList: Device[]) => ( + <> +
+ + {LL.DESCRIPTION()} + {LL.TYPE(0)} + +
+ + {tableList.length === 0 && ( + + )} + {tableList.map((device: Device) => ( + + + +    + {device.n} + +   ({device.e}) + + + {device.tn} + + ))} + + + )} +
+ )} +
+ ); const deviceValueDialogClose = () => { @@ -611,8 +606,8 @@ const Devices = () => { ); const shown_data = onlyFav - ? deviceData.data.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)) - : deviceData.data; + ? deviceData.nodes.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)) + : deviceData.nodes; const deviceIndex = coreData.devices.findIndex( (d) => d.id === device_select.state.id @@ -733,7 +728,7 @@ const Devices = () => { size="small" onClick={() => showDeviceValue(dv)} > - {dv.v === '' && dv.c ? ( + {dv.v === '' ? ( ) : ( @@ -757,7 +752,7 @@ const Devices = () => { {renderDeviceData()} {renderDeviceDetails()} {selectedDeviceValue && ( - void; onSave: (as: DeviceValue) => void; @@ -47,7 +47,7 @@ const DevicesDialog = ({ writeable, validator, progress -}: DashboardDevicesDialogProps) => { +}: DevicesDialogProps) => { const { LL } = useI18nContext(); const [editItem, setEditItem] = useState(selectedItem); const [fieldErrors, setFieldErrors] = useState(); @@ -75,7 +75,10 @@ const DevicesDialog = ({ } }; - const setUom = (uom: DeviceValueUOM) => { + const setUom = (uom?: DeviceValueUOM) => { + if (uom === undefined) { + return; + } switch (uom) { case DeviceValueUOM.HOURS: return LL.HOURS(); @@ -195,9 +198,9 @@ const DevicesDialog = ({ diff --git a/interface/src/app/main/Help.tsx b/interface/src/app/main/Help.tsx index da45a0f1b..159a1ace6 100644 --- a/interface/src/app/main/Help.tsx +++ b/interface/src/app/main/Help.tsx @@ -24,7 +24,7 @@ import { useRequest } from 'alova/client'; import { SectionContent, useLayoutTitle } from 'components'; import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; -import { saveFile } from 'utils/file'; +import { saveFile } from 'utils'; import { API, callAction } from '../../api/app'; import type { APIcall } from './types'; @@ -147,7 +147,7 @@ const Help = () => { )} - + {LL.HELP_INFORMATION_4()}