@xh/hoist 53.1.0 → 53.2.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## v53.2.0 - 2022-11-15
4
+
5
+ ### 🎁 New Features
6
+ * New convenience methods `Store.errors`, `Store.errorCount`, and `StoreRecord.allErrors` for getting
7
+ easy access to validation errors in the data package.
8
+ * A new flag `Store.validationIsComplex` which governs whether non-changed
9
+ uncommitted records need to be revalidated when any record in the store is changed. This flag
10
+ defaults to `false`, which should be correct for most applications. Set to `true` for stores with
11
+ validations that depend on other editable record values in the store (e.g. unique constraints).
12
+
13
+ ### ⚙️ Technical
14
+ * Major performance improvements to validation of records in stores. This is a critical fix for
15
+ applications that do bulk insertion of hundreds of rows or greater in editable grids.
16
+
3
17
  ## v53.1.0 - 2022-11-03
4
18
 
5
19
  ### 🎁 New Features
package/data/Store.js CHANGED
@@ -13,12 +13,15 @@ import {
13
13
  castArray,
14
14
  defaultsDeep,
15
15
  differenceBy,
16
+ flatMapDeep,
16
17
  isArray,
17
18
  isEmpty,
18
19
  isFunction,
19
20
  isNil,
20
21
  isString,
21
- remove as lodashRemove
22
+ values,
23
+ remove as lodashRemove,
24
+ uniq
22
25
  } from 'lodash';
23
26
  import {Field} from './Field';
24
27
  import {parseFilter} from './filter/Utils';
