@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.
@@ -0,0 +1,190 @@
1
+ import { getText } from './Utils';
2
+ /** The fields that are taken 1:1 from `DeviceInfo`, together with the transformation of their value */
3
+ const DIRECT_FIELDS = [
4
+ { field: 'name', translated: true },
5
+ { field: 'identifier' },
6
+ { field: 'hasDetails' },
7
+ { field: 'icon' },
8
+ { field: 'backgroundColor' },
9
+ { field: 'color' },
10
+ { field: 'manufacturer', translated: true },
11
+ { field: 'model', translated: true },
12
+ { field: 'connectionType' },
13
+ { field: 'enabled' },
14
+ ];
15
+ export const EMPTY_FIELDS = {};
16
+ /** Extract the battery value (literal or state/object reference) from the device status */
17
+ function getBatteryItem(status) {
18
+ if (!status || typeof status === 'string') {
19
+ return undefined;
20
+ }
21
+ const list = Array.isArray(status) ? status : [status];
22
+ for (const entry of list) {
23
+ if (typeof entry !== 'string' && entry.battery !== undefined) {
24
+ return entry.battery;
25
+ }
26
+ }
27
+ return undefined;
28
+ }
29
+ /** A battery problem is an explicit battery warning (`false`) or a charge level below 30 % */
30
+ function isBatteryProblem(value) {
31
+ if (value === false) {
32
+ return true;
33
+ }
34
+ return typeof value === 'number' && value < 30;
35
+ }
36
+ /**
37
+ * Resolves the filterable device fields of all devices of the list.
38
+ *
39
+ * All devices share a single `StateOrObjectHandler`, so several devices that refer to the same
40
+ * object or state result in exactly one `getObject`/`subscribeObject` on the socket.
41
+ */
42
+ export class DeviceFieldsResolver {
43
+ handler;
44
+ onChange;
45
+ entries = new Map();
46
+ notifyTimer = null;
47
+ destroyed = false;
48
+ /**
49
+ * @param handler shared handler used for all subscriptions
50
+ * @param onChange called (coalesced) whenever at least one resolved value changed
51
+ */
52
+ constructor(handler, onChange) {
53
+ this.handler = handler;
54
+ this.onChange = onChange;
55
+ }
56
+ /** Resolved values of one device. Never `undefined`, so the cards can rely on the object */
57
+ getValues(key) {
58
+ return this.entries.get(key)?.values || EMPTY_FIELDS;
59
+ }
60
+ /** All distinct, sorted model values found so far (used for the model filter dropdown) */
61
+ getModels() {
62
+ const models = new Set();
63
+ for (const entry of this.entries.values()) {
64
+ if (entry.values.model) {
65
+ models.add(entry.values.model);
66
+ }
67
+ }
68
+ return Array.from(models).sort();
69
+ }
70
+ /**
71
+ * Synchronize the subscriptions with the given device list.
72
+ *
73
+ * Devices that disappeared are unsubscribed, new ones are subscribed and for the devices that
74
+ * stayed only those fields are re-subscribed whose source actually changed. Literal values are
75
+ * resolved synchronously, so right after this call the list can already filter on them.
76
+ *
77
+ * @param devices the devices with their (pre-computed) key
78
+ */
79
+ setDevices(devices) {
80
+ if (this.destroyed) {
81
+ return;
82
+ }
83
+ const keys = new Set();
84
+ for (const { key, device } of devices) {
85
+ keys.add(key);
86
+ const entry = this.entries.get(key);
87
+ if (entry) {
88
+ entry.device = device;
89
+ this.syncEntry(entry);
90
+ }
91
+ else {
92
+ const newEntry = { device, values: {}, subscriptions: new Map() };
93
+ this.entries.set(key, newEntry);
94
+ this.syncEntry(newEntry);
95
+ }
96
+ }
97
+ for (const [key, entry] of [...this.entries]) {
98
+ if (!keys.has(key)) {
99
+ this.entries.delete(key);
100
+ this.disposeEntry(entry);
101
+ }
102
+ }
103
+ }
104
+ destroy() {
105
+ this.destroyed = true;
106
+ if (this.notifyTimer) {
107
+ clearTimeout(this.notifyTimer);
108
+ this.notifyTimer = null;
109
+ }
110
+ for (const entry of this.entries.values()) {
111
+ this.disposeEntry(entry);
112
+ }
113
+ this.entries.clear();
114
+ }
115
+ /** (Re-)subscribe all fields of one device whose source changed */
116
+ syncEntry(entry) {
117
+ for (const { field, translated } of DIRECT_FIELDS) {
118
+ this.syncField(entry, field, entry.device[field], translated);
119
+ }
120
+ this.syncField(entry, 'updateAvailable', entry.device.update?.available, false, value => !!value);
121
+ this.syncField(entry, 'batteryProblem', getBatteryItem(entry.device.status), false, isBatteryProblem);
122
+ }
123
+ syncField(entry, field, source, translated, transform) {
124
+ const existing = entry.subscriptions.get(field);
125
+ if (existing) {
126
+ if (existing.source === source) {
127
+ // Nothing changed - keep the running subscription
128
+ return;
129
+ }
130
+ entry.subscriptions.delete(field);
131
+ this.disposeSubscription(existing);
132
+ }
133
+ const holder = { source, disposed: false };
134
+ entry.subscriptions.set(field, holder);
135
+ void this.handler
136
+ .addListener(source, value => {
137
+ if (holder.disposed) {
138
+ return;
139
+ }
140
+ let resolved = value;
141
+ if (transform) {
142
+ resolved = transform(value);
143
+ }
144
+ else if (translated) {
145
+ resolved = getText(value);
146
+ }
147
+ if (entry.values[field] === resolved) {
148
+ return;
149
+ }
150
+ // Replace the object instead of mutating it, so `DeviceCard` (a `PureComponent`)
151
+ // notices the change by a simple reference comparison
152
+ entry.values = { ...entry.values, [field]: resolved };
153
+ this.scheduleNotify();
154
+ })
155
+ .then(subscription => {
156
+ holder.subscription = subscription;
157
+ if (holder.disposed) {
158
+ void subscription.unsubscribe();
159
+ }
160
+ })
161
+ .catch(error => console.error(`Cannot subscribe to "${field}":`, error));
162
+ }
163
+ disposeEntry(entry) {
164
+ for (const subscription of entry.subscriptions.values()) {
165
+ this.disposeSubscription(subscription);
166
+ }
167
+ entry.subscriptions.clear();
168
+ }
169
+ // eslint-disable-next-line class-methods-use-this
170
+ disposeSubscription(holder) {
171
+ holder.disposed = true;
172
+ void holder.subscription?.unsubscribe();
173
+ }
174
+ /**
175
+ * Values arrive one by one - a single device already produces a dozen callbacks. Without this
176
+ * throttle every one of them would re-render the complete list.
177
+ */
178
+ scheduleNotify() {
179
+ if (this.notifyTimer || this.destroyed) {
180
+ return;
181
+ }
182
+ this.notifyTimer = setTimeout(() => {
183
+ this.notifyTimer = null;
184
+ if (!this.destroyed) {
185
+ this.onChange();
186
+ }
187
+ }, 50);
188
+ }
189
+ }
190
+ //# sourceMappingURL=DeviceFields.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DeviceFields.js","sourceRoot":"./src/","sources":["DeviceFields.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ValueOrStateOrObject } from '@iobroker/dm-utils';\n\nimport type { ConfigConnectionType, DeviceInfo, DeviceStatus } from './protocol/api';\nimport type { StateOrObjectHandler, StateOrObjectSubscription } from './StateOrObjectHandler';\nimport { getText } from './Utils';\n\n/**\n * The device fields that may be bound to a state or an object and that the list has to know about,\n * because they are used for filtering and for the header of a card.\n *\n * They are resolved centrally in `DeviceList` instead of inside every `DeviceCard`: a card that is\n * not mounted (because it is filtered out or scrolled far out of the viewport) cannot resolve its\n * own name, and the filter would have nothing to work with.\n */\nexport interface ResolvedDeviceFields {\n name?: string;\n identifier?: string;\n hasDetails?: boolean;\n icon?: string;\n backgroundColor?: string;\n color?: string;\n manufacturer?: string;\n model?: string;\n connectionType?: ConfigConnectionType;\n enabled?: boolean;\n /** Resolved from `device.update.available` */\n updateAvailable?: boolean;\n /** Derived from the battery entry of `device.status` */\n batteryProblem?: boolean;\n}\n\n/** Device fields that can be used for the text filter */\nexport type DeviceFilterField = 'name' | 'identifier' | 'manufacturer' | 'model';\n\n/** The fields that are taken 1:1 from `DeviceInfo`, together with the transformation of their value */\nconst DIRECT_FIELDS: { field: keyof ResolvedDeviceFields & keyof DeviceInfo; translated?: boolean }[] = [\n { field: 'name', translated: true },\n { field: 'identifier' },\n { field: 'hasDetails' },\n { field: 'icon' },\n { field: 'backgroundColor' },\n { field: 'color' },\n { field: 'manufacturer', translated: true },\n { field: 'model', translated: true },\n { field: 'connectionType' },\n { field: 'enabled' },\n];\n\nexport const EMPTY_FIELDS: ResolvedDeviceFields = {};\n\n/** Any device field: a literal value or a reference to a state or an object */\ntype DeviceFieldSource = ValueOrStateOrObject<ioBroker.StringOrTranslated | number | boolean>;\n\n/** Extract the battery value (literal or state/object reference) from the device status */\nfunction getBatteryItem(status: DeviceInfo['status']): Extract<DeviceStatus, object>['battery'] {\n if (!status || typeof status === 'string') {\n return undefined;\n }\n const list = Array.isArray(status) ? status : [status];\n for (const entry of list) {\n if (typeof entry !== 'string' && entry.battery !== undefined) {\n return entry.battery;\n }\n }\n return undefined;\n}\n\n/** A battery problem is an explicit battery warning (`false`) or a charge level below 30 % */\nfunction isBatteryProblem(value: number | boolean | string | undefined): boolean {\n if (value === false) {\n return true;\n }\n return typeof value === 'number' && value < 30;\n}\n\n/**\n * One subscription of one field.\n *\n * `StateOrObjectHandler.addListener` is asynchronous, so the subscription may arrive after the field\n * was already replaced or the device was removed. `disposed` makes both cases safe.\n */\ninterface FieldSubscription {\n /** The `ValueOrStateOrObject` this subscription was created for; used to detect a changed source */\n source: unknown;\n subscription?: StateOrObjectSubscription;\n disposed: boolean;\n}\n\ninterface DeviceEntry {\n device: DeviceInfo;\n /** Replaced (never mutated) on every change, so the cards can compare it by reference */\n values: ResolvedDeviceFields;\n subscriptions: Map<keyof ResolvedDeviceFields, FieldSubscription>;\n}\n\n/**\n * Resolves the filterable device fields of all devices of the list.\n *\n * All devices share a single `StateOrObjectHandler`, so several devices that refer to the same\n * object or state result in exactly one `getObject`/`subscribeObject` on the socket.\n */\nexport class DeviceFieldsResolver {\n private readonly entries = new Map<string, DeviceEntry>();\n\n private notifyTimer: ReturnType<typeof setTimeout> | null = null;\n\n private destroyed = false;\n\n /**\n * @param handler shared handler used for all subscriptions\n * @param onChange called (coalesced) whenever at least one resolved value changed\n */\n constructor(\n private readonly handler: StateOrObjectHandler,\n private readonly onChange: () => void,\n ) {}\n\n /** Resolved values of one device. Never `undefined`, so the cards can rely on the object */\n public getValues(key: string): ResolvedDeviceFields {\n return this.entries.get(key)?.values || EMPTY_FIELDS;\n }\n\n /** All distinct, sorted model values found so far (used for the model filter dropdown) */\n public getModels(): string[] {\n const models = new Set<string>();\n for (const entry of this.entries.values()) {\n if (entry.values.model) {\n models.add(entry.values.model);\n }\n }\n return Array.from(models).sort();\n }\n\n /**\n * Synchronize the subscriptions with the given device list.\n *\n * Devices that disappeared are unsubscribed, new ones are subscribed and for the devices that\n * stayed only those fields are re-subscribed whose source actually changed. Literal values are\n * resolved synchronously, so right after this call the list can already filter on them.\n *\n * @param devices the devices with their (pre-computed) key\n */\n public setDevices(devices: { key: string; device: DeviceInfo }[]): void {\n if (this.destroyed) {\n return;\n }\n\n const keys = new Set<string>();\n for (const { key, device } of devices) {\n keys.add(key);\n const entry = this.entries.get(key);\n if (entry) {\n entry.device = device;\n this.syncEntry(entry);\n } else {\n const newEntry: DeviceEntry = { device, values: {}, subscriptions: new Map() };\n this.entries.set(key, newEntry);\n this.syncEntry(newEntry);\n }\n }\n\n for (const [key, entry] of [...this.entries]) {\n if (!keys.has(key)) {\n this.entries.delete(key);\n this.disposeEntry(entry);\n }\n }\n }\n\n public destroy(): void {\n this.destroyed = true;\n if (this.notifyTimer) {\n clearTimeout(this.notifyTimer);\n this.notifyTimer = null;\n }\n for (const entry of this.entries.values()) {\n this.disposeEntry(entry);\n }\n this.entries.clear();\n }\n\n /** (Re-)subscribe all fields of one device whose source changed */\n private syncEntry(entry: DeviceEntry): void {\n for (const { field, translated } of DIRECT_FIELDS) {\n this.syncField(entry, field, entry.device[field], translated);\n }\n this.syncField(entry, 'updateAvailable', entry.device.update?.available, false, value => !!value);\n this.syncField(entry, 'batteryProblem', getBatteryItem(entry.device.status), false, isBatteryProblem);\n }\n\n private syncField(\n entry: DeviceEntry,\n field: keyof ResolvedDeviceFields,\n source: DeviceFieldSource | undefined,\n translated?: boolean,\n transform?: (value: any) => any,\n ): void {\n const existing = entry.subscriptions.get(field);\n if (existing) {\n if (existing.source === source) {\n // Nothing changed - keep the running subscription\n return;\n }\n entry.subscriptions.delete(field);\n this.disposeSubscription(existing);\n }\n\n const holder: FieldSubscription = { source, disposed: false };\n entry.subscriptions.set(field, holder);\n\n void this.handler\n .addListener(source, value => {\n if (holder.disposed) {\n return;\n }\n let resolved: any = value;\n if (transform) {\n resolved = transform(value);\n } else if (translated) {\n resolved = getText(value as ioBroker.StringOrTranslated | undefined);\n }\n if (entry.values[field] === resolved) {\n return;\n }\n // Replace the object instead of mutating it, so `DeviceCard` (a `PureComponent`)\n // notices the change by a simple reference comparison\n entry.values = { ...entry.values, [field]: resolved };\n this.scheduleNotify();\n })\n .then(subscription => {\n holder.subscription = subscription;\n if (holder.disposed) {\n void subscription.unsubscribe();\n }\n })\n .catch(error => console.error(`Cannot subscribe to \"${field}\":`, error));\n }\n\n private disposeEntry(entry: DeviceEntry): void {\n for (const subscription of entry.subscriptions.values()) {\n this.disposeSubscription(subscription);\n }\n entry.subscriptions.clear();\n }\n\n // eslint-disable-next-line class-methods-use-this\n private disposeSubscription(holder: FieldSubscription): void {\n holder.disposed = true;\n void holder.subscription?.unsubscribe();\n }\n\n /**\n * Values arrive one by one - a single device already produces a dozen callbacks. Without this\n * throttle every one of them would re-render the complete list.\n */\n private scheduleNotify(): void {\n if (this.notifyTimer || this.destroyed) {\n return;\n }\n this.notifyTimer = setTimeout(() => {\n this.notifyTimer = null;\n if (!this.destroyed) {\n this.onChange();\n }\n }, 50);\n }\n}\n"]}
@@ -1,7 +1,7 @@
1
1
  import React, { type JSX } from 'react';
