@iobroker/dm-gui-components 10.0.20 → 10.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/build/DeviceCard.d.ts +46 -45
- package/build/DeviceCard.js +130 -163
- package/build/DeviceCard.js.map +1 -1
- package/build/DeviceFields.d.ts +75 -0
- package/build/DeviceFields.js +190 -0
- package/build/DeviceFields.js.map +1 -0
- package/build/DeviceList.d.ts +59 -7
- package/build/DeviceList.js +260 -87
- package/build/DeviceList.js.map +1 -1
- package/build/InstanceActionButton.js +3 -1
- package/build/InstanceActionButton.js.map +1 -1
- package/build/LazyRender.d.ts +69 -0
- package/build/LazyRender.js +128 -0
- package/build/LazyRender.js.map +1 -0
- package/build/StateOrObjectHandler.d.ts +3 -0
- package/build/StateOrObjectHandler.js +57 -7
- package/build/StateOrObjectHandler.js.map +1 -1
- package/build/TooltipButton.d.ts +2 -0
- package/build/TooltipButton.js +2 -2
- package/build/TooltipButton.js.map +1 -1
- package/build/Utils.d.ts +7 -0
- package/build/Utils.js +13 -0
- package/build/Utils.js.map +1 -1
- package/build/hooks.js +22 -3
- package/build/hooks.js.map +1 -1
- package/build/i18n/de.json +1 -0
- package/build/i18n/en.json +1 -0
- package/build/i18n/es.json +1 -0
- package/build/i18n/fr.json +1 -0
- package/build/i18n/it.json +1 -0
- package/build/i18n/nl.json +1 -0
- package/build/i18n/pl.json +1 -0
- package/build/i18n/pt.json +1 -0
- package/build/i18n/ru.json +1 -0
- package/build/i18n/uk.json +1 -0
- package/build/i18n/zh-cn.json +1 -0
- package/package.json +16 -15
package/build/DeviceList.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { IconButton, InputAdornment, TextField, Toolbar, Tooltip, LinearProgress, Select, MenuItem,
|
|
2
|
+
import { IconButton, InputAdornment, TextField, Toolbar, Tooltip, LinearProgress, Select, MenuItem, Card, CardActionArea, CardContent, Typography, Menu, Checkbox, ListSubheader, } from '@mui/material';
|
|
3
3
|
import { ArrowBack, Clear, QuestionMark, Refresh, FilterAlt, FilterAltOff, SystemUpdateAlt, BatteryAlert, Tune, } from '@mui/icons-material';
|
|
4
4
|
import { I18n, DeviceTypeIcon, Icon, InfoBox } from '@iobroker/gui-components';
|
|
5
|
-
import DeviceCard, { DeviceCardSkeleton } from './DeviceCard';
|
|
5
|
+
import DeviceCard, { DeviceCardSkeleton, CARD_WIDTH, CARD_MIN_HEIGHT, CARD_MARGIN, SMALL_CARD_WIDTH, SMALL_CARD_MIN_HEIGHT, SMALL_CARD_MARGIN, } from './DeviceCard';
|
|
6
6
|
import { getTranslation, renderIcon } from './Utils';
|
|
7
7
|
import Communication from './Communication';
|
|
8
8
|
import InstanceActionButton from './InstanceActionButton';
|
|
9
9
|
import { StatusIndicators } from './StatusIndicator';
|
|
10
10
|
import { StateOrObjectHandler } from './StateOrObjectHandler';
|
|
11
|
+
import { DeviceFieldsResolver } from './DeviceFields';
|
|
12
|
+
import { LazyRender, LazyRenderObserver } from './LazyRender';
|
|
11
13
|
import de from './i18n/de.json';
|
|
12
14
|
import en from './i18n/en.json';
|
|
13
15
|
import ru from './i18n/ru.json';
|
|
@@ -19,6 +21,44 @@ import es from './i18n/es.json';
|
|
|
19
21
|
import pl from './i18n/pl.json';
|
|
20
22
|
import uk from './i18n/uk.json';
|
|
21
23
|
import zhCn from './i18n/zh-cn.json';
|
|
24
|
+
/**
|
|
25
|
+
* The key of a device. It is used as React key, for the resolved fields and for the filter, so it is
|
|
26
|
+
* computed on every render for every device. `JSON.stringify` adds up noticeably with a few hundred
|
|
27
|
+
* devices, therefore the result is cached per device object.
|
|
28
|
+
*/
|
|
29
|
+
const deviceKeys = new WeakMap();
|
|
30
|
+
function deviceKey(device) {
|
|
31
|
+
let key = deviceKeys.get(device);
|
|
32
|
+
if (key === undefined) {
|
|
33
|
+
key = JSON.stringify(device.id);
|
|
34
|
+
deviceKeys.set(device, key);
|
|
35
|
+
}
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* How many skeletons are shown at most while loading. They stand for devices that are not there yet,
|
|
40
|
+
* and a few hundred pulsing placeholders cost more than the cards they are waiting for.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_SKELETONS = 12;
|
|
43
|
+
/**
|
|
44
|
+
* The toolbar is grey in both themes, so its content is always white. Buttons and inputs bring their own
|
|
45
|
+
* colors (primary blue, the text color of the theme), which are hard to read on grey and are overridden here.
|
|
46
|
+
*/
|
|
47
|
+
const TOOLBAR_SX = {
|
|
48
|
+
backgroundColor: '#777',
|
|
49
|
+
color: '#fff',
|
|
50
|
+
'& .MuiButtonBase-root.Mui-disabled': { color: 'rgba(255, 255, 255, 0.4)' },
|
|
51
|
+
'& .MuiInputBase-root, & .MuiSelect-icon': { color: 'inherit' },
|
|
52
|
+
'& .MuiInputBase-input::placeholder': { opacity: 0.7 },
|
|
53
|
+
'& .MuiInput-underline::before': { borderBottomColor: 'rgba(255, 255, 255, 0.7)' },
|
|
54
|
+
'& .MuiInput-underline:hover:not(.Mui-disabled, .Mui-error)::before': { borderBottomColor: '#fff' },
|
|
55
|
+
'& .MuiInput-underline::after': { borderBottomColor: '#fff' },
|
|
56
|
+
};
|
|
57
|
+
/** Marks an active toggle button in the toolbar, as the primary color is not readable there */
|
|
58
|
+
const TOOLBAR_TOGGLE_ACTIVE_SX = {
|
|
59
|
+
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
|
60
|
+
'&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.35)' },
|
|
61
|
+
};
|
|
22
62
|
/** Returns true if any of the device status objects carries a battery value */
|
|
23
63
|
function hasBatteryStatus(status) {
|
|
24
64
|
if (!status || typeof status === 'string') {
|
|
@@ -43,14 +83,32 @@ export default class DeviceList extends Communication {
|
|
|
43
83
|
alive = null;
|
|
44
84
|
lastTriggerLoad = 0;
|
|
45
85
|
filterTimeout = null;
|
|
46
|
-
/** Resolved model value per device (stringified id -> model), reported by the cards to build the model dropdown */
|
|
47
|
-
modelValues = new Map();
|
|
48
86
|
language = I18n.getLanguage();
|
|
49
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* One handler for the toolbar indicators and for all cards, so that several devices referring to
|
|
89
|
+
* the same object or state share a single subscription on the socket.
|
|
90
|
+
*/
|
|
50
91
|
stateOrObjectHandler;
|
|
92
|
+
/** Resolves the device fields that may be bound to a state or an object (name, model, ...) */
|
|
93
|
+
fieldsResolver;
|
|
94
|
+
/** One `IntersectionObserver` for all cards: only what is near the viewport is really rendered */
|
|
95
|
+
lazyObserver = new LazyRenderObserver();
|
|
96
|
+
/** The scrolling container of the card list; it is the root of `lazyObserver` */
|
|
97
|
+
containerRef = React.createRef();
|
|
98
|
+
/** A load is running. A second one must not tear down the same list in parallel */
|
|
99
|
+
loadingDevices = false;
|
|
100
|
+
/** A load was requested while another one was still running */
|
|
101
|
+
reloadRequested = false;
|
|
102
|
+
/** Devices of the running initial load that are not published to the state yet */
|
|
103
|
+
pendingDevices = null;
|
|
104
|
+
pendingTotal;
|
|
105
|
+
flushTimer = null;
|
|
106
|
+
/** Memoized result of `getHiddenIndicators`, so that all cards keep getting the same array */
|
|
107
|
+
hiddenIndicatorsCache = null;
|
|
51
108
|
constructor(props) {
|
|
52
109
|
super(props);
|
|
53
110
|
this.stateOrObjectHandler = new StateOrObjectHandler(this.props.socket);
|
|
111
|
+
this.fieldsResolver = new DeviceFieldsResolver(this.stateOrObjectHandler, this.onFieldsChanged);
|
|
54
112
|
if (!DeviceList.i18nInitialized) {
|
|
55
113
|
DeviceList.i18nInitialized = true;
|
|
56
114
|
I18n.extendTranslations({
|
|
@@ -84,6 +142,7 @@ export default class DeviceList extends Communication {
|
|
|
84
142
|
modelOptions: [],
|
|
85
143
|
indicatorVisibility: {},
|
|
86
144
|
indicatorsAnchor: null,
|
|
145
|
+
fieldsVersion: 0,
|
|
87
146
|
};
|
|
88
147
|
if (this.props.selectedInstance === undefined) {
|
|
89
148
|
// Start with the root page that shows all instances as cards
|
|
@@ -153,9 +212,8 @@ export default class DeviceList extends Communication {
|
|
|
153
212
|
window.localStorage.removeItem('dmSelectedInstance');
|
|
154
213
|
this.props.onInstanceChanged?.('');
|
|
155
214
|
this.alive = null;
|
|
156
|
-
this.
|
|
215
|
+
this.applyDevices([], {
|
|
157
216
|
selectedInstance: '',
|
|
158
|
-
devices: [],
|
|
159
217
|
totalDevices: undefined,
|
|
160
218
|
instanceInfo: null,
|
|
161
219
|
alive: null,
|
|
@@ -169,6 +227,7 @@ export default class DeviceList extends Communication {
|
|
|
169
227
|
this.setState({ dmInstances: null }, () => this.loadAdapters().catch(console.error));
|
|
170
228
|
}
|
|
171
229
|
async componentDidMount() {
|
|
230
|
+
this.lazyObserver.setRoot(this.containerRef.current);
|
|
172
231
|
let alive = false;
|
|
173
232
|
// If an instance selector must be shown
|
|
174
233
|
if (this.props.selectedInstance === undefined) {
|
|
@@ -207,10 +266,98 @@ export default class DeviceList extends Communication {
|
|
|
207
266
|
}
|
|
208
267
|
}
|
|
209
268
|
componentWillUnmount() {
|
|
269
|
+
super.componentWillUnmount();
|
|
270
|
+
this.cancelDeviceFlush();
|
|
271
|
+
this.fieldsResolver.destroy();
|
|
272
|
+
this.stateOrObjectHandler.destroy();
|
|
273
|
+
this.lazyObserver.destroy();
|
|
274
|
+
if (this.filterTimeout) {
|
|
275
|
+
clearTimeout(this.filterTimeout);
|
|
276
|
+
this.filterTimeout = null;
|
|
277
|
+
}
|
|
210
278
|
if (this.state.selectedInstance) {
|
|
211
279
|
this.props.socket.unsubscribeState(`system.adapter.${this.state.selectedInstance}.alive`, this.aliveHandler);
|
|
212
280
|
}
|
|
213
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* The side effects that used to be triggered from `render()`. A render must not have any, and
|
|
284
|
+
* the `setTimeout` there was executed on every single render.
|
|
285
|
+
*/
|
|
286
|
+
componentDidUpdate() {
|
|
287
|
+
// The scrolling container only exists after the first render
|
|
288
|
+
this.lazyObserver.setRoot(this.containerRef.current);
|
|
289
|
+
if ((this.props.triggerLoad || 0) !== this.lastTriggerLoad) {
|
|
290
|
+
this.lastTriggerLoad = this.props.triggerLoad || 0;
|
|
291
|
+
this.loadDeviceList();
|
|
292
|
+
}
|
|
293
|
+
// if instance changed
|
|
294
|
+
if (this.lastInstance !== this.state.selectedInstance) {
|
|
295
|
+
this.lastInstance = this.state.selectedInstance;
|
|
296
|
+
// The indicator visibility is stored per instance
|
|
297
|
+
this.setState({
|
|
298
|
+
indicatorVisibility: DeviceList.loadIndicatorVisibility(this.state.selectedInstance),
|
|
299
|
+
indicatorsAnchor: null,
|
|
300
|
+
});
|
|
301
|
+
if (this.state.selectedInstance) {
|
|
302
|
+
this.loadAllData().catch(error => console.error(error));
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
this.loadDeviceList();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (this.props.selectedInstance && this.props.selectedInstance !== this.state.selectedInstance) {
|
|
309
|
+
this.setState({ selectedInstance: this.props.selectedInstance });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* A value bound to a state or an object arrived. The cards are `PureComponent`s and receive the
|
|
314
|
+
* values through their `fields` property, so the list only has to trigger a re-render.
|
|
315
|
+
*/
|
|
316
|
+
onFieldsChanged = () => {
|
|
317
|
+
const modelOptions = this.fieldsResolver.getModels();
|
|
318
|
+
const modelsChanged = modelOptions.length !== this.state.modelOptions.length ||
|
|
319
|
+
modelOptions.some((model, i) => model !== this.state.modelOptions[i]);
|
|
320
|
+
this.setState({
|
|
321
|
+
fieldsVersion: this.state.fieldsVersion + 1,
|
|
322
|
+
modelOptions: modelsChanged ? modelOptions : this.state.modelOptions,
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* Publish a new device list.
|
|
327
|
+
*
|
|
328
|
+
* The resolver is updated *before* the state, because it resolves literal values synchronously -
|
|
329
|
+
* the cards and the filter therefore already have their values on the very first render.
|
|
330
|
+
*/
|
|
331
|
+
applyDevices(devices, state) {
|
|
332
|
+
this.fieldsResolver.setDevices(devices.map(device => ({ key: deviceKey(device), device })));
|
|
333
|
+
this.setState({ ...state, devices });
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Publish the devices loaded so far, but at most every 100 ms: a fast backend delivers a dozen
|
|
337
|
+
* batches in a row, and every single one of them would otherwise re-render the complete list.
|
|
338
|
+
*/
|
|
339
|
+
scheduleDeviceFlush(devices, total) {
|
|
340
|
+
this.pendingDevices = devices;
|
|
341
|
+
this.pendingTotal = total;
|
|
342
|
+
if (this.flushTimer) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.flushTimer = setTimeout(() => {
|
|
346
|
+
this.flushTimer = null;
|
|
347
|
+
const pending = this.pendingDevices;
|
|
348
|
+
if (pending) {
|
|
349
|
+
this.pendingDevices = null;
|
|
350
|
+
this.applyDevices(pending, { loading: true, totalDevices: this.pendingTotal });
|
|
351
|
+
}
|
|
352
|
+
}, 100);
|
|
353
|
+
}
|
|
354
|
+
cancelDeviceFlush() {
|
|
355
|
+
if (this.flushTimer) {
|
|
356
|
+
clearTimeout(this.flushTimer);
|
|
357
|
+
this.flushTimer = null;
|
|
358
|
+
}
|
|
359
|
+
this.pendingDevices = null;
|
|
360
|
+
}
|
|
214
361
|
aliveHandler = (id, state) => {
|
|
215
362
|
if (this.state.selectedInstance && id === `system.adapter.${this.state.selectedInstance}.alive`) {
|
|
216
363
|
const alive = !!state?.val;
|
|
@@ -237,9 +384,20 @@ export default class DeviceList extends Communication {
|
|
|
237
384
|
return new Promise(resolve => this.setState({ instanceInfo, apiVersionError: !['v1', 'v2', 'v3'].includes(instanceInfo.apiVersion) }, () => resolve(instanceInfo)));
|
|
238
385
|
}
|
|
239
386
|
/**
|
|
240
|
-
* Load devices
|
|
387
|
+
* Load devices.
|
|
388
|
+
*
|
|
389
|
+
* The list is deliberately *not* emptied first: as long as the keys stay the same React keeps the
|
|
390
|
+
* existing cards, so a reload - the matter adapter triggers one on every `updateController`
|
|
391
|
+
* message - no longer unmounts and remounts a few hundred cards with all their subscriptions.
|
|
241
392
|
*/
|
|
242
393
|
loadDeviceList() {
|
|
394
|
+
if (this.loadingDevices) {
|
|
395
|
+
// Remember the request and repeat the load once the running one has finished, instead of
|
|
396
|
+
// running two loads over the same list at the same time.
|
|
397
|
+
this.reloadRequested = true;
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
this.loadingDevices = true;
|
|
243
401
|
this.setState({ loading: true }, async () => {
|
|
244
402
|
console.log(`Loading devices for ${this.state.selectedInstance}...`);
|
|
245
403
|
let alive = this.alive;
|
|
@@ -268,14 +426,20 @@ export default class DeviceList extends Communication {
|
|
|
268
426
|
alive = false;
|
|
269
427
|
}
|
|
270
428
|
}
|
|
429
|
+
// Only the very first load shows the devices step by step (with skeletons for the ones
|
|
430
|
+
// that are still missing). A reload keeps the current list until the new one is complete,
|
|
431
|
+
// so the already rendered cards survive.
|
|
432
|
+
const incremental = !this.state.devices.length;
|
|
271
433
|
let devices = [];
|
|
272
434
|
try {
|
|
273
435
|
this.alive = alive;
|
|
274
|
-
this.setState({
|
|
436
|
+
this.setState({ loading: !!alive, alive });
|
|
275
437
|
if (alive) {
|
|
276
438
|
await this.loadDevices((batch, total) => {
|
|
277
439
|
devices = devices.concat(batch);
|
|
278
|
-
|
|
440
|
+
if (incremental) {
|
|
441
|
+
this.scheduleDeviceFlush(devices, total);
|
|
442
|
+
}
|
|
279
443
|
console.log(`Loaded ${devices.length} of ${total} devices...`);
|
|
280
444
|
});
|
|
281
445
|
}
|
|
@@ -284,21 +448,27 @@ export default class DeviceList extends Communication {
|
|
|
284
448
|
console.error(error);
|
|
285
449
|
devices = [];
|
|
286
450
|
}
|
|
287
|
-
this.
|
|
451
|
+
this.cancelDeviceFlush();
|
|
452
|
+
this.applyDevices(devices, { loading: false, totalDevices: devices.length });
|
|
288
453
|
console.log(`Loaded ${devices.length} devices for ${this.state.selectedInstance}`);
|
|
454
|
+
this.loadingDevices = false;
|
|
455
|
+
if (this.reloadRequested) {
|
|
456
|
+
this.reloadRequested = false;
|
|
457
|
+
setTimeout(() => this.loadDeviceList(), 0);
|
|
458
|
+
}
|
|
289
459
|
});
|
|
290
460
|
}
|
|
291
461
|
updateDevice(update) {
|
|
292
462
|
const updateId = JSON.stringify(update.id);
|
|
293
|
-
this.
|
|
463
|
+
this.applyDevices(this.state.devices.map(d => (deviceKey(d) === updateId ? update : d)), {});
|
|
294
464
|
}
|
|
295
465
|
deleteDevice(deviceId) {
|
|
296
466
|
const deleteId = JSON.stringify(deviceId);
|
|
297
|
-
const devices = this.state.devices.filter(d =>
|
|
467
|
+
const devices = this.state.devices.filter(d => deviceKey(d) !== deleteId);
|
|
298
468
|
const totalDevices = this.state.totalDevices && devices.length < this.state.devices.length
|
|
299
469
|
? this.state.totalDevices - 1
|
|
300
470
|
: undefined;
|
|
301
|
-
this.
|
|
471
|
+
this.applyDevices(devices, { totalDevices });
|
|
302
472
|
}
|
|
303
473
|
getText(text) {
|
|
304
474
|
if (typeof text === 'object') {
|
|
@@ -388,27 +558,38 @@ export default class DeviceList extends Communication {
|
|
|
388
558
|
info.title ? (React.createElement(Typography, { variant: "body2", color: "textSecondary", style: { textAlign: 'center' } }, info.title)) : null))));
|
|
389
559
|
});
|
|
390
560
|
}
|
|
391
|
-
/**
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
561
|
+
/**
|
|
562
|
+
* Apply the text filter and the "only updatable" / "only battery problem" filters.
|
|
563
|
+
*
|
|
564
|
+
* This used to be done by every card for itself. A filtered out card stayed mounted with all its
|
|
565
|
+
* subscriptions and only rendered nothing, so filtering did not reduce the load at all - and a
|
|
566
|
+
* card that is not rendered cannot filter itself in the first place.
|
|
567
|
+
*/
|
|
568
|
+
filterDevices(devices) {
|
|
569
|
+
const filter = (this.props.embedded ? this.props.filter : this.state.filter)?.toLowerCase();
|
|
570
|
+
const field = this.props.embedded ? 'name' : this.getEffectiveFilterField();
|
|
571
|
+
const onlyUpdatable = !this.props.embedded && this.state.onlyUpdatable;
|
|
572
|
+
const onlyBatteryProblem = !this.props.embedded && this.state.onlyBatteryProblem;
|
|
573
|
+
if (!filter && !onlyUpdatable && !onlyBatteryProblem) {
|
|
574
|
+
return devices;
|
|
575
|
+
}
|
|
576
|
+
return devices.filter(device => {
|
|
577
|
+
const fields = this.fieldsResolver.getValues(deviceKey(device));
|
|
578
|
+
if (filter &&
|
|
579
|
+
!String(fields[field] ?? '')
|
|
580
|
+
.toLowerCase()
|
|
581
|
+
.includes(filter)) {
|
|
582
|
+
return false;
|
|
397
583
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
if (modelOptions.length !== this.state.modelOptions.length ||
|
|
408
|
-
modelOptions.some((model, i) => model !== this.state.modelOptions[i])) {
|
|
409
|
-
this.setState({ modelOptions });
|
|
410
|
-
}
|
|
411
|
-
};
|
|
584
|
+
if (onlyUpdatable && !fields.updateAvailable) {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
if (onlyBatteryProblem && !fields.batteryProblem) {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
return true;
|
|
591
|
+
});
|
|
592
|
+
}
|
|
412
593
|
/** The selected filter field, falling back to `name` if the stored field is not available (e.g. no models found) */
|
|
413
594
|
getEffectiveFilterField() {
|
|
414
595
|
const field = this.state.filterField;
|
|
@@ -464,7 +645,7 @@ export default class DeviceList extends Communication {
|
|
|
464
645
|
input: {
|
|
465
646
|
autoComplete: 'new-password',
|
|
466
647
|
endAdornment: this.state.filterText ? (React.createElement(InputAdornment, { position: "end" },
|
|
467
|
-
React.createElement(IconButton, { tabIndex: -1, onClick: () => this.handleFilterChange(''), edge: "end" },
|
|
648
|
+
React.createElement(IconButton, { tabIndex: -1, onClick: () => this.handleFilterChange(''), color: "inherit", edge: "end" },
|
|
468
649
|
React.createElement(Clear, null)))) : null,
|
|
469
650
|
},
|
|
470
651
|
htmlInput: {
|
|
@@ -529,9 +710,16 @@ export default class DeviceList extends Communication {
|
|
|
529
710
|
}
|
|
530
711
|
/** IDs of the configurable indicators the user has switched off */
|
|
531
712
|
getHiddenIndicators() {
|
|
532
|
-
|
|
713
|
+
const hidden = this.getConfigurableIndicators()
|
|
533
714
|
.filter(indicator => !this.isIndicatorVisible(indicator))
|
|
534
715
|
.map(indicator => indicator.id);
|
|
716
|
+
// The array is handed to every card. A new array on every render would defeat the shallow
|
|
717
|
+
// property comparison of `DeviceCard`.
|
|
718
|
+
const key = hidden.join(',');
|
|
719
|
+
if (!this.hiddenIndicatorsCache || this.hiddenIndicatorsCache.key !== key) {
|
|
720
|
+
this.hiddenIndicatorsCache = { key, value: hidden };
|
|
721
|
+
}
|
|
722
|
+
return this.hiddenIndicatorsCache.value;
|
|
535
723
|
}
|
|
536
724
|
toggleIndicator(indicator) {
|
|
537
725
|
const indicatorVisibility = {
|
|
@@ -549,7 +737,7 @@ export default class DeviceList extends Communication {
|
|
|
549
737
|
}
|
|
550
738
|
return (React.createElement(React.Fragment, null,
|
|
551
739
|
React.createElement(Tooltip, { title: getTranslation('indicatorsTooltip'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
|
|
552
|
-
React.createElement(IconButton, { size: "small", onClick: e => this.setState({ indicatorsAnchor: e.currentTarget }) },
|
|
740
|
+
React.createElement(IconButton, { size: "small", color: "inherit", onClick: e => this.setState({ indicatorsAnchor: e.currentTarget }) },
|
|
553
741
|
React.createElement(Tune, null))),
|
|
554
742
|
React.createElement(Menu, { open: !!this.state.indicatorsAnchor, anchorEl: this.state.indicatorsAnchor, onClose: () => this.setState({ indicatorsAnchor: null }) },
|
|
555
743
|
React.createElement(ListSubheader, { style: { lineHeight: '32px' } }, getTranslation('indicatorsTitle')),
|
|
@@ -585,35 +773,6 @@ export default class DeviceList extends Communication {
|
|
|
585
773
|
const emptyStyle = {
|
|
586
774
|
padding: 25,
|
|
587
775
|
};
|
|
588
|
-
if ((this.props.triggerLoad || 0) !== this.lastTriggerLoad) {
|
|
589
|
-
this.lastTriggerLoad = this.props.triggerLoad || 0;
|
|
590
|
-
setTimeout(() => this.loadDeviceList(), 50);
|
|
591
|
-
}
|
|
592
|
-
// if instance changed
|
|
593
|
-
if (this.lastInstance !== this.state.selectedInstance) {
|
|
594
|
-
this.lastInstance = this.state.selectedInstance;
|
|
595
|
-
setTimeout(async () => {
|
|
596
|
-
// The indicator visibility is stored per instance
|
|
597
|
-
this.setState({
|
|
598
|
-
indicatorVisibility: DeviceList.loadIndicatorVisibility(this.state.selectedInstance),
|
|
599
|
-
indicatorsAnchor: null,
|
|
600
|
-
});
|
|
601
|
-
if (this.state.selectedInstance) {
|
|
602
|
-
try {
|
|
603
|
-
await this.loadAllData();
|
|
604
|
-
}
|
|
605
|
-
catch (error) {
|
|
606
|
-
console.error(error);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
else {
|
|
610
|
-
this.loadDeviceList();
|
|
611
|
-
}
|
|
612
|
-
}, 50);
|
|
613
|
-
}
|
|
614
|
-
if (this.props.selectedInstance && this.props.selectedInstance !== this.state.selectedInstance) {
|
|
615
|
-
setTimeout(() => this.setState({ selectedInstance: this.props.selectedInstance }), 50);
|
|
616
|
-
}
|
|
617
776
|
const deviceGroups = [];
|
|
618
777
|
const showRootPage = !this.props.embedded && this.props.selectedInstance === undefined && !this.state.selectedInstance;
|
|
619
778
|
let list;
|
|
@@ -680,21 +839,37 @@ export default class DeviceList extends Communication {
|
|
|
680
839
|
}
|
|
681
840
|
}
|
|
682
841
|
if (this.state.selectedInstance) {
|
|
842
|
+
const selectedInstance = this.state.selectedInstance;
|
|
683
843
|
const hiddenIndicators = this.getHiddenIndicators();
|
|
684
|
-
|
|
844
|
+
const smallCards = this.props.smallCards ?? this.state.instanceInfo?.smallCards;
|
|
845
|
+
const identifierLabel = this.state.instanceInfo?.identifierLabel ?? 'ID';
|
|
846
|
+
const cardWidth = smallCards ? SMALL_CARD_WIDTH : CARD_WIDTH;
|
|
847
|
+
const cardMinHeight = smallCards ? SMALL_CARD_MIN_HEIGHT : CARD_MIN_HEIGHT;
|
|
848
|
+
const cardMargin = smallCards ? SMALL_CARD_MARGIN : CARD_MARGIN;
|
|
849
|
+
const visibleDevices = this.filterDevices(filteredDevices);
|
|
850
|
+
// Every card is wrapped in a `LazyRender`: the placeholder always occupies the full
|
|
851
|
+
// footprint of a card, but the card itself is only built while it is near the visible
|
|
852
|
+
// area. Without that, 80 devices mean thousands of DOM nodes and several hundred MUI
|
|
853
|
+
// tooltips and buttons at once - which is what makes Safari (and especially iPadOS)
|
|
854
|
+
// hang and finally kill the tab.
|
|
855
|
+
list = visibleDevices.map(device => {
|
|
856
|
+
const key = deviceKey(device);
|
|
857
|
+
return (React.createElement(LazyRender, { key: key, observer: this.lazyObserver, width: cardWidth, minHeight: cardMinHeight, margin: cardMargin }, () => (React.createElement(DeviceCard, { fillContainer: true, smallCards: smallCards, alive: !!this.state.alive, id: device.id, identifierLabel: identifierLabel, device: device, fields: this.fieldsResolver.getValues(key), stateOrObjectHandler: this.stateOrObjectHandler, instanceId: selectedInstance, uploadImagesToInstance: this.props.uploadImagesToInstance, deviceHandler: this.deviceHandler, controlHandler: this.controlHandler, controlStateHandler: this.controlStateHandler, socket: this.props.socket, themeName: this.props.themeName, themeType: this.props.themeType, theme: this.props.theme, isFloatComma: this.props.isFloatComma, dateFormat: this.props.dateFormat, hiddenIndicators: hiddenIndicators }))));
|
|
858
|
+
});
|
|
685
859
|
if (this.state.loading) {
|
|
686
|
-
|
|
860
|
+
// How many devices are still missing - deliberately not derived from `list`,
|
|
861
|
+
// which only contains the devices that pass the filter
|
|
862
|
+
const loaded = this.state.devices.length;
|
|
863
|
+
const missing = (this.state.totalDevices ?? loaded + 1) - loaded;
|
|
864
|
+
const skeletons = Math.min(missing, MAX_SKELETONS);
|
|
687
865
|
for (let i = 0; i < skeletons; i++) {
|
|
688
|
-
list.push(React.createElement(DeviceCardSkeleton, { key: `skeleton-${i}`, smallCards:
|
|
866
|
+
list.push(React.createElement(DeviceCardSkeleton, { key: `skeleton-${i}`, smallCards: smallCards, theme: this.props.theme }));
|
|
689
867
|
}
|
|
690
868
|
}
|
|
691
|
-
else if (this.state.devices.length > 0) {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
display: 'none',
|
|
696
|
-
},
|
|
697
|
-
} },
|
|
869
|
+
else if (!visibleDevices.length && this.state.devices.length > 0) {
|
|
870
|
+
// The filter is applied here now, so the list knows that nothing is left instead
|
|
871
|
+
// of having to hide the message with a CSS rule
|
|
872
|
+
list.push(React.createElement("div", { style: emptyStyle, key: "filtered" },
|
|
698
873
|
React.createElement("span", null, getTranslation('allDevicesFilteredOut'))));
|
|
699
874
|
}
|
|
700
875
|
}
|
|
@@ -711,8 +886,7 @@ export default class DeviceList extends Communication {
|
|
|
711
886
|
this.state.apiVersionError ? React.createElement("div", null, I18n.t('apiVersionError')) : list));
|
|
712
887
|
}
|
|
713
888
|
return (React.createElement("div", { style: { width: '100%', height: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' } },
|
|
714
|
-
React.createElement(Toolbar, { variant: "dense", style: {
|
|
715
|
-
backgroundColor: '#777',
|
|
889
|
+
React.createElement(Toolbar, { variant: "dense", sx: TOOLBAR_SX, style: {
|
|
716
890
|
display: 'flex',
|
|
717
891
|
flexWrap: 'wrap',
|
|
718
892
|
rowGap: 4,
|
|
@@ -724,17 +898,17 @@ export default class DeviceList extends Communication {
|
|
|
724
898
|
this.state.dmInstances &&
|
|
725
899
|
this.state.selectedInstance ? (React.createElement("div", { style: { display: 'flex', alignItems: 'center', gap: 8 } },
|
|
726
900
|
Object.keys(this.state.dmInstances).length > 1 ? (React.createElement(Tooltip, { title: getTranslation('backToInstancesList'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
|
|
727
|
-
React.createElement(IconButton, { onClick: () => this.backToInstancesList(), size: "small" },
|
|
901
|
+
React.createElement(IconButton, { onClick: () => this.backToInstancesList(), color: "inherit", size: "small" },
|
|
728
902
|
React.createElement(ArrowBack, null)))) : null,
|
|
729
903
|
this.state.dmInstances[this.state.selectedInstance]?.icon ? (React.createElement(Icon, { src: this.state.dmInstances[this.state.selectedInstance].icon, style: { width: 24, height: 24 } })) : null,
|
|
730
904
|
React.createElement("span", { style: { marginRight: 8 } }, this.state.selectedInstance))) : null,
|
|
731
905
|
this.props.selectedInstance === undefined && !this.state.selectedInstance ? (React.createElement(Tooltip, { title: getTranslation('refreshInstanceList'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
|
|
732
906
|
React.createElement("span", null,
|
|
733
|
-
React.createElement(IconButton, { onClick: () => this.refreshInstanceList(), disabled: !this.state.dmInstances, size: "small" },
|
|
907
|
+
React.createElement(IconButton, { onClick: () => this.refreshInstanceList(), disabled: !this.state.dmInstances, color: "inherit", size: "small" },
|
|
734
908
|
React.createElement(Refresh, null))))) : null,
|
|
735
909
|
this.state.selectedInstance ? (React.createElement(Tooltip, { title: getTranslation('refreshTooltip'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
|
|
736
910
|
React.createElement("span", null,
|
|
737
|
-
React.createElement(IconButton, { onClick: () => this.loadAllData(), disabled: !this.state.alive || this.state.apiVersionError, size: "small" },
|
|
911
|
+
React.createElement(IconButton, { onClick: () => this.loadAllData(), disabled: !this.state.alive || this.state.apiVersionError, color: "inherit", size: "small" },
|
|
738
912
|
React.createElement(Refresh, null))))) : null,
|
|
739
913
|
!this.state.apiVersionError && this.state.alive && this.renderInstanceActions(),
|
|
740
914
|
!this.state.apiVersionError && this.state.alive && this.state.instanceInfo?.indicators?.length ? (React.createElement(StatusIndicators, { indicators: this.state.instanceInfo.indicators.filter(indicator => this.isIndicatorVisible(indicator)), theme: this.props.theme, stateOrObjectHandler: this.stateOrObjectHandler, resolveAction: this.resolveInstanceIndicatorAction, defaultColor: "#fff", style: { marginLeft: 20 } })) : null,
|
|
@@ -743,7 +917,7 @@ export default class DeviceList extends Communication {
|
|
|
743
917
|
!this.state.apiVersionError &&
|
|
744
918
|
this.state.alive &&
|
|
745
919
|
this.state.devices.some(device => device.update) ? (React.createElement(Tooltip, { title: getTranslation('onlyUpdatableTooltip'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
|
|
746
|
-
React.createElement(IconButton, { color: this.state.onlyUpdatable ?
|
|
920
|
+
React.createElement(IconButton, { color: "inherit", sx: this.state.onlyUpdatable ? TOOLBAR_TOGGLE_ACTIVE_SX : undefined, onClick: () => {
|
|
747
921
|
const onlyUpdatable = !this.state.onlyUpdatable;
|
|
748
922
|
this.setState({ onlyUpdatable });
|
|
749
923
|
window.localStorage.setItem('dm_onlyUpdatable', onlyUpdatable ? 'true' : 'false');
|
|
@@ -752,7 +926,7 @@ export default class DeviceList extends Communication {
|
|
|
752
926
|
!this.state.apiVersionError &&
|
|
753
927
|
this.state.alive &&
|
|
754
928
|
this.state.devices.some(device => hasBatteryStatus(device.status)) ? (React.createElement(Tooltip, { title: getTranslation('onlyBatteryProblemTooltip'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
|
|
755
|
-
React.createElement(IconButton, { color: this.state.onlyBatteryProblem ?
|
|
929
|
+
React.createElement(IconButton, { color: "inherit", sx: this.state.onlyBatteryProblem ? TOOLBAR_TOGGLE_ACTIVE_SX : undefined, onClick: () => {
|
|
756
930
|
const onlyBatteryProblem = !this.state.onlyBatteryProblem;
|
|
757
931
|
this.setState({ onlyBatteryProblem });
|
|
758
932
|
window.localStorage.setItem('dm_onlyBatteryProblem', onlyBatteryProblem ? 'true' : 'false');
|
|
@@ -760,16 +934,15 @@ export default class DeviceList extends Communication {
|
|
|
760
934
|
React.createElement(BatteryAlert, null)))) : null,
|
|
761
935
|
!this.state.apiVersionError && this.state.alive ? this.renderIndicatorSettings() : null,
|
|
762
936
|
!this.state.apiVersionError && this.state.alive ? (React.createElement("div", { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
763
|
-
React.createElement(FilterAlt,
|
|
937
|
+
React.createElement(FilterAlt, null),
|
|
764
938
|
this.renderFilterFields(),
|
|
765
939
|
this.renderFilterValue())) : null,
|
|
766
940
|
React.createElement(Typography, { sx: { display: { xs: 'none', md: 'block' } }, style: {
|
|
767
941
|
marginLeft: 16,
|
|
768
942
|
fontWeight: 'bold',
|
|
769
943
|
whiteSpace: 'nowrap',
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
React.createElement("div", { style: {
|
|
944
|
+
} }, I18n.t('configManager'))),
|
|
945
|
+
React.createElement("div", { ref: this.containerRef, style: {
|
|
773
946
|
width: '100%',
|
|
774
947
|
flex: 1,
|
|
775
948
|
minHeight: 0,
|