@iobroker/dm-gui-components 10.0.19 → 10.0.21

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.
@@ -1,13 +1,15 @@
1
1
  import React from 'react';
2
- import { IconButton, InputAdornment, TextField, Toolbar, Tooltip, LinearProgress, Select, MenuItem, Box, Card, CardActionArea, CardContent, Typography, Menu, Checkbox, ListSubheader, } from '@mui/material';
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,25 @@ 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;
22
43
  /** Returns true if any of the device status objects carries a battery value */
23
44
  function hasBatteryStatus(status) {
24
45
  if (!status || typeof status === 'string') {
@@ -43,14 +64,32 @@ export default class DeviceList extends Communication {
43
64
  alive = null;
44
65
  lastTriggerLoad = 0;
45
66
  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
67
  language = I18n.getLanguage();
49
- /** Subscriptions for the instance-wide indicators in the toolbar */
68
+ /**
69
+ * One handler for the toolbar indicators and for all cards, so that several devices referring to
70
+ * the same object or state share a single subscription on the socket.
71
+ */
50
72
  stateOrObjectHandler;
73
+ /** Resolves the device fields that may be bound to a state or an object (name, model, ...) */
74
+ fieldsResolver;
75
+ /** One `IntersectionObserver` for all cards: only what is near the viewport is really rendered */
76
+ lazyObserver = new LazyRenderObserver();
77
+ /** The scrolling container of the card list; it is the root of `lazyObserver` */
78
+ containerRef = React.createRef();
79
+ /** A load is running. A second one must not tear down the same list in parallel */
80
+ loadingDevices = false;
81
+ /** A load was requested while another one was still running */
82
+ reloadRequested = false;
83
+ /** Devices of the running initial load that are not published to the state yet */
84
+ pendingDevices = null;
85
+ pendingTotal;
86
+ flushTimer = null;
87
+ /** Memoized result of `getHiddenIndicators`, so that all cards keep getting the same array */
88
+ hiddenIndicatorsCache = null;
51
89
  constructor(props) {
52
90
  super(props);
53
91
  this.stateOrObjectHandler = new StateOrObjectHandler(this.props.socket);
92
+ this.fieldsResolver = new DeviceFieldsResolver(this.stateOrObjectHandler, this.onFieldsChanged);
54
93
  if (!DeviceList.i18nInitialized) {
55
94
  DeviceList.i18nInitialized = true;
56
95
  I18n.extendTranslations({
@@ -84,6 +123,7 @@ export default class DeviceList extends Communication {
84
123
  modelOptions: [],
85
124
  indicatorVisibility: {},
86
125
  indicatorsAnchor: null,
126
+ fieldsVersion: 0,
87
127
  };
88
128
  if (this.props.selectedInstance === undefined) {
89
129
  // Start with the root page that shows all instances as cards
@@ -153,9 +193,8 @@ export default class DeviceList extends Communication {
153
193
  window.localStorage.removeItem('dmSelectedInstance');
154
194
  this.props.onInstanceChanged?.('');
155
195
  this.alive = null;
156
- this.setState({
196
+ this.applyDevices([], {
157
197
  selectedInstance: '',
158
- devices: [],
159
198
  totalDevices: undefined,
160
199
  instanceInfo: null,
161
200
  alive: null,
@@ -169,6 +208,7 @@ export default class DeviceList extends Communication {
169
208
  this.setState({ dmInstances: null }, () => this.loadAdapters().catch(console.error));
170
209
  }
171
210
  async componentDidMount() {
211
+ this.lazyObserver.setRoot(this.containerRef.current);
172
212
  let alive = false;
173
213
  // If an instance selector must be shown
174
214
  if (this.props.selectedInstance === undefined) {
@@ -207,10 +247,98 @@ export default class DeviceList extends Communication {
207
247
  }
208
248
  }
209
249
  componentWillUnmount() {
250
+ super.componentWillUnmount();
251
+ this.cancelDeviceFlush();
252
+ this.fieldsResolver.destroy();
253
+ this.stateOrObjectHandler.destroy();
254
+ this.lazyObserver.destroy();
255
+ if (this.filterTimeout) {
256
+ clearTimeout(this.filterTimeout);
257
+ this.filterTimeout = null;
258
+ }
210
259
  if (this.state.selectedInstance) {
211
260
  this.props.socket.unsubscribeState(`system.adapter.${this.state.selectedInstance}.alive`, this.aliveHandler);
212
261
  }
213
262
  }
263
+ /**
264
+ * The side effects that used to be triggered from `render()`. A render must not have any, and
265
+ * the `setTimeout` there was executed on every single render.
266
+ */
267
+ componentDidUpdate() {
268
+ // The scrolling container only exists after the first render
269
+ this.lazyObserver.setRoot(this.containerRef.current);
270
+ if ((this.props.triggerLoad || 0) !== this.lastTriggerLoad) {
271
+ this.lastTriggerLoad = this.props.triggerLoad || 0;
272
+ this.loadDeviceList();
273
+ }
274
+ // if instance changed
275
+ if (this.lastInstance !== this.state.selectedInstance) {
276
+ this.lastInstance = this.state.selectedInstance;
277
+ // The indicator visibility is stored per instance
278
+ this.setState({
279
+ indicatorVisibility: DeviceList.loadIndicatorVisibility(this.state.selectedInstance),
280
+ indicatorsAnchor: null,
281
+ });
282
+ if (this.state.selectedInstance) {
283
+ this.loadAllData().catch(error => console.error(error));
284
+ }
285
+ else {
286
+ this.loadDeviceList();
287
+ }
288
+ }
289
+ if (this.props.selectedInstance && this.props.selectedInstance !== this.state.selectedInstance) {
290
+ this.setState({ selectedInstance: this.props.selectedInstance });
291
+ }
292
+ }
293
+ /**
294
+ * A value bound to a state or an object arrived. The cards are `PureComponent`s and receive the
295
+ * values through their `fields` property, so the list only has to trigger a re-render.
296
+ */
297
+ onFieldsChanged = () => {
298
+ const modelOptions = this.fieldsResolver.getModels();
299
+ const modelsChanged = modelOptions.length !== this.state.modelOptions.length ||
300
+ modelOptions.some((model, i) => model !== this.state.modelOptions[i]);
301
+ this.setState({
302
+ fieldsVersion: this.state.fieldsVersion + 1,
303
+ modelOptions: modelsChanged ? modelOptions : this.state.modelOptions,
304
+ });
305
+ };
306
+ /**
307
+ * Publish a new device list.
308
+ *
309
+ * The resolver is updated *before* the state, because it resolves literal values synchronously -
310
+ * the cards and the filter therefore already have their values on the very first render.
311
+ */
312
+ applyDevices(devices, state) {
313
+ this.fieldsResolver.setDevices(devices.map(device => ({ key: deviceKey(device), device })));
314
+ this.setState({ ...state, devices });
315
+ }
316
+ /**
317
+ * Publish the devices loaded so far, but at most every 100 ms: a fast backend delivers a dozen
318
+ * batches in a row, and every single one of them would otherwise re-render the complete list.
319
+ */
320
+ scheduleDeviceFlush(devices, total) {
321
+ this.pendingDevices = devices;
322
+ this.pendingTotal = total;
323
+ if (this.flushTimer) {
324
+ return;
325
+ }
326
+ this.flushTimer = setTimeout(() => {
327
+ this.flushTimer = null;
328
+ const pending = this.pendingDevices;
329
+ if (pending) {
330
+ this.pendingDevices = null;
331
+ this.applyDevices(pending, { loading: true, totalDevices: this.pendingTotal });
332
+ }
333
+ }, 100);
334
+ }
335
+ cancelDeviceFlush() {
336
+ if (this.flushTimer) {
337
+ clearTimeout(this.flushTimer);
338
+ this.flushTimer = null;
339
+ }
340
+ this.pendingDevices = null;
341
+ }
214
342
  aliveHandler = (id, state) => {
215
343
  if (this.state.selectedInstance && id === `system.adapter.${this.state.selectedInstance}.alive`) {
216
344
  const alive = !!state?.val;
@@ -237,9 +365,20 @@ export default class DeviceList extends Communication {
237
365
  return new Promise(resolve => this.setState({ instanceInfo, apiVersionError: !['v1', 'v2', 'v3'].includes(instanceInfo.apiVersion) }, () => resolve(instanceInfo)));
238
366
  }
239
367
  /**
240
- * Load devices
368
+ * Load devices.
369
+ *
370
+ * The list is deliberately *not* emptied first: as long as the keys stay the same React keeps the
371
+ * existing cards, so a reload - the matter adapter triggers one on every `updateController`
372
+ * message - no longer unmounts and remounts a few hundred cards with all their subscriptions.
241
373
  */
242
374
  loadDeviceList() {
375
+ if (this.loadingDevices) {
376
+ // Remember the request and repeat the load once the running one has finished, instead of
377
+ // running two loads over the same list at the same time.
378
+ this.reloadRequested = true;
379
+ return;
380
+ }
381
+ this.loadingDevices = true;
243
382
  this.setState({ loading: true }, async () => {
244
383
  console.log(`Loading devices for ${this.state.selectedInstance}...`);
245
384
  let alive = this.alive;
@@ -268,14 +407,20 @@ export default class DeviceList extends Communication {
268
407
  alive = false;
269
408
  }
270
409
  }
410
+ // Only the very first load shows the devices step by step (with skeletons for the ones
411
+ // that are still missing). A reload keeps the current list until the new one is complete,
412
+ // so the already rendered cards survive.
413
+ const incremental = !this.state.devices.length;
271
414
  let devices = [];
272
415
  try {
273
416
  this.alive = alive;
274
- this.setState({ devices, loading: !!alive, alive });
417
+ this.setState({ loading: !!alive, alive });
275
418
  if (alive) {
276
419
  await this.loadDevices((batch, total) => {
277
420
  devices = devices.concat(batch);
278
- this.setState({ devices, loading: true, totalDevices: total });
421
+ if (incremental) {
422
+ this.scheduleDeviceFlush(devices, total);
423
+ }
279
424
  console.log(`Loaded ${devices.length} of ${total} devices...`);
280
425
  });
281
426
  }
@@ -284,21 +429,27 @@ export default class DeviceList extends Communication {
284
429
  console.error(error);
285
430
  devices = [];
286
431
  }
287
- this.setState({ devices, loading: false, totalDevices: devices.length });
432
+ this.cancelDeviceFlush();
433
+ this.applyDevices(devices, { loading: false, totalDevices: devices.length });
288
434
  console.log(`Loaded ${devices.length} devices for ${this.state.selectedInstance}`);
435
+ this.loadingDevices = false;
436
+ if (this.reloadRequested) {
437
+ this.reloadRequested = false;
438
+ setTimeout(() => this.loadDeviceList(), 0);
439
+ }
289
440
  });
290
441
  }
291
442
  updateDevice(update) {
292
443
  const updateId = JSON.stringify(update.id);
293
- this.setState({ devices: this.state.devices.map(d => (JSON.stringify(d.id) === updateId ? update : d)) });
444
+ this.applyDevices(this.state.devices.map(d => (deviceKey(d) === updateId ? update : d)), {});
294
445
  }
295
446
  deleteDevice(deviceId) {
296
447
  const deleteId = JSON.stringify(deviceId);
297
- const devices = this.state.devices.filter(d => JSON.stringify(d.id) !== deleteId);
448
+ const devices = this.state.devices.filter(d => deviceKey(d) !== deleteId);
298
449
  const totalDevices = this.state.totalDevices && devices.length < this.state.devices.length
299
450
  ? this.state.totalDevices - 1
300
451
  : undefined;
301
- this.setState({ devices, totalDevices });
452
+ this.applyDevices(devices, { totalDevices });
302
453
  }
303
454
  getText(text) {
304
455
  if (typeof text === 'object') {
@@ -388,27 +539,38 @@ export default class DeviceList extends Communication {
388
539
  info.title ? (React.createElement(Typography, { variant: "body2", color: "textSecondary", style: { textAlign: 'center' } }, info.title)) : null))));
389
540
  });
390
541
  }
391
- /** Collects the resolved model values reported by the cards and keeps the distinct, sorted list in state */
392
- reportModel = (deviceId, model) => {
393
- const key = JSON.stringify(deviceId);
394
- if (model) {
395
- if (this.modelValues.get(key) === model) {
396
- return;
542
+ /**
543
+ * Apply the text filter and the "only updatable" / "only battery problem" filters.
544
+ *
545
+ * This used to be done by every card for itself. A filtered out card stayed mounted with all its
546
+ * subscriptions and only rendered nothing, so filtering did not reduce the load at all - and a
547
+ * card that is not rendered cannot filter itself in the first place.
548
+ */
549
+ filterDevices(devices) {
550
+ const filter = (this.props.embedded ? this.props.filter : this.state.filter)?.toLowerCase();
551
+ const field = this.props.embedded ? 'name' : this.getEffectiveFilterField();
552
+ const onlyUpdatable = !this.props.embedded && this.state.onlyUpdatable;
553
+ const onlyBatteryProblem = !this.props.embedded && this.state.onlyBatteryProblem;
554
+ if (!filter && !onlyUpdatable && !onlyBatteryProblem) {
555
+ return devices;
556
+ }
557
+ return devices.filter(device => {
558
+ const fields = this.fieldsResolver.getValues(deviceKey(device));
559
+ if (filter &&
560
+ !String(fields[field] ?? '')
561
+ .toLowerCase()
562
+ .includes(filter)) {
563
+ return false;
397
564
  }
398
- this.modelValues.set(key, model);
399
- }
400
- else if (this.modelValues.has(key)) {
401
- this.modelValues.delete(key);
402
- }
403
- else {
404
- return;
405
- }
406
- const modelOptions = Array.from(new Set(this.modelValues.values())).sort();
407
- if (modelOptions.length !== this.state.modelOptions.length ||
408
- modelOptions.some((model, i) => model !== this.state.modelOptions[i])) {
409
- this.setState({ modelOptions });
410
- }
411
- };
565
+ if (onlyUpdatable && !fields.updateAvailable) {
566
+ return false;
567
+ }
568
+ if (onlyBatteryProblem && !fields.batteryProblem) {
569
+ return false;
570
+ }
571
+ return true;
572
+ });
573
+ }
412
574
  /** The selected filter field, falling back to `name` if the stored field is not available (e.g. no models found) */
413
575
  getEffectiveFilterField() {
414
576
  const field = this.state.filterField;
@@ -529,9 +691,16 @@ export default class DeviceList extends Communication {
529
691
  }
530
692
  /** IDs of the configurable indicators the user has switched off */
531
693
  getHiddenIndicators() {
532
- return this.getConfigurableIndicators()
694
+ const hidden = this.getConfigurableIndicators()
533
695
  .filter(indicator => !this.isIndicatorVisible(indicator))
534
696
  .map(indicator => indicator.id);
697
+ // The array is handed to every card. A new array on every render would defeat the shallow
698
+ // property comparison of `DeviceCard`.
699
+ const key = hidden.join(',');
700
+ if (!this.hiddenIndicatorsCache || this.hiddenIndicatorsCache.key !== key) {
701
+ this.hiddenIndicatorsCache = { key, value: hidden };
702
+ }
703
+ return this.hiddenIndicatorsCache.value;
535
704
  }
536
705
  toggleIndicator(indicator) {
537
706
  const indicatorVisibility = {
@@ -585,35 +754,6 @@ export default class DeviceList extends Communication {
585
754
  const emptyStyle = {
586
755
  padding: 25,
587
756
  };
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
757
  const deviceGroups = [];
618
758
  const showRootPage = !this.props.embedded && this.props.selectedInstance === undefined && !this.state.selectedInstance;
619
759
  let list;
@@ -680,21 +820,37 @@ export default class DeviceList extends Communication {
680
820
  }
681
821
  }
682
822
  if (this.state.selectedInstance) {
823
+ const selectedInstance = this.state.selectedInstance;
683
824
  const hiddenIndicators = this.getHiddenIndicators();
684
- list = filteredDevices.map(device => (React.createElement(DeviceCard, { key: JSON.stringify(device.id), smallCards: this.props.smallCards ?? this.state.instanceInfo?.smallCards, filter: this.props.embedded ? this.props.filter : this.state.filter, alive: !!this.state.alive, id: device.id, identifierLabel: this.state.instanceInfo?.identifierLabel ?? 'ID', device: device, instanceId: this.state.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, onlyUpdatable: this.state.onlyUpdatable, onlyBatteryProblem: this.state.onlyBatteryProblem, filterField: this.props.embedded ? undefined : this.getEffectiveFilterField(), hiddenIndicators: hiddenIndicators, onModel: this.reportModel })));
825
+ const smallCards = this.props.smallCards ?? this.state.instanceInfo?.smallCards;
826
+ const identifierLabel = this.state.instanceInfo?.identifierLabel ?? 'ID';
827
+ const cardWidth = smallCards ? SMALL_CARD_WIDTH : CARD_WIDTH;
828
+ const cardMinHeight = smallCards ? SMALL_CARD_MIN_HEIGHT : CARD_MIN_HEIGHT;
829
+ const cardMargin = smallCards ? SMALL_CARD_MARGIN : CARD_MARGIN;
830
+ const visibleDevices = this.filterDevices(filteredDevices);
831
+ // Every card is wrapped in a `LazyRender`: the placeholder always occupies the full
832
+ // footprint of a card, but the card itself is only built while it is near the visible
833
+ // area. Without that, 80 devices mean thousands of DOM nodes and several hundred MUI
834
+ // tooltips and buttons at once - which is what makes Safari (and especially iPadOS)
835
+ // hang and finally kill the tab.
836
+ list = visibleDevices.map(device => {
837
+ const key = deviceKey(device);
838
+ 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 }))));
839
+ });
685
840
  if (this.state.loading) {
686
- const skeletons = (this.state.totalDevices ?? list.length + 1) - list.length;
841
+ // How many devices are still missing - deliberately not derived from `list`,
842
+ // which only contains the devices that pass the filter
843
+ const loaded = this.state.devices.length;
844
+ const missing = (this.state.totalDevices ?? loaded + 1) - loaded;
845
+ const skeletons = Math.min(missing, MAX_SKELETONS);
687
846
  for (let i = 0; i < skeletons; i++) {
688
- list.push(React.createElement(DeviceCardSkeleton, { key: `skeleton-${i}`, smallCards: this.props.smallCards ?? this.state.instanceInfo?.smallCards, theme: this.props.theme }));
847
+ list.push(React.createElement(DeviceCardSkeleton, { key: `skeleton-${i}`, smallCards: smallCards, theme: this.props.theme }));
689
848
  }
690
849
  }
691
- else if (this.state.devices.length > 0) {
692
- list.push(React.createElement(Box, { key: "filtered", sx: {
693
- padding: '25px',
694
- '&:not(:first-child)': {
695
- display: 'none',
696
- },
697
- } },
850
+ else if (!visibleDevices.length && this.state.devices.length > 0) {
851
+ // The filter is applied here now, so the list knows that nothing is left instead
852
+ // of having to hide the message with a CSS rule
853
+ list.push(React.createElement("div", { style: emptyStyle, key: "filtered" },
698
854
  React.createElement("span", null, getTranslation('allDevicesFilteredOut'))));
699
855
  }
700
856
  }
@@ -769,7 +925,7 @@ export default class DeviceList extends Communication {
769
925
  whiteSpace: 'nowrap',
770
926
  color: '#fff',
771
927
  } }, "Config-Manager")),
772
- React.createElement("div", { style: {
928
+ React.createElement("div", { ref: this.containerRef, style: {
773
929
  width: '100%',
774
930
  flex: 1,
775
931
  minHeight: 0,