states.ts 8.56 KB
Newer Older
Loïc Correnson's avatar
Loïc Correnson committed
1
2
3
4
5
6
// --------------------------------------------------------------------------
// --- States
// --------------------------------------------------------------------------

/**
   Typed States & Settings
Loïc Correnson's avatar
Loïc Correnson committed
7
   @packageDocumentation
Michele Alberti's avatar
Michele Alberti committed
8
   @module dome/data/states
Loïc Correnson's avatar
Loïc Correnson committed
9
10
11
*/

import React from 'react';
12
import Emitter from 'events';
Loïc Correnson's avatar
Loïc Correnson committed
13
import isEqual from 'react-fast-compare';
Loïc Correnson's avatar
Loïc Correnson committed
14
import { DEVEL } from 'dome/misc/system';
Loïc Correnson's avatar
Loïc Correnson committed
15
import * as Dome from 'dome';
Loïc Correnson's avatar
Loïc Correnson committed
16
import * as JSON from './json';
Loïc Correnson's avatar
Loïc Correnson committed
17

18
const UPDATE = 'dome.states.update';
Loïc Correnson's avatar
Loïc Correnson committed
19

20
/** Cross-component State. */
21
export class State<A> {
Loïc Correnson's avatar
Loïc Correnson committed
22

23
24
  private value: A;
  private emitter: Emitter;
25

26
27
28
29
30
  constructor(initValue: A) {
    this.value = initValue;
    this.emitter = new Emitter;
    this.getValue = this.getValue.bind(this);
    this.setValue = this.setValue.bind(this);
Loïc Correnson's avatar
Loïc Correnson committed
31
  }
Loïc Correnson's avatar
Loïc Correnson committed
32

33
  /** Current state value. */
34
  getValue() { return this.value; }
Loïc Correnson's avatar
Loïc Correnson committed
35
36

  /** Notify callbacks on change, using _deep_ structural comparison. */
37
  setValue(value: A) {
Loïc Correnson's avatar
Loïc Correnson committed
38
39
    if (!isEqual(value, this.value)) {
      this.value = value;
40
      this.emitter.emit(UPDATE, value);
Loïc Correnson's avatar
Loïc Correnson committed
41
42
43
    }
  }

Loïc Correnson's avatar
Loïc Correnson committed
44
  /** Callback Emitter. */
Loïc Correnson's avatar
Loïc Correnson committed
45
  on(callback: (value: A) => void) {
46
    this.emitter.on(UPDATE, callback);
Loïc Correnson's avatar
Loïc Correnson committed
47
48
  }

Loïc Correnson's avatar
Loïc Correnson committed
49
  /** Callback Emitter. */
Loïc Correnson's avatar
Loïc Correnson committed
50
  off(callback: (value: A) => void) {
51
    this.emitter.off(UPDATE, callback);
Loïc Correnson's avatar
Loïc Correnson committed
52
53
54
55
  }

}

56
57
58
59
60
61
62
63
64
/** React Hook, similar to `React.useState()`. */
export function useState<A>(s: State<A>): [A, (update: A) => void] {
  const [current, setCurrent] = React.useState<A>(s.getValue);
  React.useEffect(() => {
    s.on(setCurrent);
    return () => s.off(setCurrent);
  });
  return [current, s.setValue];
};
Loïc Correnson's avatar
Loïc Correnson committed
65
66

// --------------------------------------------------------------------------
Loïc Correnson's avatar
Loïc Correnson committed
67
68
69
// --- Settings
// --------------------------------------------------------------------------

70
71
72
73
74
75
76
77
78
79
80
81
82
83
/**
   Generic interface to Window and Global Settings.
   To be used with [[useSettings]] with instances of its derived classes,
   typically [[WindowSettings]] and [[GlobalSettings]]. You should never have
   to implement a Settings class on your own.

   All setting values are identified with
   an untyped `dataKey: string`, that can be dynamically modified
   for each component. Hence, two components might share both datakeys
   and settings.

   When several components share the same setting `dataKey` the behavior will be
   different depending on the situation:
   - for Window Settings, each component in each window retains its own
Michele Alberti's avatar
Michele Alberti committed
84
85
   setting value, although the last modified value from _any_ of them will be
   saved and used for any further initial value;
86
87
88
89
90
   - for Global Settings, all components synchronize to the last modified value
   from any component of any window.

   Type safety is ensured by safe JSON encoders and decoders, however, they
   might fail at runtime, causing settings value to be initialized to their
Michele Alberti's avatar
Michele Alberti committed
91
92
   fallback and not to be saved nor synchronized.
   This is not harmful but annoying.
93
94
95

   To mitigate this effect, each instance of a Settings class has its
   own, private, unique symbol that we call its « role ». A given `dataKey`
Michele Alberti's avatar
Michele Alberti committed
96
   shall always be used with the same « role » otherwise it is discarded,
97
98
   and an error message is logged when in DEVEL mode.
 */
