@xh/hoist 86.3.0 → 86.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 86.4.0 - 2026-07-15
4
+
5
+ ### 🎁 New Features
6
+
7
+ * Added `PrefService.isSet()` to report whether the current user has an explicit value on file for a
8
+ preference vs. receiving its server-side default - a distinction that cannot be reliably inferred
9
+ by comparing the value to the default. Requires a hoist-core version that emits the backing
10
+ `isSet` flag; against older servers all prefs report as unset.
11
+
12
+ ### 🐞 Bug Fixes
13
+
14
+ * `PrefService.unset()` now performs a true server-side unset, clearing the user's stored value so
15
+ the preference reverts to its (possibly changing) default and `isSet()` reports `false`.
16
+ Previously it persisted the current default as an explicit user value. Falls back to the legacy
17
+ behavior against hoist-core versions that predate the `xh/unsetPrefs` endpoint.
18
+ * Fixed `FilterChooser` popover mode to render an opaque background when expanded.
19
+
3
20
  ## 86.3.0 - 2026-07-10
4
21
 
5
22
  ### 🎁 New Features
package/admin/AppModel.ts CHANGED
@@ -9,7 +9,9 @@ import {TabConfig, TabContainerModel} from '@xh/hoist/cmp/tab';
9
9
  import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager';
10
10
  import {HoistAppModel, HoistRoute, InitContext, XH} from '@xh/hoist/core';
11
11
  import {Icon} from '@xh/hoist/icon';
12
+ import {SECONDS} from '@xh/hoist/utils/datetime';
12
13
  import {without} from 'lodash';
14
+ import {RoleModuleConfig} from './tabs/userData/roles/Types';
13
15
  import {activityTrackingPanel} from './tabs/activity/tracking/ActivityTrackingPanel';
14
16
  import {clientsPanel} from './tabs/clients/ClientsPanel';
15
17
  import {monitorTab} from './tabs/monitor/MonitorTab';
