Remove useMemo/useCallback across the web UI

This commit is contained in:
proddy
2026-04-27 13:24:07 +02:00
parent e39af36589
commit 1a880f14a0
53 changed files with 1940 additions and 2594 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -57,20 +57,18 @@ const CustomEntities = () => {
initialData: []
});
const intervalCallback = useCallback(() => {
useInterval(() => {
if (!dialogOpen && !numChanges) {
void fetchEntities();
}
}, [dialogOpen, numChanges, fetchEntities]);
useInterval(intervalCallback);
});
const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data),
{ immediate: false }
);
const hasEntityChanged = useCallback((ei: EntityItem) => {
const hasEntityChanged = (ei: EntityItem) => {
return (
ei.id !== ei.o_id ||
ei.ram !== ei.o_ram ||
@@ -86,21 +84,19 @@ const CustomEntities = () => {
ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '')
);
}, []);
};
const entity_theme = useMemo(
() =>
useTheme({
Table: `
const entity_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
@@ -120,7 +116,7 @@ const CustomEntities = () => {
text-align: center;
}
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -129,7 +125,7 @@ const CustomEntities = () => {
height: 36px;
}
`,
Row: `
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
@@ -140,11 +136,9 @@ const CustomEntities = () => {
background-color: #177ac9;
}
`
}),
[]
);
});
const saveEntities = useCallback(async () => {
const saveEntities = async () => {
await writeEntities({
entities: entities
.filter((ei: EntityItem) => !ei.deleted)
@@ -173,44 +167,41 @@ const CustomEntities = () => {
await fetchEntities();
setNumChanges(0);
});
}, [entities, writeEntities, LL, fetchEntities]);
};
const editEntityItem = useCallback((ei: EntityItem) => {
const editEntityItem = (ei: EntityItem) => {
setCreating(false);
setSelectedEntityItem(ei);
setDialogOpen(true);
}, []);
};
const onDialogClose = useCallback(() => {
const onDialogClose = () => {
setDialogOpen(false);
}, []);
};
const onDialogCancel = useCallback(async () => {
const onDialogCancel = async () => {
await fetchEntities().then(() => {
setNumChanges(0);
});
}, [fetchEntities]);
};
const onDialogSave = useCallback(
(updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
},
[creating, hasEntityChanged]
);
const onDialogSave = (updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
};
const onDialogDup = useCallback((item: EntityItem) => {
const onDialogDup = (item: EntityItem) => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -228,9 +219,9 @@ const CustomEntities = () => {
value: item.value
});
setDialogOpen(true);
}, []);
};
const addEntityItem = useCallback(() => {
const addEntityItem = () => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -248,30 +239,27 @@ const CustomEntities = () => {
value: ''
});
setDialogOpen(true);
}, []);
};
const formatValue = useCallback((value: unknown, uom: number) => {
const formatValue = (value: unknown, uom: number) => {
return value === undefined
? ''
: typeof value === 'number'
? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
}, []);
};
const showHex = useCallback((value: number, digit: number) => {
const showHex = (value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}, []);
};
const filteredAndSortedEntities = useMemo(
() =>
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const filteredAndSortedEntities =
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
const renderEntity = useCallback(() => {
const renderEntity = () => {
if (!entities) {
return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -328,17 +316,7 @@ const CustomEntities = () => {
)}
</Table>
);
}, [
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
};
return (
<SectionContent>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -68,6 +68,7 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
// Stable handler reference so the memoized ValidatedTextField can skip re-renders
const updateFormValue = useMemo(
() =>
updateValue(
@@ -105,16 +106,16 @@ const CustomEntitiesDialog = ({
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = useCallback(async () => {
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -138,27 +139,21 @@ const CustomEntitiesDialog = ({
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [validator, editItem, onSave]);
};
const remove = useCallback(() => {
const itemWithDeleted = { ...editItem, deleted: true };
onSave(itemWithDeleted);
}, [editItem, onSave]);
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const dup = useCallback(() => {
const dup = () => {
onDup(editItem);
}, [editItem, onDup]);
};
// Memoize UOM menu items to avoid recreating on every render
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
));
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify';
@@ -171,19 +171,17 @@ const Customizations = () => {
);
};
const entities_theme = useMemo(
() =>
useTheme({
Table: `
const entities_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(3) {
text-align: right;
}
@@ -194,7 +192,7 @@ const Customizations = () => {
text-align: right;
}
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -206,7 +204,7 @@ const Customizations = () => {
text-align: center;
}
`,
Row: `
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
@@ -222,7 +220,7 @@ const Customizations = () => {
background-color: #177ac9;
}
`,
Cell: `
Cell: `
&:nth-of-type(2) {
padding: 8px;
}
@@ -236,9 +234,7 @@ const Customizations = () => {
padding-right: 8px;
}
`
}),
[]
);
});
function hasEntityChanged(de: DeviceEntity) {
return (
@@ -287,26 +283,23 @@ const Customizations = () => {
return value as string;
}
const isCommand = useCallback((de: DeviceEntity) => {
const isCommand = (de: DeviceEntity) => {
return de.n && de.n[0] === '!';
}, []);
};
const formatName = useCallback(
(de: DeviceEntity, withShortname: boolean) => {
let name: string;
if (isCommand(de)) {
name = de.t
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
} else if (de.cn && de.cn !== '') {
name = de.t ? `${de.t} ${de.cn}` : de.cn;
} else {
name = de.t ? `${de.t} ${de.n}` : de.n || '';
}
return withShortname ? `${name} ${de.id}` : name;
},
[LL]
);
const formatName = (de: DeviceEntity, withShortname: boolean) => {
let name: string;
if (isCommand(de)) {
name = de.t
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
} else if (de.cn && de.cn !== '') {
name = de.t ? `${de.t} ${de.cn}` : de.cn;
} else {
name = de.t ? `${de.t} ${de.n}` : de.n || '';
}
return withShortname ? `${name} ${de.id}` : name;
};
const getMaskNumber = (newMask: string[]) => {
let new_mask = 0;
@@ -336,33 +329,27 @@ const Customizations = () => {
return new_masks;
};
const filter_entity = useCallback(
(de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
const filter_entity = (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase());
const maskDisabled = useCallback(
(set: boolean) => {
setDeviceEntities((prev) =>
prev.map((de) => {
if (filter_entity(de)) {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
return {
...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask
};
}
return de;
})
);
},
[filter_entity]
);
const maskDisabled = (set: boolean) => {
setDeviceEntities((prev) =>
prev.map((de) => {
if (filter_entity(de)) {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
return {
...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask
};
}
return de;
})
);
};
const resetCustomization = useCallback(async () => {
const resetCustomization = async () => {
try {
await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -372,30 +359,27 @@ const Customizations = () => {
setConfirmReset(false);
setRestarting(true);
}
}, [sendResetCustomizations, LL]);
};
const onDialogClose = () => {
setDialogOpen(false);
};
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setDeviceEntities(
(prev) =>
prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? []
);
}, []);
};
const onDialogSave = useCallback(
(updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
},
[updateDeviceEntity]
);
const onDialogSave = (updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
};
const editDeviceEntity = useCallback((de: DeviceEntity) => {
const editDeviceEntity = (de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) {
return;
}
@@ -406,9 +390,9 @@ const Customizations = () => {
setSelectedDeviceEntity(de);
setDialogOpen(true);
}, []);
};
const saveCustomization = useCallback(async () => {
const saveCustomization = async () => {
if (!devices || !deviceEntities || selectedDevice === -1) {
return;
}
@@ -441,9 +425,9 @@ const Customizations = () => {
.finally(() => {
setOriginalSettings(deviceEntities);
});
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
};
const renameDevice = useCallback(async () => {
const renameDevice = async () => {
await sendDeviceName({
id: selectedDevice,
name: selectedDeviceName,
@@ -459,14 +443,7 @@ const Customizations = () => {
setRename(false);
await fetchCoreData();
});
}, [
selectedDevice,
selectedDeviceName,
selectedDeviceBrand,
sendDeviceName,
LL,
fetchCoreData
]);
};
const renderDeviceList = () => (
<>
@@ -562,10 +539,7 @@ const Customizations = () => {
</>
);
const filteredEntities = useMemo(
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
const renderDeviceData = () => {
return (

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
@@ -57,23 +57,16 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
const isWriteableNumber = useMemo(
() =>
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY),
[editItem.v, editItem.w, editItem.m]
);
const isWriteableNumber =
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY);
useEffect(() => {
if (open) {
@@ -82,16 +75,16 @@ const CustomizationsDialog = ({
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = useCallback(() => {
const save = () => {
if (
isWriteableNumber &&
editItem.mi &&
@@ -102,34 +95,31 @@ const CustomizationsDialog = ({
} else {
onSave(editItem);
}
}, [isWriteableNumber, editItem, onSave]);
};
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
}, []);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
const writeableIcon = useMemo(
() =>
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
),
[editItem.w]
);
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
<DialogContent dividers>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n}
/>
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
<LabelValue
label={LL.WRITEABLE()}
value={
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
)
}
/>
<Box sx={{ mt: 1, mb: 2 }}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { memo, useContext, useEffect, useState } from 'react';
import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
@@ -77,40 +77,35 @@ const Dashboard = memo(() => {
}
);
const deviceValueDialogSave = useCallback(
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);
});
},
[selectedDashboardItem, sendDeviceValue, LL]
);
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 = useMemo(
() =>
useTheme({
Table: `
const dashboard_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 28px;
}
`,
Row: `
Row: `
cursor: pointer;
background-color: #1e1e1e;
&:nth-of-type(odd) .td {
@@ -120,7 +115,7 @@ const Dashboard = memo(() => {
background-color: #177ac9;
},
`,
BaseCell: `
BaseCell: `
&:nth-of-type(2) {
text-align: right;
}
@@ -128,9 +123,7 @@ const Dashboard = memo(() => {
text-align: right;
}
`
}),
[]
);
});
const tree = useTree(
{ nodes: [...data.nodes] },
@@ -164,79 +157,64 @@ const Dashboard = memo(() => {
}
});
const nodeIds = useMemo(
() => data.nodes.map((item: DashboardItem) => item.id),
[data.nodes]
);
useEffect(() => {
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
showAll
? tree.fns.onAddAll(nodeIds) // expand tree
: tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]);
const showType = useCallback(
(n?: string, t?: number) => {
// if we have a name show it
if (n) {
return n;
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;
}
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 '';
},
[LL]
);
}
return '';
};
const showName = useCallback(
(di: DashboardItem) => {
if (di.id < 100) {
// if its a device (parent node) and has entities
if (di.nodes?.length) {
return (
<span style={{ fontSize: '15px' }}>
<DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
</span>
);
}
const showName = (di: DashboardItem) => {
if (di.id < 100) {
// if its a device (parent node) and has entities
if (di.nodes?.length) {
return (
<span style={{ fontSize: '15px' }}>
<DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
</span>
);
}
if (di.dv) {
return <span>{di.dv.id.slice(2)}</span>;
}
return null;
},
[showType]
);
}
if (di.dv) {
return <span>{di.dv.id.slice(2)}</span>;
}
return null;
};
const hasMask = useCallback(
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const editDashboardValue = useCallback(
(di: DashboardItem) => {
if (me.admin && di.dv?.c) {
setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true);
}
},
[me.admin]
);
const editDashboardValue = (di: DashboardItem) => {
if (me.admin && di.dv?.c) {
setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true);
}
};
const handleShowAll = (
_event: React.MouseEvent<HTMLElement>,
@@ -248,10 +226,9 @@ const Dashboard = memo(() => {
}
};
const hasFavEntities = useMemo(
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
[data.nodes]
);
const hasFavEntities = data.nodes.filter(
(item: DashboardItem) => item.id <= 90
).length;
const renderContent = () => {
if (!data) {

View File

@@ -4,7 +4,6 @@ import {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState
} from 'react';
import { IconContext } from 'react-icons';
@@ -133,21 +132,19 @@ const Devices = memo(() => {
};
}, []);
const leftOffset = useCallback(() => {
const leftOffset = () => {
const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) return 0;
const { left, right } = devicesWindow.getBoundingClientRect();
if (!left || !right) return 0;
return left + (right - left < 400 ? 0 : 200);
}, []);
};
const common_theme = useMemo(
() =>
useTheme({
BaseRow: `
const common_theme = useTheme({
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -155,7 +152,7 @@ const Devices = memo(() => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
cursor: pointer;
background-color: #1E1E1E;
.td {
@@ -165,88 +162,78 @@ const Devices = memo(() => {
background-color: #177ac9;
}
`
}),
[]
);
});
const device_theme = useMemo(
() =>
useTheme([
common_theme,
{
BaseRow: `
font-size: 15px;
.td {
height: 28px;
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
HeaderRow: `
.th {
padding: 8px;
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
},
`
}
]),
[common_theme]
);
const data_theme = useMemo(
() =>
useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
const device_theme = useTheme([
common_theme,
{
BaseRow: `
font-size: 15px;
.td {
height: 28px;
}
`,
BaseRow: `
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
border-left: 1px solid #177ac9;
},
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
HeaderRow: `
.th {
padding: 8px;
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
},
&:hover .td {
background-color: #177ac9;
},
`
}
]);
const data_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
}
`
}
]),
[common_theme]
);
`,
BaseRow: `
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
border-left: 1px solid #177ac9;
},
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
}
`
}
]);
const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
@@ -345,10 +332,8 @@ const Devices = memo(() => {
return sc;
};
const hasMask = useCallback(
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex(
@@ -607,41 +592,35 @@ const Devices = memo(() => {
return;
}
const showDeviceValue = useCallback((dv: DeviceValue) => {
const showDeviceValue = (dv: DeviceValue) => {
setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true);
}, []);
};
const renderNameCell = useCallback(
(dv: DeviceValue) => (
<>
{dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
<StarIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</>
),
[hasMask]
const renderNameCell = (dv: DeviceValue) => (
<>
{dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
<StarIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</>
);
const shown_data = useMemo(() => {
if (onlyFav) {
return deviceData.nodes.filter(
const shown_data = onlyFav
? deviceData.nodes.filter(
(dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
)
: deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
}
return deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
}, [deviceData.nodes, onlyFav, search]);
const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -52,6 +52,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
// Stable handler reference so the memoized ValidatedTextField can skip re-renders
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
useEffect(() => {
@@ -61,7 +62,7 @@ const DevicesDialog = ({
}
}, [open, selectedItem]);
const save = useCallback(async () => {
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -69,28 +70,25 @@ const DevicesDialog = ({
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [validator, editItem, onSave]);
};
const setUom = useCallback(
(uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return LL.HOURS();
case DeviceValueUOM.MINUTES:
return LL.MINUTES();
case DeviceValueUOM.SECONDS:
return LL.SECONDS();
default:
return DeviceValueUOM_s[uom];
}
},
[LL]
);
const setUom = (uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return LL.HOURS();
case DeviceValueUOM.MINUTES:
return LL.MINUTES();
case DeviceValueUOM.SECONDS:
return LL.SECONDS();
default:
return DeviceValueUOM_s[uom];
}
};
const showHelperText = useCallback((dv: DeviceValue) => {
const showHelperText = (dv: DeviceValue) => {
if (dv.h) return dv.h;
if (dv.l) return dv.l.join(' | ');
if (dv.m !== undefined && dv.x !== undefined) {
@@ -101,26 +99,16 @@ const DevicesDialog = ({
);
}
return undefined;
}, []);
};
const isCommand = useMemo(
() => selectedItem.v === '' && selectedItem.c,
[selectedItem.v, selectedItem.c]
);
const dialogTitle = useMemo(() => {
if (isCommand) return LL.RUN_COMMAND();
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
}, [isCommand, writeable, LL]);
const buttonLabel = useMemo(() => {
return isCommand ? LL.EXECUTE() : LL.UPDATE();
}, [isCommand, LL]);
const helperText = useMemo(
() => showHelperText(editItem),
[editItem, showHelperText]
);
const isCommand = selectedItem.v === '' && selectedItem.c;
const dialogTitle = isCommand
? LL.RUN_COMMAND()
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0);
const buttonLabel = isCommand ? LL.EXECUTE() : LL.UPDATE();
const helperText = showHelperText(editItem);
const valueLabel = LL.VALUE(0);

View File

@@ -1,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon';
@@ -11,7 +9,6 @@ interface EntityMaskToggleProps {
de: DeviceEntity;
}
// Available mask values
const MASK_VALUES = [
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
@@ -20,123 +17,95 @@ const MASK_VALUES = [
DeviceEntityMask.DV_DELETED // 128
];
/**
* Converts an array of mask strings to a bitmask number
*/
const getMaskNumber = (newMask: string[]): number => {
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
};
const getMaskNumber = (newMask: string[]): number =>
newMask.reduce((mask, entry) => mask | Number(entry), 0);
/**
* Converts a bitmask number to an array of mask strings
*/
const getMaskString = (mask: number): string[] => {
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
const getMaskString = (mask: number): string[] =>
MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
String(value)
);
};
/**
* Checks if a specific mask bit is set
*/
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = useCallback(
(_event: unknown, mask: string[]) => {
// Convert selected masks to a number
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
const handleChange = (_event: unknown, mask: string[]) => {
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
// Apply business logic for mask interactions
// If entity has no name and is set to readonly, also exclude from web
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
} else {
updatedDe.m = newMask;
}
// If entity has no name and is set to readonly, also exclude from web
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
} else {
updatedDe.m = newMask;
}
// If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
}
// If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
}
onUpdate(updatedDe);
},
[de, onUpdate]
);
// Memoize mask string value
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
// Memoize disabled states
const isFavoriteDisabled = useMemo(
() =>
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
de.n === undefined,
[de.m, de.n]
);
const isReadonlyDisabled = useMemo(
() =>
!de.w ||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
[de.w, de.m]
);
const isApiMqttExcludeDisabled = useMemo(
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
const isWebExcludeDisabled = useMemo(
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
// Memoize mask flag checks
const isFavoriteSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
[de.m]
);
const isReadonlySet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
[de.m]
);
const isApiMqttExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
[de.m]
);
const isWebExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
[de.m]
);
const isDeletedSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.m]
);
onUpdate(updatedDe);
};
return (
<ToggleButtonGroup
size="small"
color="secondary"
value={maskStringValue}
value={getMaskString(de.m)}
onChange={handleChange}
>
<ToggleButton value="8" disabled={isFavoriteDisabled}>
<OptionIcon type="favorite" isSet={isFavoriteSet} />
<ToggleButton
value="8"
disabled={
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
) || de.n === undefined
}
>
<OptionIcon
type="favorite"
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
/>
</ToggleButton>
<ToggleButton value="4" disabled={isReadonlyDisabled}>
<OptionIcon type="readonly" isSet={isReadonlySet} />
<ToggleButton
value="4"
disabled={
!de.w ||
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
)
}
>
<OptionIcon
type="readonly"
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
/>
</ToggleButton>
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
<ToggleButton
value="2"
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="api_mqtt_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
/>
</ToggleButton>
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
<ToggleButton
value="1"
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="web_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
/>
</ToggleButton>
<ToggleButton value="128">
<OptionIcon type="deleted" isSet={isDeletedSet} />
<OptionIcon
type="deleted"
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
/>
</ToggleButton>
</ToggleButtonGroup>
);

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { memo, useContext, useState } from 'react';
import type { ReactElement } from 'react';
import { toast } from 'react-toastify';
@@ -60,6 +60,8 @@ const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9'
};
const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
const HelpComponent = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.HELP());
@@ -72,12 +74,7 @@ const HelpComponent = () => {
});
const [imgError, setImgError] = useState<boolean>(false);
const getCustomSupportMethod = useMemo(
() => callAction({ action: 'getCustomSupport' }),
[]
);
useRequest(getCustomSupportMethod).onSuccess((event) => {
useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
if (event?.data && Object.keys(event.data).length !== 0) {
const { Support } = event.data as {
Support: { img_url?: string; html?: string[] };
@@ -100,47 +97,26 @@ const HelpComponent = () => {
toast.error(String(error.error?.message || 'An error occurred'));
});
// Optimize API call memoization
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
const helpLinks: HelpLink[] = [
{
href: 'https://emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/GP9DPSgeJq',
icon: <CommentIcon />,
label: () => LL.HELP_INFORMATION_2()
},
{
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3()
}
];
const handleDownloadSystemInfo = useCallback(() => {
void sendAPI(apiCall);
}, [sendAPI, apiCall]);
const handleImageError = useCallback(() => {
setImgError(true);
}, []);
// Memoize help links to prevent recreation on every render
const helpLinks: HelpLink[] = useMemo(
() => [
{
href: 'https://emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/GP9DPSgeJq',
icon: <CommentIcon />,
label: () => LL.HELP_INFORMATION_2()
},
{
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3()
}
],
[LL]
);
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
// Memoize image source computation
const imageSrc = useMemo(
() =>
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
[imgError, customSupport.img_url]
);
const imageSrc =
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
return (
<SectionContent>
@@ -157,13 +133,13 @@ const HelpComponent = () => {
component="img"
referrerPolicy="no-referrer"
sx={IMAGE_STYLES}
onError={handleImageError}
onError={() => setImgError(true)}
src={imageSrc}
/>
</Stack>
)}
{isAdmin && (
{me?.admin && (
<List>
{helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}>
@@ -191,7 +167,7 @@ const HelpComponent = () => {
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={handleDownloadSystemInfo}
onClick={() => void sendAPI(SYSTEM_INFO_API)}
>
{LL.SUPPORT_INFORMATION(0)}
</Button>
@@ -214,7 +190,6 @@ const HelpComponent = () => {
);
};
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent);
export default Help;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { memo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -69,58 +69,53 @@ const Modules = () => {
}
);
const modules_theme = useTheme(
useMemo(
() => ({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:hover .td {
border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
`
}),
[]
)
);
const modules_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:hover .td {
border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
`
});
const onDialogClose = useCallback(() => {
const onDialogClose = () => {
setDialogOpen(false);
}, []);
};
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
const updateModuleItem = (updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
@@ -128,28 +123,25 @@ const Modules = () => {
setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data;
});
}, []);
};
const onDialogSave = useCallback(
(updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
},
[updateModuleItem]
);
const onDialogSave = (updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
};
const editModuleItem = useCallback((mi: ModuleItem) => {
const editModuleItem = (mi: ModuleItem) => {
setSelectedModuleItem(mi);
setDialogOpen(true);
}, []);
};
const onCancel = useCallback(async () => {
const onCancel = async () => {
await fetchModules().then(() => {
setNumChanges(0);
});
}, [fetchModules]);
};
const saveModules = useCallback(async () => {
const saveModules = async () => {
try {
await Promise.all(
modules.map((condensed_mi: ModuleItem) =>
@@ -167,9 +159,9 @@ const Modules = () => {
await fetchModules();
setNumChanges(0);
}
}, [modules, updateModules, LL, fetchModules]);
};
const content = useMemo(() => {
const renderContent = () => {
if (!modules) {
return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
@@ -262,22 +254,12 @@ const Modules = () => {
</Box>
</>
);
}, [
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content}
{renderContent()}
{selectedModuleItem && (
<ModulesDialog
open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -37,14 +37,10 @@ const ModulesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
// Sync form state when dialog opens or selected item changes
@@ -54,18 +50,13 @@ const ModulesDialog = ({
}
}, [open, selectedItem]);
const handleSave = useCallback(() => {
const handleSave = () => {
onSave(editItem);
}, [editItem, onSave]);
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
};
return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
<DialogContent dividers>
<Grid container>
<BlockFormControlLabel

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -132,7 +132,7 @@ const Scheduler = () => {
}
);
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
const hasScheduleChanged = (si: ScheduleItem) => {
return (
si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') ||
@@ -143,15 +143,13 @@ const Scheduler = () => {
si.cmd !== si.o_cmd ||
si.value !== si.o_value
);
}, []);
};
const intervalCallback = useCallback(() => {
useInterval(() => {
if (numChanges === 0) {
void fetchSchedule();
}
}, [numChanges, fetchSchedule]);
useInterval(intervalCallback, INTERVAL_DELAY);
}, INTERVAL_DELAY);
useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, {
@@ -169,7 +167,7 @@ const Scheduler = () => {
const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = useCallback(async () => {
const saveSchedule = async () => {
try {
await updateSchedule({
schedule: schedule
@@ -192,46 +190,43 @@ const Scheduler = () => {
await fetchSchedule();
setNumChanges(0);
}
}, [LL, schedule, updateSchedule, fetchSchedule]);
};
const editScheduleItem = useCallback((si: ScheduleItem) => {
const editScheduleItem = (si: ScheduleItem) => {
setCreating(false);
setSelectedScheduleItem(si);
setDialogOpen(true);
if (si.o_name === undefined) {
si.o_name = si.name;
}
}, []);
};
const onDialogClose = useCallback(() => {
const onDialogClose = () => {
setDialogOpen(false);
}, []);
};
const onDialogCancel = useCallback(async () => {
const onDialogCancel = async () => {
await fetchSchedule().then(() => {
setNumChanges(0);
});
}, [fetchSchedule]);
};
const onDialogSave = useCallback(
(updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
const onDialogSave = (updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
return new_data;
});
},
[creating, hasScheduleChanged]
);
return new_data;
});
};
const addScheduleItem = useCallback(() => {
const addScheduleItem = () => {
setCreating(true);
const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -239,36 +234,29 @@ const Scheduler = () => {
};
setSelectedScheduleItem(newItem);
setDialogOpen(true);
}, []);
};
const filteredAndSortedSchedule = useMemo(
() =>
schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const filteredAndSortedSchedule = schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
const dayBox = useCallback(
(si: ScheduleItem, flag: number) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
const dayBox = (si: ScheduleItem, flag: number) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
return (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
},
[dow]
);
return (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
};
const scheduleType = useCallback((si: ScheduleItem) => {
const scheduleType = (si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags];
return (
@@ -278,9 +266,9 @@ const Scheduler = () => {
</Typography>
</Box>
);
}, []);
};
const renderSchedule = useCallback(() => {
const renderSchedule = () => {
if (!schedule) {
return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
@@ -343,17 +331,7 @@ const Scheduler = () => {
)}
</Table>
);
}, [
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
};
return (
<SectionContent>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -60,6 +60,12 @@ const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SAT
] as const;
const getFlagDOWnumber = (flags: string[]) =>
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
const getFlagDOWstring = (f: number) =>
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
interface SchedulerDialogProps {
open: boolean;
creating: boolean;
@@ -84,6 +90,7 @@ const SchedulerDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
// Stable handler reference so the memoized ValidatedTextField can skip re-renders
const updateFormValue = useMemo(
() =>
updateValue(
@@ -112,129 +119,95 @@ const SchedulerDialog = ({
}
}, [open, selectedItem]);
// Helper function to handle save operations
const handleSave = useCallback(
async (itemToSave: ScheduleItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
},
[validator, onSave]
);
const save = useCallback(async () => {
await handleSave(editItem);
}, [editItem, handleSave]);
const saveandactivate = useCallback(async () => {
await handleSave({ ...editItem, active: true });
}, [editItem, handleSave]);
const remove = useCallback(() => {
onSave({ ...editItem, deleted: true });
}, [editItem, onSave]);
// Optimize DOW flag conversion
const getFlagDOWnumber = useCallback((flags: string[]) => {
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
}, []);
const getFlagDOWstring = useCallback((f: number) => {
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
String(flag)
);
}, []);
// Day of week display component
const DayOfWeekButton = useCallback(
(flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
},
[editItem.flags, dow]
);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleScheduleTypeChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
},
[]
);
const handleDOWChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
},
[getFlagDOWnumber]
);
// Memoize derived values
const isDaySchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
[scheduleType]
);
const isTimerSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
[scheduleType]
);
const isImmediateSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
[scheduleType]
);
const needsTimeField = useMemo(
() => isDaySchedule || isTimerSchedule,
[isDaySchedule, isTimerSchedule]
);
const dowFlags = useMemo(
() => getFlagDOWstring(editItem.flags),
[editItem.flags, getFlagDOWstring]
);
const timeFieldValue = useMemo(() => {
if (needsTimeField) {
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
const handleSave = async (itemToSave: ScheduleItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
}, [editItem.time, needsTimeField]);
};
const timeFieldLabel = useMemo(() => {
const save = async () => {
await handleSave(editItem);
};
const saveandactivate = async () => {
await handleSave({ ...editItem, active: true });
};
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const DayOfWeekButton = (flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
};
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleScheduleTypeChange = (
_event: React.SyntheticEvent<HTMLElement>,
flag: ScheduleFlag | null
) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
};
const handleDOWChange = (
_event: React.SyntheticEvent<HTMLElement>,
flags: string[]
) => {
const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
};
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE;
const needsTimeField = isDaySchedule || isTimerSchedule;
const dowFlags = getFlagDOWstring(editItem.flags);
const timeFieldValue = needsTimeField
? editItem.time === ''
? DEFAULT_TIME
: editItem.time
: editItem.time === DEFAULT_TIME
? ''
: editItem.time;
const timeFieldLabel = (() => {
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1);
}, [scheduleType, LL]);
})();
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useContext, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
@@ -158,18 +158,16 @@ const Sensors = () => {
}
);
const intervalCallback = useCallback(() => {
useInterval(() => {
if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData();
}
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
useInterval(intervalCallback);
});
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]);
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />;
}
@@ -177,7 +175,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />;
}
return <UnfoldMoreOutlinedIcon />;
}, []);
};
const analog_sort = useSort(
{ nodes: sensorData.as },
@@ -234,119 +232,104 @@ const Sensors = () => {
useLayoutTitle(LL.SENSORS());
const formatDurationMin = useCallback(
(duration_min: number) => {
const totalMs = duration_min * MS_PER_MINUTE;
const days = Math.trunc(totalMs / MS_PER_DAY);
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const formatDurationMin = (duration_min: number) => {
const totalMs = duration_min * MS_PER_MINUTE;
const days = Math.trunc(totalMs / MS_PER_DAY);
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const parts: string[] = [];
if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
}
return parts.join(' ');
},
[LL]
);
const parts: string[] = [];
if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
}
return parts.join(' ');
};
const formatValue = useCallback(
(value: unknown, uom: DeviceValueUOM) => {
if (value === undefined) {
return '';
}
if (typeof value !== 'number') {
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
},
[formatDurationMin, LL]
);
const formatValue = (value: unknown, uom: DeviceValueUOM) => {
if (value === undefined) {
return '';
}
if (typeof value !== 'number') {
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
};
const updateTemperatureSensor = useCallback(
(ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
},
[me.admin]
);
const updateTemperatureSensor = (ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
};
const onTemperatureDialogClose = useCallback(() => {
const onTemperatureDialogClose = () => {
setTemperatureDialogOpen(false);
void fetchSensorData();
}, [fetchSensorData]);
};
const onTemperatureDialogSave = useCallback(
async (ts: TemperatureSensor) => {
await sendTemperatureSensor({
id: ts.id,
name: ts.n,
offset: ts.o,
is_system: ts.s
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
await sendTemperatureSensor({
id: ts.id,
name: ts.n,
offset: ts.o,
is_system: ts.s
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
},
[sendTemperatureSensor, LL, fetchSensorData]
);
.catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
};
const updateAnalogSensor = useCallback(
(as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
},
[me.admin]
);
const updateAnalogSensor = (as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
};
const onAnalogDialogClose = useCallback(() => {
const onAnalogDialogClose = () => {
setAnalogDialogOpen(false);
void fetchSensorData();
}, [fetchSensorData]);
};
const addAnalogSensor = useCallback(() => {
const addAnalogSensor = () => {
if (firstAvailableGPIO.current === undefined) {
toast.error(LL.NO_GPIO());
return;
@@ -366,194 +349,167 @@ const Sensors = () => {
o_n: ''
});
setAnalogDialogOpen(true);
}, []);
};
const onAnalogDialogSave = useCallback(
async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
name: as.n,
offset: as.o,
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d,
is_system: as.s
const onAnalogDialogSave = async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
name: as.n,
offset: as.o,
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d,
is_system: as.s
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
},
[sendAnalogSensor, LL, fetchSensorData]
.catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
};
const RenderAnalogSensors = (
<Table
data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((as: AnalogSensor) => (
<Row
style={{ color: as.s ? 'grey' : 'inherit' }}
key={as.id}
item={as}
onClick={() => updateAnalogSensor(as)}
>
<Cell stiff>{as.g}</Cell>
<Cell>{as.n}</Cell>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
{(as.t === AnalogType.DIGITAL_OUT &&
as.g !== GPIO_25 &&
as.g !== GPIO_26) ||
as.t === AnalogType.DIGITAL_IN ||
as.t === AnalogType.PULSE ? (
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</Table>
);
const RenderAnalogSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() =>
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((as: AnalogSensor) => (
<Row
style={{ color: as.s ? 'grey' : 'inherit' }}
key={as.id}
item={as}
onClick={() => updateAnalogSensor(as)}
const RenderTemperatureSensors = (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
<Cell stiff>{as.g}</Cell>
<Cell>{as.n}</Cell>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
{(as.t === AnalogType.DIGITAL_OUT &&
as.g !== GPIO_25 &&
as.g !== GPIO_26) ||
as.t === AnalogType.DIGITAL_IN ||
as.t === AnalogType.PULSE ? (
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</Table>
),
[
analog_sort,
analog_theme,
getSortIcon,
sensorData.as,
LL,
updateAnalogSensor,
formatValue
]
);
const RenderTemperatureSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row
style={{ color: ts.s ? 'grey' : 'inherit' }}
key={ts.id}
item={ts}
onClick={() => updateTemperatureSensor(ts)}
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
),
[
temperature_sort,
temperature_theme,
getSortIcon,
sensorData.ts,
LL,
updateTemperatureSensor,
formatValue
]
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row
style={{ color: ts.s ? 'grey' : 'inherit' }}
key={ts.id}
item={ts}
onClick={() => updateTemperatureSensor(ts)}
>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
return (

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -53,6 +53,7 @@ const SensorsAnalogDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
// Stable handler reference so the memoized ValidatedTextField can skip re-renders
const updateFormValue = useMemo(
() =>
updateValue((updater) =>
@@ -66,71 +67,45 @@ const SensorsAnalogDialog = ({
[setEditItem]
);
// Memoize helper functions to check sensor type conditions
const isCounterOrRate = useMemo(
() =>
editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.RATE ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
[editItem.t]
);
const isCounter = useMemo(
() =>
editItem.t === AnalogType.COUNTER ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
[editItem.t]
);
const isFreqType = useMemo(
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
[editItem.t]
);
const isPWM = useMemo(
() =>
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2,
[editItem.t]
);
const isDACOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26),
[editItem.t, editItem.g]
);
const isDigitalOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26,
[editItem.t, editItem.g]
);
const isCounterOrRate =
editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.RATE ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
const isCounter =
editItem.t === AnalogType.COUNTER ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
const isFreqType =
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
const isPWM =
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2;
const isDACOutGPIO =
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26);
const isDigitalOutGPIO =
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
// Memoize menu items to avoid recreation on each render
const analogTypeMenuItems = useMemo(
() =>
AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 }))
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => (
<MenuItem
key={name}
value={value}
disabled={disabledTypeList?.includes(value)}
>
{name}
</MenuItem>
)),
[disabledTypeList]
);
const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
name: val,
value: i + 1
}))
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => (
<MenuItem
key={name}
value={value}
disabled={disabledTypeList?.includes(value)}
>
{name}
</MenuItem>
));
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
));
const analogGPIOMenuItems = () =>
// add selectedItem.g to the list
@@ -157,16 +132,16 @@ const SensorsAnalogDialog = ({
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = useCallback(async () => {
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -174,17 +149,13 @@ const SensorsAnalogDialog = ({
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [validator, editItem, onSave]);
};
const remove = useCallback(() => {
const remove = () => {
onSave({ ...editItem, d: true });
}, [editItem, onSave]);
};
const dialogTitle = useMemo(
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -50,6 +50,7 @@ const SensorsTemperatureDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
// Stable handler reference so the memoized ValidatedTextField can skip re-renders
const updateFormValue = useMemo(
() =>
updateValue(
@@ -69,16 +70,13 @@ const SensorsTemperatureDialog = ({
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = useCallback(async () => {
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -86,29 +84,11 @@ const SensorsTemperatureDialog = ({
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [validator, editItem, onSave]);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
const slotProps = useMemo(
() => ({
input: {
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}),
[]
);
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
@@ -128,12 +108,23 @@ const SensorsTemperatureDialog = ({
<TextField
name="o"
label={LL.OFFSET()}
value={offsetValue}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
slotProps={slotProps}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
)
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}}
/>
</Grid>
</Grid>

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext } from 'react';
import { memo, useContext } from 'react';
import PersonIcon from '@mui/icons-material/Person';
import {
@@ -23,9 +23,9 @@ const UserProfileComponent = () => {
useLayoutTitle(LL.USER_PROFILE());
const handleSignOut = useCallback(() => {
const handleSignOut = () => {
signOut(true);
}, [signOut]);
};
return (
<SectionContent>