-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathPrefService.ts
More file actions
181 lines (159 loc) · 6.03 KB
/
PrefService.ts
File metadata and controls
181 lines (159 loc) · 6.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
/*
* This file belongs to Hoist, an application development toolkit
* developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
*
* Copyright © 2026 Extremely Heavy Industries Inc.
*/
import {HoistService, XH} from '@xh/hoist/core';
import {SECONDS} from '@xh/hoist/utils/datetime';
import {debounced, deepFreeze, throwIf} from '@xh/hoist/utils/js';
import {cloneDeep, forEach, isEmpty, isEqual} from 'lodash';
/**
* Service to read and set user-specific preference values.
*
* Server-side preference support is provided by hoist-core. Preferences must be predefined on the
* server (they can be managed via the Admin console) and are referenced by their string key. They
* are assigned default values that apply to users who have yet to have a value set that is specific
* to their account. Once set, however, the user will get their customized value instead of the
* default going forwards.
*
* This could happen via an explicit option the user adjusts, or happen transparently based on a
* natural user action or component integration (e.g. collapsing or resizing a `Resizable` that has
* been configured with preference support).
*
* Preferences are persisted automatically back to the server by default so as to follow their user
* across workstations.
*/
export class PrefService extends HoistService {
static instance: PrefService;
private _data = {};
private _updates = {};
override async initAsync() {
window.addEventListener('beforeunload', () => this.pushPendingAsync());
return this.loadPrefsAsync();
}
/**
* Check to see if a given preference has been *defined*.
*/
hasKey(key: string): boolean {
return this._data.hasOwnProperty(key);
}
/**
* Get the value for a given key, either the user-specific value (if set) or the default.
* Typically accessed via the convenience alias {@link XH.getPref}.
*
* @param key - unique key used to identify the pref.
* @param defaultValue - value to return if the preference key is not found - i.e.
* the config has not been created on the server - instead of throwing. Use sparingly!
* In general, it's better to not provide defaults here, but instead keep entries updated
* via the Admin client and have it be obvious when one is missing.
*/
get(key: string, defaultValue?: any) {
const data = this._data;
let ret = defaultValue;
if (data.hasOwnProperty(key)) {
ret = data[key].value;
}
throwIf(ret === undefined, `Preference key not found: '${key}'`);
return ret;
}
/**
* Set a preference value for the current user.
* Typically accessed via the convenience alias {@link XH.setPref}.
*
* Values are validated client-side to ensure they (probably) are of the correct data type.
*
* Values are saved to the server in an asynchronous and debounced manner.
* See pushAsync() and pushPendingAsync()
*/
set(key: string, value: any) {
this.validateBeforeSet(key, value);
const oldValue = this.get(key);
if (isEqual(oldValue, value)) return;
// Change local value to sanitized copy and fire.
value = deepFreeze(cloneDeep(value));
this._data[key].value = value;
// Schedule serialization to storage
this._updates[key] = value;
this.pushPendingBuffered();
}
/**
* Restore a preference to its default value.
*/
unset(key: string) {
// TODO: round-trip this to the server as a proper unset?
this.set(key, this._data[key]?.defaultValue);
}
/**
* Set a preference value for the current user, and immediately trigger a sync to the server.
*
* Useful when important to verify that the preference has been fully round-tripped - e.g.
* before making another call that relies on its updated value being read on the server.
*/
async pushAsync(key: string, value: any) {
this.validateBeforeSet(key, value);
this.set(key, value);
return this.pushPendingAsync();
}
/**
* Push any pending buffered updates to persist newly set values to server.
* Called automatically by this app on page unload to avoid dropping changes when e.g. a user
* changes and option and then immediately hits a (browser) refresh.
*/
async pushPendingAsync() {
const updates = this._updates;
if (isEmpty(updates)) return;
this._updates = {};
await XH.postJson({
url: 'xh/setPrefs',
body: updates,
params: {
clientUsername: XH.getUsername()
}
});
}
//-------------------
// Implementation
//-------------------
@debounced(5 * SECONDS)
private pushPendingBuffered() {
this.pushPendingAsync();
}
private async loadPrefsAsync() {
const data = await XH.fetchJson({
url: 'xh/getPrefs',
params: {clientUsername: XH.getUsername()}
});
forEach(data, v => {
deepFreeze(v.value);
deepFreeze(v.defaultValue);
});
this._data = data;
}
private validateBeforeSet(key, value) {
const pref = this._data[key];
throwIf(!pref, `Cannot set preference ${key}: not found`);
throwIf(value === undefined, `Cannot set preference ${key}: value not defined`);
throwIf(
!this.valueIsOfType(value, pref.type),
`Cannot set preference ${key}: must be of type ${pref.type}`
);
}
private valueIsOfType(value, type) {
const valueType = typeof value;
switch (type) {
case 'string':
return valueType === 'string';
case 'int':
case 'long':
case 'double':
return valueType === 'number';
case 'bool':
return valueType === 'boolean';
case 'json':
return valueType === 'object';
default:
return false;
}
}
}