@@ -60,6 +63,9 @@ export class Store extends HoistBase {
60
63
  /** @member {boolean} */
61
64
  freezeData;
62
65
 
66
+ /** @member {boolean} */
67
+ validationIsComplex;
68
+
63
69
  /** @member {Filter} */
64
70
  @observable.ref filter;
65
71
 
@@ -127,6 +133,8 @@ export class Store extends HoistBase {
127
133
  * object creation, and raw data processing when reloading reference-identical data.
128
134
  * Should not be used if a processRawData function that depends on external state is
129
135
  * provided, as this function will be circumvented on subsequent reloads. Default false.
136
+ * @param {boolean} [c.validationIsComplex] - set to true to always validate all uncommitted
137
+ * records on every change to uncommitted records (add, modify, or remove). Default false.
130
138
  * @param {Object} [c.experimental] - flags for experimental features. These features are
131
139
  * designed for early client-access and testing, but are not yet part of the Hoist API.
132
140
  * @param {Object[]} [c.data] - source data to load.
@@ -144,6 +152,7 @@ export class Store extends HoistBase {
144
152
  freezeData = true,
145
153
  idEncodesTreePath = false,
146
154
  reuseRecords = false,
155
+ validationIsComplex = false,
147
156
  experimental,
148
157
  data
149
158
  }) {
@@ -161,6 +170,7 @@ export class Store extends HoistBase {
161
170
  this.freezeData = freezeData;
162
171
  this.idEncodesTreePath = idEncodesTreePath;
163
172
  this.reuseRecords = reuseRecords;
173
+ this.validationIsComplex = validationIsComplex;
164
174
  this.lastUpdated = Date.now();
165
175
 
166
176
  this.resetRecords();
@@ -692,6 +702,21 @@ export class Store extends HoistBase {
692
702
  return this._current.maxDepth; // maxDepth should not be effected by filtering.
693
703
  }
694
704
 
705
+ /** @return {StoreErrorMap} - Map of StoreRecord IDs to StoreRecord-level error maps. */
706
+ get errors() {
707
+ return this.validator.errors;
708
+ }
709
+
710
+ /** @return {number} - count of all validation errors for the store. */
711
+ get errorCount() {
712
+ return this.validator.errorCount;
713
+ }
714
+
715
+ /** @return {string[]} - Array of all errors for this store. */
716
+ get allErrors() {
717
+ return uniq(flatMapDeep(this.errors, values));
718
+ }
719
+
695
720
  /**
696
721
  * Get a record by ID, or null if no matching record found.
697
722
  *
@@ -1007,3 +1032,11 @@ function isChildDataObject(obj) {
1007
1032
  * @property {Object} rawData - data for the child records to be added. Can include a `children`
1008
1033
  * property that will be processed into new (grand)child records.
1009
1034
  */
1035
+
1036
+ /**
1037
+ * @typedef {Object.<string, string[]>} RecordErrorMap - map of Field names -> Field-level error lists.
1038
+ */
1039
+
1040
+ /**
1041
+ * @typedef {Object.<StoreRecordId, RecordErrorMap>} StoreErrorMap - map of StoreRecord IDs -> StoreRecord-level error maps.
1042
+ */
@@ -5,7 +5,7 @@
5
5
  * Copyright © 2022 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {throwIf} from '@xh/hoist/utils/js';
8
- import {isNil} from 'lodash';
8
+ import {isNil, flatMap} from 'lodash';
9
9
  import {ValidationState} from './validation/ValidationState';
10
10
 
11
11
  /**
@@ -152,6 +152,11 @@ export class StoreRecord {
152
152
  return this.validator?.errors ?? {};
153
153
  }
154
154
 
155
+ /** @return {string[]} - Array of all errors for this record. */
156
+ get allErrors() {
157
+ return flatMap(this.errors);
158
+ }
159
+
155
160
  /** @return {number} - count of all validation errors for the record. */
156
161
  get errorCount() {
157
162
  return this.validator?.errorCount ?? 0;
@@ -5,21 +5,25 @@
5
5
  * Copyright © 2022 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
- import {HoistBase} from '@xh/hoist/core';
9
8
  import {ValidationState} from '@xh/hoist/data';
10
- import {computed, makeObservable} from '@xh/hoist/mobx';
11
- import {find, map, some, sumBy} from 'lodash';
12
- import {RecordFieldValidator} from './RecordFieldValidator';
9
+ import {computed} from '@xh/hoist/mobx';
10
+ import {compact, flatten, isEmpty, mapValues, values} from 'lodash';
11
+ import {makeObservable, observable, runInAction} from 'mobx';
12
+ import {TaskObserver} from '../../core';
13
13
 
14
14
  /**
15
15
  * Computes validation state for a StoreRecord
16
16
  * @private
17
17
  */
18
- export class RecordValidator extends HoistBase {
18
+ export class RecordValidator {
19
19
 
20
20
  /** @member {StoreRecord} */
21
21
  record;
22
22
 
23
+ @observable.ref _fieldErrors = null;
24
+ _validationTask = TaskObserver.trackLast();
25
+ _validationRunId = 0;
26
+
23
27
  /** @member {StoreRecordId} */
24
28
  get id() {
25
29
  return this.record.id;
@@ -46,34 +50,28 @@ export class RecordValidator extends HoistBase {
46
50
  /** @return {RecordErrorMap} - map of field names -> field-level errors. */
47
51
  @computed.struct
48
52
  get errors() {
49
- return this.getErrorMap();
53
+ return this._fieldErrors ?? {};
50
54
  }
51
55
 
52
56
  /** @return {number} - count of all validation errors for the record. */
53
57
  @computed
54
58
  get errorCount() {
55
- return sumBy(this._validators, 'errorCount');
59
+ return flatten(values(this._fieldErrors)).length;
56
60
  }
57
61
 
58
62
  /** @return {boolean} - true if any fields are currently recomputing their validation state. */
59
63
  @computed
60
64
  get isPending() {
61
- return some(this._validators, it => it.isPending);
65
+ return this._validationTask.isPending;
62
66
  }
63
67
 
64
- _validators = [];
65
-
66
68
  /**
67
69
  * @param {Object} c - RecordValidator configuration.
68
70
  * @param {StoreRecord} c.record - record to validate
69
71
  */
70
72
  constructor({record}) {
71
- super();
72
- makeObservable(this);
73
73
  this.record = record;
74
-
75
- const {fields} = this.record.store;
76
- this._validators = fields.map(field => new RecordFieldValidator({record, field}));
74
+ makeObservable(this);
77
75
  }
78
76
 
79
77
  /**
@@ -81,36 +79,60 @@ export class RecordValidator extends HoistBase {
81
79
  * @returns {Promise<boolean>}
82
80
  */
83
81
  async validateAsync() {
84
- const promises = map(this._validators, v => v.validateAsync());
85
- await Promise.all(promises);
82
+ let runId = ++this._validationRunId,
83
+ fieldErrors = {},
84
+ {record} = this,
85
+ fieldsToValidate = record.store.fields.filter(it => !isEmpty(it.rules));
86
+
87
+ const promises = fieldsToValidate.flatMap(field => {
88
+ fieldErrors[field.name] = [];
89
+ return field.rules.map(async (rule) => {
90
+ const result = await this.evaluateRuleAsync(record, field, rule);
91
+ fieldErrors[field.name].push(result);
92
+ });
93
+ });
94
+ await Promise.all(promises).linkTo(this._validationTask);
95
+
96
+ if (runId !== this._validationRunId) return;
97
+ fieldErrors = mapValues(fieldErrors, it => compact(flatten(it)));
98
+
99
+ runInAction(() => this._fieldErrors = fieldErrors);
100
+
86
101
  return this.isValid;
87
102
  }
88
103
 
89
104
  /** @return {ValidationState} - the current validation state for the record. */
90
105
  getValidationState() {
91
106
  const VS = ValidationState,
92
- states = map(this._validators, v => v.validationState);
93
- if (states.includes(VS.NotValid)) return VS.NotValid;
94
- if (states.includes(VS.Unknown)) return VS.Unknown;
95
- return VS.Valid;
96
- }
107
+ {_fieldErrors} = this;
97
108
 
98
- /** @return {RecordErrorMap} - map of field names -> field-level errors. */
99
- getErrorMap() {
100
- const ret = {};
101
- this._validators.forEach(v => ret[v.id] = v.errors);
102
- return ret;
109
+ if (_fieldErrors === null) return VS.Unknown; // Before executing any rules
110
+
111
+ return (values(_fieldErrors).some(errors => !isEmpty(errors))) ?
112
+ VS.NotValid :
113
+ VS.Valid;
103
114
  }
104
115
 
105
- /**
106
- * @param {string} id - ID of RecordFieldValidator (should match field.name)
107
- * @return {RecordFieldValidator}
108
- */
109
- findFieldValidator(id) {
110
- return find(this._validators, {id});
116
+ async evaluateRuleAsync(record, field, rule) {
117
+ const values = record.getValues(),
118
+ {name, displayName} = field,
119
+ value = record.get(name);
120
+
121
+ if (this.ruleIsActive(record, field, rule)) {
122
+ const promises = rule.check.map(async (constraint) => {
123
+ const fieldState = {value, name, displayName, record};
124
+ return await constraint(fieldState, values);
125
+ });
126
+
127
+ const ret = await Promise.all(promises);
128
+ return compact(flatten(ret));
129
+ }
130
+
131
+ return [];
111
132
  }
112
- }
113
133
 
114
- /**
115
- * @typedef {Object.<string, string[]>} RecordErrorMap - map of Field names -> Field-level error lists.
116
- */
134
+ ruleIsActive(record, field, rule) {
135
+ const {when} = rule;
136
+ return !when || when(field, record.getValues());
137
+ }
138
+ }
@@ -5,12 +5,14 @@
5
5
  * Copyright © 2022 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
- import {XH, HoistBase} from '@xh/hoist/core';
8
+ import {HoistBase} from '@xh/hoist/core';
9
9
  import {computed, makeObservable, observable} from '@xh/hoist/mobx';
10
- import {find, sumBy, map, some} from 'lodash';
10
+ import {sumBy, chunk} from 'lodash';
11
+ import {runInAction} from 'mobx';
12
+ import {logDebug, findIn} from '../../utils/js';
13
+ import {ValidationState} from '../validation/ValidationState';
11
14
 
12
15
  import {RecordValidator} from './RecordValidator';
13
- import {ValidationState} from '../validation/ValidationState';
14
16
 
15
17
  /**
16
18
  * Computes validation state for a Store's uncommitted Records
@@ -48,17 +50,21 @@ export class StoreValidator extends HoistBase {
48
50
  /** @return {number} - count of all validation errors for the store. */
49
51
  @computed
50
52
  get errorCount() {
51
- return sumBy(this._validators, 'errorCount');
53
+ return sumBy(this.validators, 'errorCount');
52
54
  }
53
55
 
54
56
  /** @return {boolean} - true if any records are currently recomputing their validation state. */
55
57
  @computed
56
58
  get isPending() {
57
- return some(this._validators, it => it.isPending);
59
+ return findIn(this._validators, it => it.isPending);
60
+ }
61
+
62
+ get validators() {
63
+ return this.mapValidators();
58
64
  }
59
65
 
60
- /** @member {RecordValidator[]} */
61
- @observable.ref _validators = [];
66
+ /** @member {Map<StoreRecordId, RecordValidator>} */
67
+ @observable.ref _validators = new Map();
62
68
 
63
69
  /**
64
70
  * @param {Object} c - StoreValidator configuration.
@@ -80,15 +86,14 @@ export class StoreValidator extends HoistBase {
80
86
  * @returns {Promise<boolean>}
81
87
  */
82
88
  async validateAsync() {
83
- const promises = map(this._validators, v => v.validateAsync());
84
- await Promise.all(promises);
89
+ await this.validateInChunksAsync(this.validators);
85
90
  return this.isValid;
86
91
  }
87
92
 
88
93
  /** @return {ValidationState} - the current validation state for the store. */
89
94
  getValidationState() {
90
95
  const VS = ValidationState,
91
- states = map(this._validators, v => v.validationState);
96
+ states = this.mapValidators(v => v.validationState);
92
97
  if (states.includes(VS.NotValid)) return VS.NotValid;
93
98
  if (states.includes(VS.Unknown)) return VS.Unknown;
94
99
  return VS.Valid;
@@ -106,7 +111,7 @@ export class StoreValidator extends HoistBase {
106
111
  * @return {RecordValidator}
107
112
  */
108
113
  findRecordValidator(id) {
109
- return find(this._validators, {id});
114
+ return this._validators.get(id);
110
115
  }
111
116
 
112
117
  //---------------------------------------
@@ -117,12 +122,46 @@ export class StoreValidator extends HoistBase {
117
122
  }
118
123
 
119
124
  async syncValidatorsAsync() {
120
- XH.safeDestroy(this._validators);
121
- this._validators = this.uncommittedRecords.map(record => new RecordValidator({record}));
122
- return this.validateAsync();
125
+ const isComplex = this.store.validationIsComplex,
126
+ currValidators = this._validators,
127
+ newValidators = new Map(),
128
+ toValidate = [];
129
+
130
+ this.uncommittedRecords.forEach(record => {
131
+ const {id} = record;
132
+
133
+ // Re-use existing validators to preserve validation state and avoid churn.
134
+ let validator = currValidators.get(id);
135
+
136
+ // 1) If exists validator for an unchanged record, no need to validate
137
+ if (!isComplex && validator?.record == record) {
138
+ newValidators.set(id, validator);
139
+ return;
140
+ }
141
+
142
+ // 2) Otherwise create/update the validator, and trigger validation
143
+ if (!validator) {
144
+ validator = new RecordValidator({record});
145
+ } else {
146
+ validator.record = record;
147
+ }
148
+ newValidators.set(id, validator);
149
+ toValidate.push(validator);
150
+ });
151
+
152
+ await this.validateInChunksAsync(toValidate);
153
+ runInAction(() => this._validators = newValidators);
123
154
  }
124
- }
125
155
 
126
- /**
127
- * @typedef {Object.<StoreRecordId, RecordErrorMap>} StoreErrorMap - map of StoreRecord IDs -> StoreRecord-level error maps.
128
- */
156
+ async validateInChunksAsync(validators) {
157
+ logDebug(`Validating ${validators.length} records`, this);
158
+ const validateChunks = chunk(validators, 100);
159
+ for (let chunk of validateChunks) {
160
+ await Promise.all(chunk.map(v => v.validateAsync()));
161
+ }
162
+ }
163
+
164
+ mapValidators(fn = undefined) {
165
+ return Array.from(this._validators.values(), fn);
166
+ }
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "53.1.0",
3
+ "version": "53.2.0",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": "github:xh/hoist-react",
6
6
  "homepage": "https://xh.io",
@@ -1,150 +0,0 @@
1
- /*
2
- * This file belongs to Hoist, an application development toolkit
3
- * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
- *
5
- * Copyright © 2022 Extremely Heavy Industries Inc.
6
- */
7
-
8
- import {HoistBase, managed, TaskObserver} from '@xh/hoist/core';
9
- import {ValidationState} from '@xh/hoist/data';
10
- import {computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx';
11
- import {compact, flatten, isEmpty, isNil} from 'lodash';
12
-
13
- /**
14
- * Computes validation state for a Field on a StoreRecord instance
15
- * @private
16
- */
17
- export class RecordFieldValidator extends HoistBase {
18
-
19
- /** @member {StoreRecord} */
20
- record;
21
-
22
- /** @member {Field} */
23
- field;
24
-
25
- /** @return {string} */
26
- get id() {
27
- return this.field.name;
28
- }
29
-
30
- /** @return {Rule[]} */
31
- get rules() {
32
- return this.field.rules;
33
- }
34
-
35
- /** @return {boolean} - true if the field is confirmed to be Valid. */
36
- @computed
37
- get isValid() {
38
- return this.validationState === ValidationState.Valid;
39
- }
40
-
41
- /** @return {boolean} - true if the field is confirmed to be NotValid. */
42
- @computed
43
- get isNotValid() {
44
- return this.validationState === ValidationState.NotValid;
45
- }
46
-
47
- /** @return {ValidationState} - the current validation state of the field. */
48
- @computed
49
- get validationState() {
50
- return this.getValidationState();
51
- }
52
-
53
- /** @return {string[]} - all validation errors for the field. */
54
- @computed.struct
55
- get errors() {
56
- return this.getErrorList();
57
- }
58
-
59
- /** @return {number} - count of all validation errors for the field. */
60
- @computed
61
- get errorCount() {
62
- return this.errors.length;
63
- }
64
-
65
- /** @return {boolean} - true if any rules are currently recomputing their validation state. */
66
- get isPending() {
67
- return this._validationTask.isPending;
68
- }
69
-
70
- // An array with the result of evaluating each rule. Each element will be array of strings
71
- // containing any validation errors for the rule. If validation for the rule has not
72
- // completed will contain null
73
- @observable _errors;
74
-
75
- @managed _validationTask = TaskObserver.trackLast();
76
- _validationRunId = 0;
77
-
78
- /**
79
- * @param {Object} c - RecordFieldValidator configuration.
80
- * @param {StoreRecord} c.record - record to validate
81
- * @param {Field} c.field - Field to validate
82
- */
83
- constructor({record, field}) {
84
- super();
85
- makeObservable(this);
86
- this.record = record;
87
- this.field = field;
88
- this._errors = this.rules.map(() => null);
89
- }
90
-
91
- /**
92
- * Recompute validations for the record field and return true if valid.
93
- * @returns {Promise<boolean>}
94
- */
95
- async validateAsync() {
96
- await this.evaluateAsync().linkTo(this._validationTask);
97
- return this.isValid;
98
- }
99
-
100
- /** @return {ValidationState} - the current validation state for the record field. */
101
- getValidationState() {
102
- const VS = ValidationState,
103
- {_errors} = this;
104
- if (_errors.some(e => !isEmpty(e))) return VS.NotValid;
105
- if (_errors.some(e => isNil(e))) return VS.Unknown;
106
- return VS.Valid;
107
- }
108
-
109
- /** @member {string[]} - all validation errors for this field. */
110
- getErrorList() {
111
- return compact(flatten(this._errors));
112
- }
113
-
114
- //---------------------------------------
115
- // Implementation
116
- //---------------------------------------
117
- async evaluateAsync() {
118
- const runId = ++this._validationRunId;
119
- const promises = this.rules.map(async (rule, idx) => {
120
- const result = await this.evaluateRuleAsync(rule);
121
- if (runId === this._validationRunId) {
122
- runInAction(() => this._errors[idx] = result);
123
- }
124
- });
125
- await Promise.all(promises).linkTo(this._validationTask);
126
- }
127
-
128
- async evaluateRuleAsync(rule) {
129
- const {record, field} = this;
130
- if (this.ruleIsActive(rule)) {
131
- const promises = rule.check.map(async (constraint) => {
132
- const {name, displayName} = field,
133
- value = record.get(name),
134
- fieldState = {value, name, displayName, record};
135
-
136
- return await constraint(fieldState, record.getValues());
137
- });
138
-
139
- const ret = await Promise.all(promises);
140
- return compact(flatten(ret));
141
- }
142
- return [];
143
- }
144
-
145
- ruleIsActive(rule) {
146
- const {record, field} = this,
147
- {when} = rule;
148
- return !when || when(field, record.getValues());
149
- }
150
- }