@@ -27,6 +29,9 @@ export class AppModel extends HoistAppModel {
27
29
 
28
30
  viewManagerModels: Record<string, ViewManagerModel> = {};
29
31
 
32
+ /** Role-module config, loaded once at init and shared with the Roles tab. */
33
+ roleModuleConfig: RoleModuleConfig = null;
34
+
30
35
  static get readonly() {
31
36
  return !XH.getUser().isHoistAdmin;
32
37
  }
@@ -34,16 +39,18 @@ export class AppModel extends HoistAppModel {
34
39
  constructor() {
35
40
  super();
36
41
 
37
- this.tabModel = new TabContainerModel({
38
- route: 'default',
39
- tabs: this.createTabs()
40
- });
41
-
42
42
  // Enable managed autosize mode across Hoist Admin console grids.
43
43
  GridModel.defaults.autosizeMode = 'managed';
44
44
  }
45
45
 
46
46
  override async initAsync(ctx: InitContext) {
47
+ await this.loadRoleModuleConfigAsync(ctx);
48
+
49
+ this.tabModel = new TabContainerModel({
50
+ route: 'default',
51
+ tabs: this.createTabs()
52
+ });
53
+
47
54
  await this.initViewManagerModelsAsync(ctx);
48
55
  await super.initAsync(ctx);
49
56
  }
@@ -122,7 +129,8 @@ export class AppModel extends HoistAppModel {
122
129
  }
123
130
 
124
131
  createTabs(): TabConfig[] {
125
- const conf = XH.getConf('xhAdminAppConfig', {});
132
+ const conf = XH.getConf('xhAdminAppConfig', {}),
133
+ rolesEnabled = this.roleModuleConfig?.enabled ?? false;
126
134
 
127
135
  return [
128
136
  {
@@ -159,6 +167,7 @@ export class AppModel extends HoistAppModel {
159
167
  },
160
168
  {
161
169
  id: 'userData',
170
+ title: rolesEnabled ? 'User Data & Roles' : 'User Data',
162
171
  icon: Icon.users(),
163
172
  content: {
164
173
  refreshMode: 'onShowAlways',
@@ -172,7 +181,8 @@ export class AppModel extends HoistAppModel {
172
181
  {
173
182
  id: 'roles',
174
183
  icon: Icon.idBadge(),
175
- content: rolePanel
184
+ content: rolePanel,
185
+ omit: !rolesEnabled
176
186
  },
177
187
  {
178
188
  id: 'prefs',
@@ -219,4 +229,22 @@ export class AppModel extends HoistAppModel {
219
229
  ctx
220
230
  );
221
231
  }
232
+
233
+ //----------------
234
+ // Implementation
235
+ //----------------
236
+ private async loadRoleModuleConfigAsync(ctx: InitContext) {
237
+ // Load role config up-front to title/show the Roles tab (see createTabs).
238
+ // Never block startup if it can't load - the tab defaults to hidden.
239
+ try {
240
+ this.roleModuleConfig = await this.runner(ctx)
241
+ .span('loadRoleModuleConfig')
242
+ .fetchJson({url: 'roleAdmin/config', timeout: 10 * SECONDS});
243
+ } catch (e) {
244
+ XH.handleException(e, {
245
+ message: 'Unable to load roles configuration',
246
+ alertType: 'toast'
247
+ });
248
+ }
249
+ }
222
250
  }
@@ -4,12 +4,13 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {getAppModel} from '@xh/hoist/admin/AdminUtils';
7
8
  import {RecategorizeDialogModel} from '@xh/hoist/admin/tabs/userData/roles/recategorize/RecategorizeDialogModel';
8
9
  import {FilterChooserModel} from '@xh/hoist/cmp/filter';
9
10
  import {GridModel, tagsRenderer, TreeStyle} from '@xh/hoist/cmp/grid';
10
11
  import * as Col from '@xh/hoist/cmp/grid/columns';
11
12
  import {fragment, p} from '@xh/hoist/cmp/layout';
12
- import {CallContext, HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core';
13
+ import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core';
13
14
  import {RecordActionSpec} from '@xh/hoist/data';
14
15
  import {actionCol, calcActionColWidth} from '@xh/hoist/desktop/cmp/grid';
15
16
  import {Icon} from '@xh/hoist/icon';
@@ -39,10 +40,14 @@ export class RoleModel extends HoistModel {
39
40
  @managed recategorizeDialogModel = new RecategorizeDialogModel(this);
40
41
 
41
42
  @observable.ref allRoles: HoistRole[] = [];
42
- @observable.ref moduleConfig: RoleModuleConfig;
43
43
 
44
44
  @bindable showInGroups = true;
45
45
 
46
+ /** Role-module config - loaded at init. */
47
+ get moduleConfig(): RoleModuleConfig {
48
+ return getAppModel().roleModuleConfig;
49
+ }
50
+
46
51
  get readonly() {
47
52
  return !XH.getUser().isHoistRoleManager;
48
53
  }
@@ -56,6 +61,10 @@ export class RoleModel extends HoistModel {
56
61
  constructor() {
57
62
  super();
58
63
  makeObservable(this);
64
+
65
+ this.gridModel = this.createGridModel();
66
+ this.filterChooserModel = this.createFilterChooserModel();
67
+
59
68
  this.addReaction({
60
69
  track: () => this.showInGroups,
61
70
  run: showInGroups => {
@@ -74,9 +83,6 @@ export class RoleModel extends HoistModel {
74
83
  return this.runner({loadSpec})
75
84
  .span('list')
76
85
  .run(async ctx => {
77
- await this.ensureInitializedAsync(ctx);
78
- if (!this.moduleConfig.enabled) return;
79
-
80
86
  const {data} = await XH.fetchJson({url: 'roleAdmin/list'}, ctx);
81
87
  if (loadSpec.isStale) return;
82
88
 
@@ -233,19 +239,6 @@ export class RoleModel extends HoistModel {
233
239
  gridModel.autosizeAsync({includeCollapsedChildren: true});
234
240
  }
235
241
 
236
- private async ensureInitializedAsync(ctx: CallContext) {
237
- if (this.moduleConfig) return;
238
-
239
- const config = await this.runner(ctx).fetchJson({url: 'roleAdmin/config'});
240
- runInAction(() => {
241
- this.moduleConfig = config;
242
- if (config.enabled) {
243
- this.gridModel = this.createGridModel();
244
- this.filterChooserModel = this.createFilterChooserModel();
245
- }
246
- });
247
- }
248
-
249
242
  private processRolesFromServer(roles: Partial<HoistRole>[]): HoistRole[] {
250
243
  return roles.map(role => {
251
244
  const membersByType = mapValues(groupBy(role.members, 'type'), members =>
@@ -9,7 +9,6 @@ import {grid} from '@xh/hoist/cmp/grid';
9
9
  import {fragment, hframe, vframe} from '@xh/hoist/cmp/layout';
10
10
  import {creates, hoistCmp} from '@xh/hoist/core';
11
11
  import {button, colChooserButton} from '@xh/hoist/desktop/cmp/button';
12
- import {errorMessage} from '@xh/hoist/cmp/error';
13
12
  import {filterChooser} from '@xh/hoist/desktop/cmp/filter';
14
13
  import {switchInput} from '@xh/hoist/desktop/cmp/input';
15
14
  import {panel} from '@xh/hoist/desktop/cmp/panel';
@@ -25,12 +24,6 @@ export const rolePanel = hoistCmp.factory({
25
24
  model: creates(RoleModel),
26
25
 
27
26
  render({className, model}) {
28
- const {moduleConfig} = model;
29
- if (!moduleConfig) return null;
30
- if (!moduleConfig.enabled) {
31
- return errorMessage({error: 'Default Role Module not enabled.'});
32
- }
33
-
34
27
  const {gridModel, readonly} = model;
35
28
  return fragment(
36
29
  panel({
@@ -1,9 +1,12 @@
1
1
  import { TabConfig, TabContainerModel } from '@xh/hoist/cmp/tab';
2
2
  import { ViewManagerModel } from '@xh/hoist/cmp/viewmanager';
3
3
  import { HoistAppModel, HoistRoute, InitContext } from '@xh/hoist/core';
4
+ import { RoleModuleConfig } from './tabs/userData/roles/Types';
4
5
  export declare class AppModel extends HoistAppModel {
5
6
  tabModel: TabContainerModel;
6
7
  viewManagerModels: Record<string, ViewManagerModel>;
8
+ /** Role-module config, loaded once at init and shared with the Roles tab. */
9
+ roleModuleConfig: RoleModuleConfig;
7
10
  static get readonly(): boolean;
8
11
  constructor();
9
12
  initAsync(ctx: InitContext): Promise<void>;
@@ -15,4 +18,5 @@ export declare class AppModel extends HoistAppModel {
15
18
  openPrimaryApp(): void;
16
19
  getPrimaryAppCode(): string;
17
20
  initViewManagerModelsAsync(ctx: InitContext): Promise<void>;
21
+ private loadRoleModuleConfigAsync;
18
22
  }
@@ -19,8 +19,9 @@ export declare class RoleModel extends HoistModel {
19
19
  readonly roleEditorModel: RoleEditorModel;
20
20
  recategorizeDialogModel: RecategorizeDialogModel;
21
21
  allRoles: HoistRole[];
22
- moduleConfig: RoleModuleConfig;
23
22
  showInGroups: boolean;
23
+ /** Role-module config - loaded at init. */
24
+ get moduleConfig(): RoleModuleConfig;
24
25
  get readonly(): boolean;
25
26
  get selectedRole(): HoistRole;
26
27
  constructor();
@@ -36,7 +37,6 @@ export declare class RoleModel extends HoistModel {
36
37
  private deleteAction;
37
38
  private groupByAction;
38
39
  private displayRoles;
39
- private ensureInitializedAsync;
40
40
  private processRolesFromServer;
41
41
  private processRolesForTreeGrid;
42
42
  private createGridModel;
@@ -267,8 +267,9 @@ export declare class Store extends HoistBase implements FilterBindTarget, Filter
267
267
  * Add new Records to this Store in a local, uncommitted state - i.e. with data that has yet to
268
268
  * be persisted back to, or sourced from, the server or other data source of record.
269
269
  *
270
- * Note that data objects passed to this method must include a unique ID - callers can generate
271
- * one with `XH.genId()` if no natural ID can be produced locally on the client.
270
+ * Note that data objects passed to this method must include a literal `id` property - this
271
+ * method does *not* run the Store's `idSpec` function. Callers can generate an id with
272
+ * `XH.genId()` if no natural ID can be produced locally on the client.
272
273
  *
273
274
  * For StoreRecord additions that originate from the server, call `updateData()` instead.
274
275
  *
@@ -27,8 +27,8 @@ export declare const swiper: import("@xh/hoist/core").ElementFactory<import("rea
27
27
  onScrollbarDragStart?: (swiper: import("swiper/types").Swiper, event: MouseEvent | TouchEvent | PointerEvent) => void;
28
28
  onScrollbarDragMove?: (swiper: import("swiper/types").Swiper, event: MouseEvent | TouchEvent | PointerEvent) => void;
29
29
  onScrollbarDragEnd?: (swiper: import("swiper/types").Swiper, event: MouseEvent | TouchEvent | PointerEvent) => void;
30
- onVirtualUpdate?: (swiper: import("swiper/types").Swiper) => void;
31
30
  onZoomChange?: (swiper: import("swiper/types").Swiper, scale: number, imageEl: HTMLElement, slideEl: HTMLElement) => void;
31
+ onVirtualUpdate?: (swiper: import("swiper/types").Swiper) => void;
32
32
  onInit?: (swiper: import("swiper/types").Swiper) => any;
33
33
  onBeforeDestroy?: (swiper: import("swiper/types").Swiper) => void;
34
34
  onSlidesUpdated?: (swiper: import("swiper/types").Swiper) => void;
@@ -25,6 +25,13 @@ export declare class PrefService extends HoistService {
25
25
  * Check to see if a given preference has been *defined*.
26
26
  */
27
27
  hasKey(key: string): boolean;
28
+ /**
29
+ * Check whether the current user has an explicit value on file for the given preference, vs.
30
+ * receiving the preference's server-side default value.
31
+ *
32
+ * @param key - unique key used to identify the pref.
33
+ */
34
+ isSet(key: string): boolean;
28
35
  /**
29
36
  * Get the value for a given key, either the user-specific value (if set) or the default.
30
37
  * Typically accessed via the convenience alias {@link XH.getPref}.
@@ -47,7 +54,10 @@ export declare class PrefService extends HoistService {
47
54
  */
48
55
  set(key: string, value: any): void;
49
56
  /**
50
- * Restore a preference to its default value.
57
+ * Restore a preference to its default value, clearing the user's explicit value on the server.
58
+ *
59
+ * Unlike `set()`, this clears the user's explicit value rather than persisting the default as
60
+ * one - so {@link isSet} will report `false` afterwards. Saved asynchronously (see `set()`).
51
61
  */
52
62
  unset(key: string): void;
53
63
  /**
@@ -66,6 +76,7 @@ export declare class PrefService extends HoistService {
66
76
  pushPendingAsync(): Promise<void>;
67
77
  private pushPendingBuffered;
68
78
  private loadPrefsAsync;
79
+ private ensureKeyExists;
69
80
  private validateBeforeSet;
70
81
  private valueIsOfType;
71
82
  }
@@ -9,6 +9,15 @@ import { Moment, MomentInput } from 'moment';
9
9
  * For efficiency and to enable strict equality checks, instances of this class are memoized:
10
10
  * only a single version of the object will be created and returned for each calendar day,
11
11
  * as long as the caller uses one of the *public factory methods*, which they always should!
12
+ *
13
+ * Instances serialize directly to their ISO date string (e.g. '2024-01-15') via built-in
14
+ * `toString()`, `valueOf()`, and `toJSON()` overrides. This means a LocalDate can be passed
15
+ * as-is within the params or body of a `FetchService` request (or any `JSON.stringify()` call)
16
+ * and will serialize as expected - prefer this over calling `toString()` or `format()` yourself.
17
+ *
18
+ * Instances also support natural comparison: because they are memoized, `===` tests whether two
19
+ * references are the same calendar day, while `valueOf()` returning the (lexically sortable) ISO
20
+ * string means the relational operators `<`, `>`, `<=`, `>=` order instances chronologically.
12
21
  */
13
22
  export declare class LocalDate {
14
23
  static readonly VALID_UNITS: Set<LocalDateUnit>;
@@ -33,7 +42,11 @@ export declare class LocalDate {
33
42
  * @param val - any string, timestamp, or date parsable by moment.js.
34
43
  */
35
44
  static from(val: MomentInput | LocalDate): LocalDate;
36
- /** LocalDate representing the current day. */
45
+ /**
46
+ * LocalDate representing the current day in the browser's local time zone.
47
+ * See `currentAppDay()` / `currentServerDay()` to resolve "today" in the app or server zone,
48
+ * which can differ from the browser for users in another region.
49
+ */
37
50
  static today(): LocalDate;
38
51
  /** LocalDate representing the current day in the App TimeZone */
39
52
  static currentAppDay(): LocalDate;
@@ -46,9 +59,17 @@ export declare class LocalDate {
46
59
  /** Is the input value a local Date? */
47
60
  static isLocalDate(val: any): boolean;
48
61
  get isoString(): string;
62
+ /** JS `Date` for this day at midnight in the browser's local time zone. Fresh instance per call. */
49
63
  get date(): Date;
64
+ /** A mutable moment.js clone - safe to modify without affecting this (immutable) instance. */
50
65
  get moment(): Moment;
66
+ /** Epoch millis for this day at midnight in the browser's local time zone. */
51
67
  get timestamp(): number;
68
+ /**
69
+ * Format this date using moment.js format tokens, primarily for display.
70
+ * Note: to send a LocalDate to the server, pass the instance directly rather than a formatted
71
+ * string - it serializes to an ISO date on its own (see class-level docs).
72
+ */
52
73
  format(...args: any[]): string;
53
74
  dayOfWeek(): string;
54
75
  get isToday(): boolean;
@@ -83,6 +104,7 @@ export declare class LocalDate {
83
104
  currentOrNextWeekday(): LocalDate;
84
105
  /** The same date if already a weekday, or the previous weekday. */
85
106
  currentOrPreviousWeekday(): LocalDate;
107
+ /** Difference between this date and `other` in the given unit; positive when this is later. */
86
108
  diff(other: LocalDate, unit?: LocalDateUnit): number;
87
109
  /** @internal - use one of the static factory methods instead. */
88
110
  private constructor();
package/data/Store.ts CHANGED
@@ -559,8 +559,9 @@ export class Store
559
559
  * Add new Records to this Store in a local, uncommitted state - i.e. with data that has yet to
560
560
  * be persisted back to, or sourced from, the server or other data source of record.
561
561
  *
562
- * Note that data objects passed to this method must include a unique ID - callers can generate
563
- * one with `XH.genId()` if no natural ID can be produced locally on the client.
562
+ * Note that data objects passed to this method must include a literal `id` property - this
563
+ * method does *not* run the Store's `idSpec` function. Callers can generate an id with
564
+ * `XH.genId()` if no natural ID can be produced locally on the client.
564
565
  *
565
566
  * For StoreRecord additions that originate from the server, call `updateData()` instead.
566
567
  *
@@ -47,8 +47,9 @@
47
47
  flex: 1;
48
48
  }
49
49
 
50
+ // Opaque bg to occlude the trigger and page beneath (--xh-input-bg is translucent in dark mode).
50
51
  .bp6-popover-content {
51
- background: transparent;
52
+ background: var(--xh-bg);
52
53
  }
53
54
 
54
55
  .xh-select__value-container--is-multi {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "86.3.0",
3
+ "version": "86.4.0",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@auth0/auth0-spa-js": "~2.23.0",
42
- "@azure/msal-browser": "~5.16.0",
42
+ "@azure/msal-browser": "~5.17.0",
43
43
  "@blueprintjs/core": "^6.3.2",
44
44
  "@blueprintjs/datetime": "^6.0.6",
45
45
  "@codemirror/commands": "^6.10.3",
@@ -30,9 +30,8 @@ export class PrefService extends HoistService {
30
30
  override telemetryPrefix = 'xh.client.prefs';
31
31
 
32
32
  static instance: PrefService;
33
-
34
- private _data = {};
35
- private _updates = {};
33
+ private _data: Record<string, PrefEntry> = {};
34
+ private _updates: Record<string, any> = {}; // undefined indicates unset
36
35
 
37
36
  override async initAsync(ctx: InitContext) {
38
37
  // Flush on page teardown while the page is still alive.
@@ -52,6 +51,17 @@ export class PrefService extends HoistService {
52
51
  return this._data.hasOwnProperty(key);
53
52
  }
54
53
 
54
+ /**
55
+ * Check whether the current user has an explicit value on file for the given preference, vs.
56
+ * receiving the preference's server-side default value.
57
+ *
58
+ * @param key - unique key used to identify the pref.
59
+ */
60
+ isSet(key: string): boolean {
61
+ this.ensureKeyExists(key);
62
+ return !!this._data[key].isSet;
63
+ }
64
+
55
65
  /**
56
66
  * Get the value for a given key, either the user-specific value (if set) or the default.
57
67
  * Typically accessed via the convenience alias {@link XH.getPref}.
@@ -91,7 +101,9 @@ export class PrefService extends HoistService {
91
101
 
92
102
  // Change local value to sanitized copy and fire.
93
103
  value = deepFreeze(cloneDeep(value));
94
- this._data[key].value = value;
104
+ const pref = this._data[key];
105
+ pref.value = value;
106
+ pref.isSet = true;
95
107
 
96
108
  // Schedule serialization to storage
97
109
  this._updates[key] = value;
@@ -99,11 +111,22 @@ export class PrefService extends HoistService {
99
111
  }
100
112
 
101
113
  /**
102
- * Restore a preference to its default value.
114
+ * Restore a preference to its default value, clearing the user's explicit value on the server.
115
+ *
116
+ * Unlike `set()`, this clears the user's explicit value rather than persisting the default as
117
+ * one - so {@link isSet} will report `false` afterwards. Saved asynchronously (see `set()`).
103
118
  */
104
119
  unset(key: string) {
105
- // TODO: round-trip this to the server as a proper unset?
106
- this.set(key, this._data[key]?.defaultValue);
120
+ this.ensureKeyExists(key);
121
+ const pref = this._data[key];
122
+ if (!pref.isSet && isEqual(pref.value, pref.defaultValue)) return;
123
+
124
+ pref.value = pref.defaultValue;
125
+ pref.isSet = false;
126
+
127
+ // Schedule serialization to storage
128
+ this._updates[key] = undefined;
129
+ this.pushPendingBuffered();
107
130
  }
108
131
 
109
132
  /**
@@ -131,18 +154,44 @@ export class PrefService extends HoistService {
131
154
  // Clear synchronously with the capture, so overlapping flushes cannot post twice.
132
155
  this._updates = {};
133
156
 
157
+ // Partition into value updates and unsets.
158
+ // On a core that predates unset support, fall back to persisting default
159
+ const setPrefs = {},
160
+ unsetKeys = [];
161
+ forEach(updates, (value, key) => {
162
+ const pref = this._data[key];
163
+ if (value !== undefined) {
164
+ setPrefs[key] = value;
165
+ } else if (pref.hasOwnProperty('isSet')) {
166
+ unsetKeys.push(key);
167
+ } else {
168
+ setPrefs[key] = pref.defaultValue;
169
+ }
170
+ });
171
+
134
172
  await this.runner()
135
- .span('set')
136
- .run(ctx =>
137
- terminationSafePostJson(
138
- {
139
- url: 'xh/setPrefs',
140
- body: updates,
141
- params: {clientUsername: XH.getUsername()}
142
- },
143
- ctx
144
- )
145
- );
173
+ .span('update')
174
+ .run(async ctx => {
175
+ const clientUsername = XH.getUsername(),
176
+ tasks = [];
177
+ if (!isEmpty(setPrefs)) {
178
+ tasks.push(
179
+ terminationSafePostJson(
180
+ {url: 'xh/setPrefs', body: setPrefs, params: {clientUsername}},
181
+ ctx
182
+ )
183
+ );
184
+ }
185
+ if (!isEmpty(unsetKeys)) {
186
+ tasks.push(
187
+ terminationSafePostJson(
188
+ {url: 'xh/unsetPrefs', body: unsetKeys, params: {clientUsername}},
189
+ ctx
190
+ )
191
+ );
192
+ }
193
+ await Promise.all(tasks);
194
+ });
146
195
  }
147
196
 
148
197
  //-------------------
@@ -172,9 +221,13 @@ export class PrefService extends HoistService {
172
221
  });
173
222
  }
174
223
 
175
- private validateBeforeSet(key, value) {
224
+ private ensureKeyExists(key: string) {
225
+ throwIf(!this.hasKey(key), `Preference key not found: '${key}'`);
226
+ }
227
+
228
+ private validateBeforeSet(key: string, value: any) {
229
+ this.ensureKeyExists(key);
176
230
  const pref = this._data[key];
177
- throwIf(!pref, `Cannot set preference ${key}: not found`);
178
231
  throwIf(value === undefined, `Cannot set preference ${key}: value not defined`);
179
232
  throwIf(
180
233
  !this.valueIsOfType(value, pref.type),
@@ -201,3 +254,10 @@ export class PrefService extends HoistService {
201
254
  }
202
255
  }
203
256
  }
257
+
258
+ interface PrefEntry {
259
+ type: string;
260
+ value: any;
261
+ defaultValue: any;
262
+ isSet: boolean;
263
+ }
package/svc/README.md CHANGED
@@ -186,6 +186,12 @@ XH.setPref('gridPageSize', 100);
186
186
 
187
187
  // Immediate save - no alias, access service directly
188
188
  await XH.prefService.pushAsync('criticalPref', value);
189
+
190
+ // Distinguish an explicit user value from the server-side default
191
+ if (XH.prefService.isSet('gridPageSize')) { /* user has customized this */ }
192
+
193
+ // Clear the user's value, reverting to the default (real server-side unset)
194
+ XH.prefService.unset('gridPageSize');
189
195
  ```
190
196
 
191
197
  Preferences are type-validated against server-defined types: `string`, `int`, `long`, `double`,
@@ -18,6 +18,15 @@ import moment, {Moment, MomentInput} from 'moment';
18
18
  * For efficiency and to enable strict equality checks, instances of this class are memoized:
19
19
  * only a single version of the object will be created and returned for each calendar day,
20
20
  * as long as the caller uses one of the *public factory methods*, which they always should!
21
+ *
22
+ * Instances serialize directly to their ISO date string (e.g. '2024-01-15') via built-in
23
+ * `toString()`, `valueOf()`, and `toJSON()` overrides. This means a LocalDate can be passed
24
+ * as-is within the params or body of a `FetchService` request (or any `JSON.stringify()` call)
25
+ * and will serialize as expected - prefer this over calling `toString()` or `format()` yourself.
26
+ *
27
+ * Instances also support natural comparison: because they are memoized, `===` tests whether two
28
+ * references are the same calendar day, while `valueOf()` returning the (lexically sortable) ISO
29
+ * string means the relational operators `<`, `>`, `<=`, `>=` order instances chronologically.
21
30
  */
22
31
  export class LocalDate {
23
32
  static readonly VALID_UNITS: Set<LocalDateUnit> = new Set([
@@ -77,7 +86,11 @@ export class LocalDate {
77
86
  return this.get(m.format('YYYY-MM-DD'));
78
87
  }
79
88
 
80
- /** LocalDate representing the current day. */
89
+ /**
90
+ * LocalDate representing the current day in the browser's local time zone.
91
+ * See `currentAppDay()` / `currentServerDay()` to resolve "today" in the app or server zone,
92
+ * which can differ from the browser for users in another region.
93
+ */
81
94
  static today(): LocalDate {
82
95
  return this.from(moment());
83
96
  }
@@ -120,18 +133,26 @@ export class LocalDate {
120
133
  return this._isoString;
121
134
  }
122
135
 
136
+ /** JS `Date` for this day at midnight in the browser's local time zone. Fresh instance per call. */
123
137
  get date(): Date {
124
138
  return new Date(this.timestamp);
125
139
  }
126
140
 
141
+ /** A mutable moment.js clone - safe to modify without affecting this (immutable) instance. */
127
142
  get moment(): Moment {
128
143
  return this._moment.clone();
129
144
  }
130
145
 
146
+ /** Epoch millis for this day at midnight in the browser's local time zone. */
131
147
  get timestamp(): number {
132
148
  return this._date.getTime();
133
149
  }
134
150
 
151
+ /**
152
+ * Format this date using moment.js format tokens, primarily for display.
153
+ * Note: to send a LocalDate to the server, pass the instance directly rather than a formatted
154
+ * string - it serializes to an ISO date on its own (see class-level docs).
155
+ */
135
156
  format(...args): string {
136
157
  return this._moment.format(...args);
137
158
  }
@@ -186,6 +207,7 @@ export class LocalDate {
186
207
  return this._isoString;
187
208
  }
188
209
 
210
+ // Returns the ISO string (not a number) so the relational operators sort instances by date.
189
211
  valueOf(): string {
190
212
  return this._isoString;
191
213
  }
@@ -319,6 +341,7 @@ export class LocalDate {
319
341
  return this.isWeekday ? this : this.previousWeekday();
320
342
  }
321
343
 
344
+ /** Difference between this date and `other` in the given unit; positive when this is later. */
322
345
  diff(other: LocalDate, unit: LocalDateUnit = 'days'): number {
323
346
  this.ensureUnitValid(unit);
324
347
  return this._moment.diff(other._moment, unit);