@xh/hoist 80.0.0-SNAPSHOT.1768323341476 → 80.0.0-SNAPSHOT.1768360784265
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 +27 -3
- package/admin/tabs/activity/tracking/ActivityTracking.scss +7 -7
- package/admin/tabs/userData/roles/details/RoleDetails.scss +6 -6
- package/build/types/cmp/form/FormModel.d.ts +1 -1
- package/build/types/cmp/form/field/BaseFieldModel.d.ts +7 -3
- package/build/types/cmp/form/field/SubformsFieldModel.d.ts +3 -3
- package/build/types/cmp/grid/Grid.d.ts +2 -2
- package/build/types/data/Field.d.ts +2 -1
- package/build/types/data/Store.d.ts +7 -4
- package/build/types/data/StoreRecord.d.ts +5 -0
- package/build/types/data/impl/RecordValidator.d.ts +9 -10
- package/build/types/data/impl/StoreValidator.d.ts +8 -7
- package/build/types/data/index.d.ts +1 -0
- package/build/types/data/validation/Rule.d.ts +5 -40
- package/build/types/data/validation/Types.d.ts +56 -0
- package/build/types/data/validation/constraints.d.ts +1 -1
- package/build/types/desktop/cmp/appOption/AutoRefreshAppOption.d.ts +3 -3
- package/build/types/desktop/cmp/appOption/ThemeAppOption.d.ts +3 -3
- package/cmp/form/FormModel.ts +2 -2
- package/cmp/form/field/BaseFieldModel.ts +38 -18
- package/cmp/form/field/SubformsFieldModel.ts +5 -5
- package/cmp/grid/Grid.scss +31 -8
- package/cmp/grid/columns/Column.ts +24 -10
- package/cmp/input/HoistInput.scss +19 -1
- package/cmp/input/HoistInputModel.ts +10 -2
- package/data/Field.ts +2 -1
- package/data/Store.ts +16 -4
- package/data/StoreRecord.ts +11 -0
- package/data/impl/RecordValidator.ts +46 -28
- package/data/impl/StoreValidator.ts +22 -9
- package/data/index.ts +1 -0
- package/data/validation/Rule.ts +12 -52
- package/data/validation/Types.ts +81 -0
- package/data/validation/constraints.ts +2 -1
- package/desktop/appcontainer/OptionsDialog.scss +1 -1
- package/desktop/cmp/form/FormField.scss +128 -43
- package/desktop/cmp/form/FormField.ts +74 -40
- package/desktop/cmp/input/CodeInput.scss +13 -1
- package/desktop/cmp/input/RadioInput.scss +16 -4
- package/desktop/cmp/input/SwitchInput.scss +23 -5
- package/desktop/cmp/input/TextArea.scss +9 -1
- package/desktop/cmp/rest/impl/RestForm.scss +1 -1
- package/desktop/cmp/viewmanager/ViewManager.scss +7 -15
- package/kit/blueprint/styles.scss +4 -4
- package/kit/onsen/styles.scss +10 -2
- package/mobile/cmp/form/FormField.scss +52 -19
- package/mobile/cmp/form/FormField.ts +30 -21
- package/package.json +1 -1
- package/styles/XH.scss +1 -0
- package/styles/vars.scss +70 -12
- 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 {
|
|
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.
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
|
118
|
-
const
|
|
119
|
-
return [...this.
|
|
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
|
package/cmp/grid/Grid.scss
CHANGED
|
@@ -104,16 +104,16 @@
|
|
|
104
104
|
padding-right: 0;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// Render badge on
|
|
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
|
|
873
|
-
|
|
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
|
-
|
|
891
|
+
validationMessages.length === 1
|
|
880
892
|
? 'xh-grid-tooltip--validation--single'
|
|
881
893
|
: null
|
|
882
894
|
),
|
|
883
|
-
items:
|
|
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
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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():
|
|
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
|
|
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
|
}
|
package/data/StoreRecord.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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():
|
|
48
|
-
return this.
|
|
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.
|
|
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.
|
|
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
|
|
88
|
+
* Recompute ValidationResults for the record and return true if valid.
|
|
72
89
|
*/
|
|
73
90
|
async validateAsync(): Promise<boolean> {
|
|
74
|
-
let runId = ++this.
|
|
75
|
-
|
|
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
|
-
|
|
97
|
+
fieldValidations[field.name] = [];
|
|
81
98
|
return field.rules.map(async rule => {
|
|
82
99
|
const result = await this.evaluateRuleAsync(record, field, rule);
|
|
83
|
-
|
|
100
|
+
fieldValidations[field.name].push(result);
|
|
84
101
|
});
|
|
85
102
|
});
|
|
86
|
-
await Promise.all(promises).linkTo(this.
|
|
103
|
+
await Promise.all(promises).linkTo(this.validationTask);
|
|
87
104
|
|
|
88
|
-
if (runId !== this.
|
|
89
|
-
|
|
105
|
+
if (runId !== this.validationRunId) return;
|
|
106
|
+
fieldValidations = mapValues(fieldValidations, it => compact(flatten(it)));
|
|
90
107
|
|
|
91
|
-
runInAction(() => (this.
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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(
|
|
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[]>;
|