Loïc Correnson's avatar
Loïc Correnson committed
99
export abstract class Settings<A> {
Loïc Correnson's avatar
Loïc Correnson committed
100
101
102
103
104

  private static keyRoles = new Map<string, symbol>();

  private readonly role: symbol;
  protected readonly decoder: JSON.Safe<A>;
Loïc Correnson's avatar
Loïc Correnson committed
105
  protected readonly encoder: JSON.Encoder<A>;
Loïc Correnson's avatar
Loïc Correnson committed
106

107
  /**
108
     Encoders shall be protected against exception.
Loïc Correnson's avatar
Loïc Correnson committed
109
     Use [[dome/data/json.jTry]] and [[dome/data/json.jCatch]] in case of uncertainty.
110
     Decoders are automatically protected internally to the Settings class.
Michele Alberti's avatar
Michele Alberti committed
111
112
113
114
115
116
     @param role Debugging name of instance roles (each instance has its unique
     role, though)
     @param decoder JSON decoder for the setting values
     @param encoder JSON encoder for the setting values
     @param fallback If provided, used to automatically protect your encoders
     against exceptions.
117
   */
118
119
120
121
122
123
  constructor(
    role: string,
    decoder: JSON.Safe<A>,
    encoder: JSON.Encoder<A>,
    fallback?: A,
  ) {
Loïc Correnson's avatar
Loïc Correnson committed
124
    this.role = Symbol(role);
Loïc Correnson's avatar
Loïc Correnson committed
125
    this.encoder = encoder;
126
127
    this.decoder =
      fallback !== undefined ? JSON.jCatch(decoder, fallback) : decoder;
Loïc Correnson's avatar
Loïc Correnson committed
128
129
  }

130
131
132
133
134
135
136
  /**
     Returns identity if the data key is only
     used with the same setting instance.
     Otherwise, returns `undefined`.
   */
  validateKey(dataKey?: string): string | undefined {
    if (dataKey === undefined) return undefined;
Loïc Correnson's avatar
Loïc Correnson committed
137
    const rq = this.role;
138
    const rk = Settings.keyRoles.get(dataKey);
Loïc Correnson's avatar
Loïc Correnson committed
139
    if (rk === undefined) {
140
      Settings.keyRoles.set(dataKey, rq);
Loïc Correnson's avatar
Loïc Correnson committed
141
142
143
    } else {
      if (rk !== rq) {
        if (DEVEL) console.error(
Michele Alberti's avatar
Michele Alberti committed
144
          `[Dome.settings] Key ${dataKey} used with incompatible roles`, rk, rq,
Loïc Correnson's avatar
Loïc Correnson committed
145
146
147
148
        );
        return undefined;
      }
    }
149
    return dataKey;
Loïc Correnson's avatar
Loïc Correnson committed
150
151
  }

152
  /** @internal */
Loïc Correnson's avatar
Loïc Correnson committed
153
  abstract loadData(key: string): JSON.json;
154
155

  /** @internal */
Loïc Correnson's avatar
Loïc Correnson committed
156
  abstract saveData(key: string, data: JSON.json): void;
157
158

  /** @internal */
159
  abstract event: string;
Loïc Correnson's avatar
Loïc Correnson committed
160

161
162
163
164
  /** Returns the current setting value for the provided data key. You shall
      only use validated keys otherwise you might fallback to default values. */
  loadValue(dataKey?: string) {
    return this.decoder(dataKey ? this.loadData(dataKey) : undefined)
Loïc Correnson's avatar
Loïc Correnson committed
165
166
  }

167
  /** Push the new setting value for the provided data key.
Michele Alberti's avatar
Michele Alberti committed
168
      You shall only use validated keys otherwise further loads
169
170
      might fail and fallback to defaults. */
  saveValue(dataKey: string, value: A) {
171
172
173
    try { this.saveData(dataKey, this.encoder(value)); }
    catch (err) {
      if (DEVEL) console.error(
Michele Alberti's avatar
Michele Alberti committed
174
        '[Dome.settings] Error while encoding value',
175
176
177
        dataKey, value, err,
      );
    }
Loïc Correnson's avatar
Loïc Correnson committed
178
179
  }

Loïc Correnson's avatar
Loïc Correnson committed
180
181
}

182
183
184
185
186
/**
   Generic React Hook to be used with any kind of [[Settings]].
   You may share `dataKey` between components, or change it dynamically.
   However, a given data key shall always be used for the same Setting instance.
   See [[Settings]] documentation for details.
Michele Alberti's avatar
Michele Alberti committed
187
188
   @param S The instance settings to be used.
   @param dataKey Identifies which value in the settings to be used.
189
 */
