@xh/hoist 80.0.0-SNAPSHOT.1768323341476 → 80.0.0-SNAPSHOT.1768415875152

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +27 -3
  2. package/admin/tabs/activity/tracking/ActivityTracking.scss +7 -7
  3. package/admin/tabs/userData/roles/details/RoleDetails.scss +6 -6
  4. package/build/types/cmp/form/FormModel.d.ts +1 -1
  5. package/build/types/cmp/form/field/BaseFieldModel.d.ts +7 -3
  6. package/build/types/cmp/form/field/SubformsFieldModel.d.ts +3 -3
  7. package/build/types/cmp/grid/Grid.d.ts +2 -2
  8. package/build/types/data/Field.d.ts +2 -1
  9. package/build/types/data/Store.d.ts +7 -4
  10. package/build/types/data/StoreRecord.d.ts +5 -0
  11. package/build/types/data/impl/RecordValidator.d.ts +9 -10
  12. package/build/types/data/impl/StoreValidator.d.ts +8 -7
  13. package/build/types/data/index.d.ts +1 -0
  14. package/build/types/data/validation/Rule.d.ts +5 -40
  15. package/build/types/data/validation/Types.d.ts +56 -0
  16. package/build/types/data/validation/constraints.d.ts +1 -1
  17. package/build/types/desktop/cmp/appOption/AutoRefreshAppOption.d.ts +3 -3
  18. package/build/types/desktop/cmp/appOption/ThemeAppOption.d.ts +3 -3
  19. package/cmp/form/FormModel.ts +2 -2
  20. package/cmp/form/field/BaseFieldModel.ts +38 -18
  21. package/cmp/form/field/SubformsFieldModel.ts +5 -5
  22. package/cmp/grid/Grid.scss +31 -8
  23. package/cmp/grid/columns/Column.ts +24 -10
  24. package/cmp/input/HoistInput.scss +19 -1
  25. package/cmp/input/HoistInputModel.ts +10 -2
  26. package/data/Field.ts +2 -1
  27. package/data/Store.ts +16 -4
  28. package/data/StoreRecord.ts +11 -0
  29. package/data/impl/RecordValidator.ts +46 -28
  30. package/data/impl/StoreValidator.ts +22 -9
  31. package/data/index.ts +1 -0
  32. package/data/validation/Rule.ts +12 -52
  33. package/data/validation/Types.ts +81 -0
  34. package/data/validation/constraints.ts +2 -1
  35. package/desktop/appcontainer/OptionsDialog.scss +1 -1
  36. package/desktop/cmp/form/FormField.scss +136 -42
  37. package/desktop/cmp/form/FormField.ts +74 -40
  38. package/desktop/cmp/input/CodeInput.scss +13 -1
  39. package/desktop/cmp/input/RadioInput.scss +16 -4
  40. package/desktop/cmp/input/SwitchInput.scss +23 -5
  41. package/desktop/cmp/input/TextArea.scss +9 -1
  42. package/desktop/cmp/rest/impl/RestForm.scss +1 -1
  43. package/desktop/cmp/viewmanager/ViewManager.scss +7 -15
  44. package/kit/blueprint/styles.scss +4 -4
  45. package/kit/onsen/styles.scss +10 -2
  46. package/mobile/cmp/form/FormField.scss +52 -19
  47. package/mobile/cmp/form/FormField.ts +30 -21
  48. package/package.json +1 -1
  49. package/styles/XH.scss +1 -0
  50. package/styles/vars.scss +77 -12
  51. package/tsconfig.tsbuildinfo +1 -1
