diff --git a/interface/src/app/main/Customizations.tsx b/interface/src/app/main/Customizations.tsx index d975e6442..013492275 100644 --- a/interface/src/app/main/Customizations.tsx +++ b/interface/src/app/main/Customizations.tsx @@ -11,6 +11,7 @@ import WarningIcon from '@mui/icons-material/Warning'; import { Box, Button, + Checkbox, Dialog, DialogActions, DialogContent, @@ -91,6 +92,8 @@ const Customizations = () => { const [deviceEntities, setDeviceEntities] = useState([]); const [confirmReset, setConfirmReset] = useState(false); const [selectedFilters, setSelectedFilters] = useState(0); + const [modifiedOnly, setModifiedOnly] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); const [search, setSearch] = useState(''); const [selectedDeviceEntity, setSelectedDeviceEntity] = useState(); const [dialogOpen, setDialogOpen] = useState(false); @@ -173,7 +176,7 @@ const Customizations = () => { const entities_theme = useTheme({ Table: ` - --data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); + --data-table-library_grid-template-columns: 32px 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); `, BaseRow: ` font-size: 14px; @@ -182,10 +185,10 @@ const Customizations = () => { } `, BaseCell: ` - &:nth-of-type(3) { + &:nth-of-type(4) { text-align: right; } - &:nth-of-type(4) { + &:nth-of-type(5) { text-align: right; } &:last-of-type { @@ -221,13 +224,13 @@ const Customizations = () => { } `, Cell: ` - &:nth-of-type(2) { + &:nth-of-type(3) { padding: 8px; } - &:nth-of-type(3) { + &:nth-of-type(4) { padding-right: 4px; } - &:nth-of-type(4) { + &:nth-of-type(5) { padding-right: 4px; } &:last-of-type { @@ -245,6 +248,11 @@ const Customizations = () => { ); } + // Saved customization (c), or an edit that is not applied yet. + function isModified(de: DeviceEntity) { + return !!de.c || hasEntityChanged(de); + } + useEffect(() => { if (deviceEntities.length) { const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de)); @@ -267,6 +275,7 @@ const Customizations = () => { setSelectedDeviceBrand(device.b); } setNumChanges(0); + setSelectedIds(new Set()); setRestartNeeded(false); } } @@ -331,24 +340,61 @@ const Customizations = () => { const filter_entity = (de: DeviceEntity) => (de.m & selectedFilters || !selectedFilters) && + (!modifiedOnly || isModified(de)) && formatName(de, true).toLowerCase().includes(search.toLowerCase()); + // Checked rows when any are checked, otherwise every row the filter is showing. + const inBulkTarget = (de: DeviceEntity) => + selectedIds.size > 0 ? selectedIds.has(de.id) : filter_entity(de); + const maskDisabled = (set: boolean) => { + const excludeMask = + DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; 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 - }; + if (!inBulkTarget(de)) { + return de; } - return de; + return { + ...de, + m: set ? de.m | excludeMask : de.m & ~excludeMask + }; }) ); }; + const maskReadonly = (set: boolean) => { + setDeviceEntities((prev) => + prev.map((de) => { + if (!inBulkTarget(de) || !de.w) { + return de; + } + return { + ...de, + m: set + ? de.m | DeviceEntityMask.DV_READONLY + : de.m & ~DeviceEntityMask.DV_READONLY + }; + }) + ); + }; + + const resetSelected = () => { + setDeviceEntities((prev) => + prev.map((de) => { + if (!selectedIds.has(de.id)) { + return de; + } + const next: DeviceEntity = { ...de, m: 0, c: false }; + delete next.cn; + delete next.mi; + delete next.ma; + return next; + }) + ); + setSelectedIds(new Set()); + }; + const resetCustomization = async () => { try { await sendResetCustomizations(); @@ -414,6 +460,8 @@ const Customizations = () => { }) .then(() => { toast.success(LL.CUSTOMIZATIONS_SAVED()); + // Reload so cleared min/max fall back to the built-in limits and the modified flag matches the file. + void sendDeviceEntities(selectedDevice); }) .catch((error: Error) => { if (error.message === 'Reboot required') { @@ -541,6 +589,34 @@ const Customizations = () => { ); const filteredEntities = deviceEntities.filter((de) => filter_entity(de)); + const allFilteredSelected = + filteredEntities.length > 0 && + filteredEntities.every((de) => selectedIds.has(de.id)); + const someFilteredSelected = filteredEntities.some((de) => selectedIds.has(de.id)); + + const toggleSelected = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const toggleSelectFiltered = () => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (allFilteredSelected) { + filteredEntities.forEach((de) => next.delete(de.id)); + } else { + filteredEntities.forEach((de) => next.add(de.id)); + } + return next; + }); + }; const renderDeviceData = () => { return ( @@ -608,6 +684,17 @@ const Customizations = () => { + + + + + + + + + + {selectedIds.size > 0 && ( + + + + )} {LL.SHOWING()} {filteredEntities.length}/{deviceEntities.length} @@ -650,6 +774,16 @@ const Customizations = () => { <>
+ + + {LL.OPTIONS()} {LL.NAME(1)} {LL.MIN()} @@ -660,6 +794,14 @@ const Customizations = () => { {tableList.map((de: DeviceEntity) => ( editDeviceEntity(de)}> + event.stopPropagation()}> + toggleSelected(de.id)} + /> + diff --git a/interface/src/app/main/CustomizationsDialog.tsx b/interface/src/app/main/CustomizationsDialog.tsx index 9e5d68ca9..d701487bb 100644 --- a/interface/src/app/main/CustomizationsDialog.tsx +++ b/interface/src/app/main/CustomizationsDialog.tsx @@ -3,6 +3,7 @@ import { memo, useEffect, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import CloseIcon from '@mui/icons-material/Close'; import DoneIcon from '@mui/icons-material/Done'; +import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; import { Box, Button, @@ -101,6 +102,14 @@ const CustomizationsDialog = ({ setEditItem((prev) => ({ ...prev, m: updatedItem.m })); }; + const remove = () => { + const next: DeviceEntity = { ...editItem, m: 0, c: false }; + delete next.cn; + delete next.mi; + delete next.ma; + onSave(next); + }; + return ( {`${LL.EDIT()} ${LL.ENTITY()}`} @@ -169,6 +178,15 @@ const CustomizationsDialog = ({ + +