Loïc Correnson's avatar
Loïc Correnson committed
190
export function useSettings<A>(
Loïc Correnson's avatar
Loïc Correnson committed
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
  S: Settings<A>,
  dataKey?: string,
): [A, (update: A) => void] {

  const theKey = React.useMemo(() => S.validateKey(dataKey), [S, dataKey]);
  const [value, setValue] = React.useState<A>(() => S.loadValue(theKey));

  React.useEffect(() => {
    if (theKey) {
      const callback = () => setValue(S.loadValue(theKey));
      Dome.on(S.event, callback);
      return () => Dome.off(S.event, callback);
    }
    return undefined;
  });

  const updateValue = React.useCallback((update: A) => {
    if (!isEqual(value, update)) {
      setValue(update);
Loïc Correnson's avatar
Loïc Correnson committed
210
      if (theKey) S.saveValue(theKey, update);
Loïc Correnson's avatar
Loïc Correnson committed
211
212
213
214
215
216
217
    }
  }, [S, theKey]);

  return [value, updateValue];

}

218
219
/** Window Settings for non-JSON data.
    In most situations, you can use [[WindowSettings]] instead.
Loïc Correnson's avatar
Loïc Correnson committed
220
    You can use a [[dome/data/json.Loose]] decoder for optional values. */
Loïc Correnson's avatar
Loïc Correnson committed
221
export class WindowSettingsData<A> extends Settings<A> {
Loïc Correnson's avatar
Loïc Correnson committed
222

223
224
225
226
227
228
229
  constructor(
    role: string,
    decoder: JSON.Safe<A>,
    encoder: JSON.Encoder<A>,
    fallback?: A,
  ) {
    super(role, decoder, encoder, fallback);
Loïc Correnson's avatar
Loïc Correnson committed
230
231
  }

232
  event = 'dome.defaults';
Loïc Correnson's avatar
Loïc Correnson committed
233
234
235
236
237
  loadData(key: string) { return Dome.getWindowSetting(key) as JSON.json; }
  saveData(key: string, data: JSON.json) { Dome.setWindowSetting(key, data); }

}

238
239
/** Global Settings for non-JSON data.
    In most situations, you can use [[WindowSettings]] instead.
Loïc Correnson's avatar
Loïc Correnson committed
240
    You can use a [[dome/data/json.Loose]] decoder for optional values. */
Loïc Correnson's avatar
Loïc Correnson committed
241
export class GlobalSettingsData<A> extends Settings<A> {
Loïc Correnson's avatar
Loïc Correnson committed
242

243
244
245
246
247
248
249
  constructor(
    role: string,
    decoder: JSON.Safe<A>,
    encoder: JSON.Encoder<A>,
    fallback?: A,
  ) {
    super(role, decoder, encoder, fallback);
Loïc Correnson's avatar
Loïc Correnson committed
250
251
  }

252
  event = 'dome.settings';
Loïc Correnson's avatar
Loïc Correnson committed
253
254
255
256
257
  loadData(key: string) { return Dome.getGlobalSetting(key) as JSON.json; }
  saveData(key: string, data: JSON.json) { Dome.setGlobalSetting(key, data); }

}

258
/** Window Settings.
Loïc Correnson's avatar
Loïc Correnson committed
259
260
    For non-JSON data, use [[WindowSettingsData]] instead.
    You can use a [[dome/data/json.Loose]] decoder for optional values. */
Loïc Correnson's avatar
Loïc Correnson committed
261
262
export class WindowSettings<A extends JSON.json> extends WindowSettingsData<A> {

263
264
  constructor(role: string, decoder: JSON.Safe<A>, fallback?: A) {
    super(role, decoder, JSON.identity, fallback);
Loïc Correnson's avatar
Loïc Correnson committed
265
266
267
268
  }

}

269
/** Global Settings.
Loïc Correnson's avatar
Loïc Correnson committed
270
271
    For non-JSON data, use [[WindowSettingsData]] instead.
    You can use a [[dome/data/json.Loose]] decoder for optional values. */
Loïc Correnson's avatar
Loïc Correnson committed
272
273
export class GlobalSettings<A extends JSON.json> extends GlobalSettingsData<A> {

274
275
  constructor(role: string, decoder: JSON.Safe<A>, fallback?: A) {
    super(role, decoder, JSON.identity, fallback);
Loïc Correnson's avatar
Loïc Correnson committed
276
277
278
279
  }

}

Loïc Correnson's avatar
Loïc Correnson committed
280
// --------------------------------------------------------------------------