@@ -5,12 +5,19 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {HoistModel, managed, TaskObserver} from '@xh/hoist/core';
8
- import {genDisplayName, required, Rule, RuleLike, ValidationState} from '@xh/hoist/data';
8
+ import {
9
+ genDisplayName,
10
+ required,
11
+ Rule,
12
+ RuleLike,
13
+ ValidationResult,
14
+ ValidationState
15
+ } from '@xh/hoist/data';
9
16
  import {action, bindable, computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx';
10
17
  import {wait} from '@xh/hoist/promise';
11
18
  import {executeIfFunction, withDefault} from '@xh/hoist/utils/js';
12
19
  import {createObservableRef} from '@xh/hoist/utils/react';
13
- import {compact, flatten, isEmpty, isEqual, isFunction, isNil} from 'lodash';
20
+ import {compact, flatten, isEmpty, isEqual, isFunction, isNil, isString} from 'lodash';
14
21
  import {FormModel} from '../FormModel';
15
22
 
16
23
  export interface BaseFieldConfig {
@@ -91,11 +98,11 @@ export abstract class BaseFieldModel extends HoistModel {
91
98
 
92
99
  boundInputRef = createObservableRef();
93
100
 
94
- // An array with the result of evaluating each rule. Each element will be array of strings
95
- // containing any validation errors for the rule. If validation for the rule has not
96
- // completed will contain null
101
+ // An array with the result of evaluating each rule. Each element will be an array of
102
+ // ValidationResults for the rule. If validation for the rule has not completed will contain
103
+ // null.
97
104
  @observable
98
- private _errors: string[][];
105
+ private validationResultsInternal: ValidationResult[][];
99
106
 
100
107
  @managed
101
108
  private validationTask = TaskObserver.trackLast();
@@ -118,7 +125,7 @@ export abstract class BaseFieldModel extends HoistModel {
118
125
  this._disabled = disabled;
119
126
  this._readonly = readonly;
120
127
  this.rules = this.processRuleSpecs(rules);
121
- this._errors = this.rules.map(() => null);
128
+ this.validationResultsInternal = this.rules.map(() => null);
122
129
  }
123
130
 
124
131
  //-----------------------------
@@ -175,12 +182,25 @@ export abstract class BaseFieldModel extends HoistModel {
175
182
  /** All validation errors for this field. */
176
183
  @computed
177
184
  get errors(): string[] {
178
- return compact(flatten(this._errors));
185
+ return this.validationResults.filter(it => it.severity === 'error').map(it => it.message);
186
+ }
187
+
188
+ /** All ValidationResults for this field. */
189
+ @computed
190
+ get validationResults(): ValidationResult[] {
191
+ return compact(flatten(this.validationResultsInternal));
179
192
  }
180
193
 
181
194
  /** All validation errors for this field and its sub-forms. */
182
195
  get allErrors(): string[] {
183
- return this.errors;
196
+ return this.allValidationResults
197
+ .filter(it => it.severity === 'error')
198
+ .map(it => it.message);
199
+ }
200
+
201
+ /** All ValidationResults for this field and its sub-forms. */
202
+ get allValidationResults(): ValidationResult[] {
203
+ return this.validationResults;
184
204
  }
185
205
 
186
206
  /**
@@ -202,7 +222,7 @@ export abstract class BaseFieldModel extends HoistModel {
202
222
 
203
223
  // Force an immediate 'Unknown' state -- the async recompute leaves the old state in place until it completed.
204
224
  // (We want that for a value change, but not reset/init) Force the recompute only if needed.
205
- this._errors.fill(null);
225
+ this.validationResultsInternal.fill(null);
206
226
  wait().then(() => {
207
227
  if (!this.isValidationPending && this.validationState === 'Unknown') {
208
228
  this.computeValidationAsync();
@@ -300,7 +320,7 @@ export abstract class BaseFieldModel extends HoistModel {
300
320
  }
301
321
 
302
322
  /**
303
- * Recompute all validations and return true if the field is valid.
323
+ * Recompute all ValidationResults and return true if the field is valid.
304
324
  *
305
325
  * @param display - true to trigger the display of validation errors (if any)
306
326
  * by the bound FormField component after validation is complete.
@@ -339,13 +359,13 @@ export abstract class BaseFieldModel extends HoistModel {
339
359
  const promises = this.rules.map(async (rule, idx) => {
340
360
  const result = await this.evaluateRuleAsync(rule);
341
361
  if (runId === this.validationRunId) {
342
- runInAction(() => (this._errors[idx] = result));
362
+ runInAction(() => (this.validationResultsInternal[idx] = result));
343
363
  }
344
364
  });
345
365
  await Promise.all(promises);
346
366
  }
347
367
 
348
- private async evaluateRuleAsync(rule): Promise<string[]> {
368
+ private async evaluateRuleAsync(rule: Rule): Promise<ValidationResult[]> {
349
369
  if (this.ruleIsActive(rule)) {
350
370
  const promises = rule.check.map(async constraint => {
351
371
  const {value, name, displayName} = this,
@@ -355,7 +375,9 @@ export abstract class BaseFieldModel extends HoistModel {
355
375
  });
356
376
 
357
377
  const ret = await Promise.all(promises);
358
- return compact(flatten(ret));
378
+ return compact(flatten(ret)).map(issue =>
379
+ isString(issue) ? {message: issue, severity: 'error'} : issue
380
+ );
359
381
  }
360
382
  return [];
361
383
  }
@@ -367,10 +389,8 @@ export abstract class BaseFieldModel extends HoistModel {
367
389
  }
368
390
 
369
391
  protected deriveValidationState(): ValidationState {
370
- const {_errors} = this;
371
-
372
- if (_errors.some(e => !isEmpty(e))) return 'NotValid';
373
- if (_errors.some(e => isNil(e))) return 'Unknown';
392
+ if (!isEmpty(this.errors)) return 'NotValid';
393
+ if (this.validationResultsInternal.some(e => isNil(e))) return 'Unknown';
374
394
  return 'Valid';
375
395
  }
376
396
  }
@@ -5,7 +5,7 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {managed, PlainObject, XH} from '@xh/hoist/core';
8
- import {ValidationState} from '@xh/hoist/data';
8
+ import {ValidationResult, ValidationState} from '@xh/hoist/data';
9
9
  import {action, computed, makeObservable, override} from '@xh/hoist/mobx';
10
10
  import {throwIf} from '@xh/hoist/utils/js';
11
11
  import {clone, defaults, isEqual, flatMap, isArray, partition, without} from 'lodash';
@@ -47,7 +47,7 @@ export interface SubformAddOptions {
47
47
  * all existing form contents to new values. Call {@link add} or {@link remove} on one of these
48
48
  * fields to adjust the contents of its collection while preserving existing state.
49
49
  *
50
- * Validation rules for the entire collection may be specified as for any field, but validations on
50
+ * Validation rules for the entire collection may be specified as for any field, but ValidationResults on
51
51
  * the subforms will also bubble up to this field, affecting its overall validation state.
52
52
  */
53
53
  export class SubformsFieldModel extends BaseFieldModel {
@@ -114,9 +114,9 @@ export class SubformsFieldModel extends BaseFieldModel {
114
114
  }
115
115
 
116
116
  @computed
117
- override get allErrors(): string[] {
118
- const subErrs = flatMap(this.value, s => s.allErrors);
119
- return [...this.errors, ...subErrs];
117
+ override get allValidationResults(): ValidationResult[] {
118
+ const subVals = flatMap(this.value, s => s.allValidations);
119
+ return [...this.validationResults, ...subVals];
120
120
  }
121
121
 
122
122
  @override
@@ -104,16 +104,16 @@
104
104
  padding-right: 0;
105
105
  }
106
106
 
107
- // Render badge on invalid cells
108
- .ag-cell.xh-cell--invalid {
107
+ // Render badge on cells with validation issues
108
+ .ag-cell.xh-cell--invalid,
109
+ .ag-cell.xh-cell--warning,
110
+ .ag-cell.xh-cell--info {
109
111
  &::before {
110
112
  content: '';
111
113
  position: absolute;
112
114
  top: 0;
113
115
  right: 0;
114
116
  border-color: transparent;
115
- border-right-color: var(--xh-intent-danger);
116
- border-top-color: var(--xh-intent-danger);
117
117
  border-style: solid;
118
118
  }
119
119
 
@@ -125,6 +125,21 @@
125
125
  }
126
126
  }
127
127
 
128
+ .ag-cell.xh-cell--invalid::before {
129
+ border-right-color: var(--xh-intent-danger);
130
+ border-top-color: var(--xh-intent-danger);
131
+ }
132
+
133
+ .ag-cell.xh-cell--warning::before {
134
+ border-right-color: var(--xh-intent-warning);
135
+ border-top-color: var(--xh-intent-warning);
136
+ }
137
+
138
+ .ag-cell.xh-cell--info::before {
139
+ border-right-color: var(--xh-intent-primary);
140
+ border-top-color: var(--xh-intent-primary);
141
+ }
142
+
128
143
  // Render left / right group borders
129
144
  .ag-cell.xh-cell--group-border-left {
130
145
  @include AgGrid.group-border(left);
@@ -136,25 +151,33 @@
136
151
 
137
152
  .xh-ag-grid {
138
153
  &--tiny {
139
- .ag-cell.xh-cell--invalid::before {
154
+ .ag-cell.xh-cell--invalid::before,
155
+ .ag-cell.xh-cell--warning::before,
156
+ .ag-cell.xh-cell--info::before {
140
157
  border-width: 3px;
141
158
  }
142
159
  }
143
160
 
144
161
  &--compact {
145
- .ag-cell.xh-cell--invalid::before {
162
+ .ag-cell.xh-cell--invalid::before,
163
+ .ag-cell.xh-cell--warning::before,
164
+ .ag-cell.xh-cell--info::before {
146
165
  border-width: 4px;
147
166
  }
148
167
  }
149
168
 
150
169
  &--standard {
151
- .ag-cell.xh-cell--invalid::before {
170
+ .ag-cell.xh-cell--invalid::before,
171
+ .ag-cell.xh-cell--warning::before,
172
+ .ag-cell.xh-cell--info::before {
152
173
  border-width: 5px;
153
174
  }
154
175
  }
155
176
 
156
177
  &--large {
157
- .ag-cell.xh-cell--invalid::before {
178
+ .ag-cell.xh-cell--invalid::before,
179
+ .ag-cell.xh-cell--warning::before,
180
+ .ag-cell.xh-cell--info::before {
158
181
  border-width: 6px;
159
182
  }
160
183
  }
@@ -10,9 +10,12 @@ import {
10
10
  CubeFieldSpec,
11
11
  FieldSpec,
12
12
  genDisplayName,
13
+ maxSeverity,
13
14
  RecordAction,
14
15
  RecordActionSpec,
15
- StoreRecord
16
+ StoreRecord,
17
+ ValidationResult,
18
+ ValidationSeverity
16
19
  } from '@xh/hoist/data';
17
20
  import {logDebug, logWarn, throwIf, warnIf, withDefault} from '@xh/hoist/utils/js';
18
21
  import classNames from 'classnames';
@@ -21,6 +24,7 @@ import {
21
24
  clone,
22
25
  find,
23
26
  get,
27
+ groupBy,
24
28
  isArray,
25
29
  isEmpty,
26
30
  isFinite,
@@ -867,20 +871,28 @@ export class Column {
867
871
  if (location === 'header') return div({ref: wrapperRef, item: this.headerTooltip});
868
872
  if (!hasRecord) return null;
869
873
 
870
- // Override with validation errors, if present
874
+ // Override with validation errors, if present -- only show highest-severity level
871
875
  if (editor) {
872
- const errors = record.errors[field];
873
- if (!isEmpty(errors)) {
876
+ const validationsBySeverity = groupBy(
877
+ record.validationResults[field],
878
+ 'severity'
879
+ ) as Record<ValidationSeverity, ValidationResult[]>,
880
+ validationMessages = (
881
+ validationsBySeverity.error ??
882
+ validationsBySeverity.warning ??
883
+ validationsBySeverity.info
884
+ )?.map(v => v.message);
885
+ if (!isEmpty(validationMessages)) {
874
886
  return div({
875
887
  ref: wrapperRef,
876
888
  item: ul({
877
889
  className: classNames(
878
890
  'xh-grid-tooltip--validation',
879
- errors.length === 1
891
+ validationMessages.length === 1
880
892
  ? 'xh-grid-tooltip--validation--single'
881
893
  : null
882
894
  ),
883
- items: errors.map((it, idx) => li({key: idx, item: it}))
895
+ items: validationMessages.map((it, idx) => li({key: idx, item: it}))
884
896
  })
885
897
  });
886
898
  }
@@ -1007,10 +1019,12 @@ export class Column {
1007
1019
  });
1008
1020
  ret.cellEditorPopup = this.editorIsPopup;
1009
1021
  ret.cellClassRules = {
1010
- 'xh-cell--invalid': agParams => {
1011
- const record = agParams.data;
1012
- return record && !isEmpty(record.errors[field]);
1013
- },
1022
+ 'xh-cell--invalid': agParams =>
1023
+ maxSeverity(agParams.data.validationResults[field]) === 'error',
1024
+ 'xh-cell--warning': agParams =>
1025
+ maxSeverity(agParams.data.validationResults[field]) === 'warning',
1026
+ 'xh-cell--info': agParams =>
1027
+ maxSeverity(agParams.data.validationResults[field]) === 'info',
1014
1028
  'xh-cell--editable': agParams => {
1015
1029
  return this.isEditableForRecord(agParams.data);
1016
1030
  },
@@ -6,9 +6,27 @@
6
6
  */
7
7
 
8
8
  .xh-input {
9
- &.xh-input-invalid {
9
+ // Override browser user agent styles on input elements.
10
+ input {
11
+ font-family: var(--xh-input-font-family);
12
+ font-feature-settings: var(--xh-input-font-feature-settings);
13
+ }
14
+
15
+ &.xh-input--invalid {
10
16
  input {
11
17
  border: var(--xh-form-field-invalid-border);
12
18
  }
13
19
  }
20
+
21
+ &.xh-input--warning {
22
+ input {
23
+ border: var(--xh-form-field-warning-border);
24
+ }
25
+ }
26
+
27
+ &.xh-input--info {
28
+ input {
29
+ border: var(--xh-form-field-info-border);
30
+ }
31
+ }
14
32
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import {FieldModel} from '@xh/hoist/cmp/form';
8
8
  import {DefaultHoistProps, HoistModel, HoistModelClass, useLocalModel} from '@xh/hoist/core';
9
+ import {maxSeverity} from '@xh/hoist/data';
9
10
  import {action, computed, makeObservable, observable} from '@xh/hoist/mobx';
10
11
  import {createObservableRef} from '@xh/hoist/utils/react';
11
12
  import classNames from 'classnames';
@@ -336,13 +337,20 @@ export function useHoistInputModel(
336
337
  useImperativeHandle(ref, () => inputModel);
337
338
 
338
339
  const field = inputModel.getField(),
339
- validityClass = field?.isNotValid && field?.validationDisplayed ? 'xh-input-invalid' : null,
340
+ severityToDisplay = field?.validationDisplayed && maxSeverity(field?.validationResults),
341
+ displayInvalid = severityToDisplay === 'error',
340
342
  disabledClass = props.disabled ? 'xh-input-disabled' : null;
341
343
 
342
344
  return component({
343
345
  ...props,
344
346
  model: inputModel,
345
347
  ref: inputModel.domRef,
346
- className: classNames('xh-input', validityClass, disabledClass, props.className)
348
+ className: classNames(
349
+ 'xh-input',
350
+ severityToDisplay && `xh-input--${severityToDisplay}`,
351
+ displayInvalid && 'xh-input--invalid',
352
+ disabledClass,
353
+ props.className
354
+ )
347
355
  });
348
356
  }
package/data/Field.ts CHANGED
@@ -6,9 +6,10 @@
6
6
  */
7
7
 
8
8
  import {XH} from '@xh/hoist/core';
9
+ import {RuleLike} from '@xh/hoist/data/validation/Types';
9
10
  import {isLocalDate, LocalDate} from '@xh/hoist/utils/datetime';
10
11
  import {withDefault} from '@xh/hoist/utils/js';
11
- import {Rule, RuleLike} from './validation/Rule';
12
+ import {Rule} from './validation/Rule';
12
13
  import equal from 'fast-deep-equal';
13
14
  import {isDate, isString, toNumber, isFinite, startCase, isFunction, castArray} from 'lodash';
14
15
  import DOMPurify from 'dompurify';
package/data/Store.ts CHANGED
@@ -16,8 +16,12 @@ import {
16
16
  parseFilter,
17
17
  StoreRecord,
18
18
  StoreRecordId,
19
- StoreRecordOrId
19
+ StoreRecordOrId,
20
+ StoreValidationMessagesMap,
21
+ StoreValidationResultsMap,
22
+ ValidationResult
20
23
  } from '@xh/hoist/data';
24
+ import {StoreValidator} from '@xh/hoist/data/impl/StoreValidator';
21
25
  import {action, computed, makeObservable, observable} from '@xh/hoist/mobx';
22
26
  import {logWithDebug, throwIf, warnIf} from '@xh/hoist/utils/js';
23
27
  import equal from 'fast-deep-equal';
@@ -41,7 +45,6 @@ import {
41
45
  } from 'lodash';
42
46
  import {instanceManager} from '../core/impl/InstanceManager';
43
47
  import {RecordSet} from './impl/RecordSet';
44
- import {StoreErrorMap, StoreValidator} from './impl/StoreValidator';
45
48
 
46
49
  export interface StoreConfig {
47
50
  /** Field names, configs, or instances. */
@@ -893,10 +896,14 @@ export class Store extends HoistBase implements FilterBindTarget, FilterValueSou
893
896
  return this._current.maxDepth; // maxDepth should not be effected by filtering.
894
897
  }
895
898
 
896
- get errors(): StoreErrorMap {
899
+ get errors(): StoreValidationMessagesMap {
897
900
  return this.validator.errors;
898
901
  }
899
902
 
903
+ get validationResults(): StoreValidationResultsMap {
904
+ return this.validator.validationResults;
905
+ }
906
+
900
907
  /** Count of all validation errors for the store. */
901
908
  get errorCount(): number {
902
909
  return this.validator.errorCount;
@@ -907,6 +914,11 @@ export class Store extends HoistBase implements FilterBindTarget, FilterValueSou
907
914
  return uniq(flatMapDeep(this.errors, values));
908
915
  }
909
916
 
917
+ /** Array of all ValidationResults for this store. */
918
+ get allValidationResults(): ValidationResult[] {
919
+ return uniq(flatMapDeep(this.validationResults, values));
920
+ }
921
+
910
922
  /**
911
923
  * Get a record by ID, or null if no matching record found.
912
924
  *
@@ -979,7 +991,7 @@ export class Store extends HoistBase implements FilterBindTarget, FilterValueSou
979
991
  return this.validator.isNotValid;
980
992
  }
981
993
 
982
- /** Recompute validations for all records and return true if the store is valid. */
994
+ /** Recompute ValidationResults for all records and return true if the store is valid. */
983
995
  async validateAsync(): Promise<boolean> {
984
996
  return this.validator.validateAsync();
985
997
  }
@@ -5,6 +5,7 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {PlainObject} from '@xh/hoist/core';
8
+ import {ValidationResult} from '@xh/hoist/data/validation/Types';
8
9
  import {throwIf} from '@xh/hoist/utils/js';
9
10
  import {isNil, flatMap, isMatch, isEmpty, pickBy} from 'lodash';
10
11
  import {Store} from './Store';
@@ -153,11 +154,21 @@ export class StoreRecord {
153
154
  return this.validator?.errors ?? {};
154
155
  }
155
156
 
157
+ /** Map of field names to list of ValidationResults. */
158
+ get validationResults(): Record<string, ValidationResult[]> {
159
+ return this.validator?.validationResults ?? {};
160
+ }
161
+
156
162
  /** Array of all errors for this record. */
157
163
  get allErrors() {
158
164
  return flatMap(this.errors);
159
165
  }
160
166
 
167
+ /** Array of all ValidationResults for this record. */
168
+ get allValidationResults(): ValidationResult[] {
169
+ return flatMap(this.validationResults);
170
+ }
171
+
161
172
  /** Count of all validation errors for the record. */
162
173
  get errorCount(): number {
163
174
  return this.validator?.errorCount ?? 0;
@@ -4,9 +4,18 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {Field, Rule, StoreRecord, StoreRecordId, ValidationState} from '@xh/hoist/data';
7
+ import {
8
+ Field,
9
+ RecordValidationMessagesMap,
10
+ RecordValidationResultsMap,
11
+ Rule,
12
+ StoreRecord,
13
+ StoreRecordId,
14
+ ValidationResult,
15
+ ValidationState
16
+ } from '@xh/hoist/data';
8
17
  import {computed, observable, makeObservable, runInAction} from '@xh/hoist/mobx';
9
- import {compact, flatten, isEmpty, mapValues, values} from 'lodash';
18
+ import {compact, flatten, isEmpty, isString, mapValues, values} from 'lodash';
10
19
  import {TaskObserver} from '../../core';
11
20
 
12
21
  /**
@@ -16,9 +25,9 @@ import {TaskObserver} from '../../core';
16
25
  export class RecordValidator {
17
26
  record: StoreRecord;
18
27
 
19
- @observable.ref _fieldErrors: RecordErrorMap = null;
20
- _validationTask = TaskObserver.trackLast();
21
- _validationRunId = 0;
28
+ @observable.ref private fieldValidations: RecordValidationResultsMap = null;
29
+ private validationTask = TaskObserver.trackLast();
30
+ private validationRunId = 0;
22
31
 
23
32
  get id(): StoreRecordId {
24
33
  return this.record.id;
@@ -44,20 +53,28 @@ export class RecordValidator {
44
53
 
45
54
  /** Map of field names to field-level errors. */
46
55
  @computed.struct
47
- get errors(): RecordErrorMap {
48
- return this._fieldErrors ?? {};
56
+ get errors(): RecordValidationMessagesMap {
57
+ return mapValues(this.fieldValidations ?? {}, issues =>
58
+ compact(issues.map(it => (it?.severity === 'error' ? it.message : null)))
59
+ );
60
+ }
61
+
62
+ /** Map of field names to field-level ValidationResults. */
63
+ @computed.struct
64
+ get validationResults(): RecordValidationResultsMap {
65
+ return this.fieldValidations ?? {};
49
66
  }
50
67
 
51
68
  /** Count of all validation errors for the record. */
52
69
  @computed
53
70
  get errorCount(): number {
54
- return flatten(values(this._fieldErrors)).length;
71
+ return flatten(values(this.errors)).length;
55
72
  }
56
73
 
57
74
  /** True if any fields are currently recomputing their validation state. */
58
75
  @computed
59
76
  get isPending(): boolean {
60
- return this._validationTask.isPending;
77
+ return this.validationTask.isPending;
61
78
  }
62
79
 
63
80
  private _validators = [];
@@ -68,41 +85,43 @@ export class RecordValidator {
68
85
  }
69
86
 
70
87
  /**
71
- * Recompute validations for the record and return true if valid.
88
+ * Recompute ValidationResults for the record and return true if valid.
72
89
  */
73
90
  async validateAsync(): Promise<boolean> {
74
- let runId = ++this._validationRunId,
75
- fieldErrors = {},
91
+ let runId = ++this.validationRunId,
92
+ fieldValidations = {},
76
93
  {record} = this,
77
94
  fieldsToValidate = record.store.fields.filter(it => !isEmpty(it.rules));
78
95
 
79
96
  const promises = fieldsToValidate.flatMap(field => {
80
- fieldErrors[field.name] = [];
97
+ fieldValidations[field.name] = [];
81
98
  return field.rules.map(async rule => {
82
99
  const result = await this.evaluateRuleAsync(record, field, rule);
83
- fieldErrors[field.name].push(result);
100
+ fieldValidations[field.name].push(result);
84
101
  });
85
102
  });
86
- await Promise.all(promises).linkTo(this._validationTask);
103
+ await Promise.all(promises).linkTo(this.validationTask);
87
104
 
88
- if (runId !== this._validationRunId) return;
89
- fieldErrors = mapValues(fieldErrors, it => compact(flatten(it)));
105
+ if (runId !== this.validationRunId) return;
106
+ fieldValidations = mapValues(fieldValidations, it => compact(flatten(it)));
90
107
 
91
- runInAction(() => (this._fieldErrors = fieldErrors));
108
+ runInAction(() => (this.fieldValidations = fieldValidations));
92
109
 
93
110
  return this.isValid;
94
111
  }
95
112
 
96
113
  /** The current validation state for the record. */
97
114
  getValidationState(): ValidationState {
98
- const {_fieldErrors} = this;
99
-
100
- if (_fieldErrors === null) return 'Unknown'; // Before executing any rules
101
-
102
- return values(_fieldErrors).some(errors => !isEmpty(errors)) ? 'NotValid' : 'Valid';
115
+ if (this.fieldValidations === null) return 'Unknown'; // Before executing any rules
116
+ if (this.errorCount) return 'NotValid';
117
+ return 'Valid';
103
118
  }
104
119
 
105
- async evaluateRuleAsync(record: StoreRecord, field: Field, rule: Rule): Promise<string[]> {
120
+ async evaluateRuleAsync(
121
+ record: StoreRecord,
122
+ field: Field,
123
+ rule: Rule
124
+ ): Promise<ValidationResult[]> {
106
125
  const values = record.getValues(),
107
126
  {name, displayName} = field,
108
127
  value = record.get(name);
@@ -114,7 +133,9 @@ export class RecordValidator {
114
133
  });
115
134
 
116
135
  const ret = await Promise.all(promises);
117
- return compact(flatten(ret));
136
+ return compact(flatten(ret)).map(issue =>
137
+ isString(issue) ? {message: issue, severity: 'error'} : issue
138
+ );
118
139
  }
119
140
  }
120
141
 
@@ -123,6 +144,3 @@ export class RecordValidator {
123
144
  return !when || when(field, record.getValues());
124
145
  }
125
146
  }
126
-
127
- /** Map of Field names to Field-level error lists. */
128
- export type RecordErrorMap = Record<string, string[]>;