2
2
  import type { DeviceId, DeviceInfo, InstanceDetails } from './protocol/api';
3
- import { type DeviceFilterField } from './DeviceCard';
4
3
  import Communication, { type CommunicationProps, type CommunicationState } from './Communication';
4
+ import { type DeviceFilterField } from './DeviceFields';
5
5
  interface DeviceListProps extends CommunicationProps {
6
6
  /** Instance to upload images to, like `adapterName.X` */
7
7
  uploadImagesToInstance?: string;
@@ -48,6 +48,8 @@ interface DeviceListState extends CommunicationState {
48
48
  indicatorVisibility: Record<string, boolean>;
49
49
  /** Anchor of the indicator visibility menu */
50
50
  indicatorsAnchor: HTMLElement | null;
51
+ /** Increased whenever a device field bound to a state or an object changed, to trigger a re-render */
52
+ fieldsVersion: number;
51
53
  }
52
54
  /**
53
55
  * Device List Component
@@ -65,11 +67,28 @@ export default class DeviceList extends Communication<DeviceListProps, DeviceLis
65
67
  private alive;
66
68
  private lastTriggerLoad;
67
69
  private filterTimeout;
68
- /** Resolved model value per device (stringified id -> model), reported by the cards to build the model dropdown */
69
- private readonly modelValues;
70
70
  private readonly language;
71
- /** Subscriptions for the instance-wide indicators in the toolbar */
71
+ /**
72
+ * One handler for the toolbar indicators and for all cards, so that several devices referring to
73
+ * the same object or state share a single subscription on the socket.
74
+ */
72
75
  private readonly stateOrObjectHandler;
76
+ /** Resolves the device fields that may be bound to a state or an object (name, model, ...) */
77
+ private readonly fieldsResolver;
78
+ /** One `IntersectionObserver` for all cards: only what is near the viewport is really rendered */
79
+ private readonly lazyObserver;
80
+ /** The scrolling container of the card list; it is the root of `lazyObserver` */
81
+ private readonly containerRef;
82
+ /** A load is running. A second one must not tear down the same list in parallel */
83
+ private loadingDevices;
84
+ /** A load was requested while another one was still running */
85
+ private reloadRequested;
86
+ /** Devices of the running initial load that are not published to the state yet */
87
+ private pendingDevices;
88
+ private pendingTotal;
89
+ private flushTimer;
90
+ /** Memoized result of `getHiddenIndicators`, so that all cards keep getting the same array */
91
+ private hiddenIndicatorsCache;
73
92
  constructor(props: DeviceListProps);
74
93
  setStateAsync(state: Partial<DeviceListState>): Promise<void>;
75
94
  private loadAdapters;
@@ -78,11 +97,38 @@ export default class DeviceList extends Communication<DeviceListProps, DeviceLis
78
97
  private refreshInstanceList;
79
98
  componentDidMount(): Promise<void>;
80
99
  componentWillUnmount(): void;
100
+ /**
101
+ * The side effects that used to be triggered from `render()`. A render must not have any, and
102
+ * the `setTimeout` there was executed on every single render.
103
+ */
104
+ componentDidUpdate(): void;
105
+ /**
106
+ * A value bound to a state or an object arrived. The cards are `PureComponent`s and receive the
107
+ * values through their `fields` property, so the list only has to trigger a re-render.
108
+ */
109
+ private onFieldsChanged;
110
+ /**
111
+ * Publish a new device list.
112
+ *
113
+ * The resolver is updated *before* the state, because it resolves literal values synchronously -
114
+ * the cards and the filter therefore already have their values on the very first render.
115
+ */
116
+ private applyDevices;
117
+ /**
118
+ * Publish the devices loaded so far, but at most every 100 ms: a fast backend delivers a dozen
119
+ * batches in a row, and every single one of them would otherwise re-render the complete list.
120
+ */
121
+ private scheduleDeviceFlush;
122
+ private cancelDeviceFlush;
81
123
  aliveHandler: ioBroker.StateChangeHandler;
82
124
  loadAllData(): Promise<void>;
83
125
  loadInstanceInfos(): Promise<InstanceDetails>;
84
126
  /**
85
- * Load devices
127
+ * Load devices.
128
+ *
129
+ * The list is deliberately *not* emptied first: as long as the keys stay the same React keeps the
130
+ * existing cards, so a reload - the matter adapter triggers one on every `updateController`
131
+ * message - no longer unmounts and remounts a few hundred cards with all their subscriptions.
86
132
  */
87
133
  loadDeviceList(): void;
88
134
  updateDevice(update: DeviceInfo): void;
@@ -96,8 +142,14 @@ export default class DeviceList extends Communication<DeviceListProps, DeviceLis
96
142
  icon?: React.JSX.Element | string | null;
97
143
  }[] | undefined): React.JSX.Element | null;
98
144
  renderInstanceCards(): React.JSX.Element[];
99
- /** Collects the resolved model values reported by the cards and keeps the distinct, sorted list in state */
100
- private reportModel;
145
+ /**
146
+ * Apply the text filter and the "only updatable" / "only battery problem" filters.
147
+ *
148
+ * This used to be done by every card for itself. A filtered out card stayed mounted with all its
149
+ * subscriptions and only rendered nothing, so filtering did not reduce the load at all - and a
150
+ * card that is not rendered cannot filter itself in the first place.
151
+ */
152
+ private filterDevices;
101
153
  /** The selected filter field, falling back to `name` if the stored field is not available (e.g. no models found) */
102
154
  private getEffectiveFilterField;
103
155
  renderFilterFields(): React.JSX.Element | null;