From ca43f379fb60be9e5ee4baf4b1b21f494b7583ac Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Mon, 5 Jan 2026 15:31:16 -0500 Subject: [PATCH 01/13] Add support for validation warnings --- CHANGELOG.md | 5 ++ cmp/form/FormModel.ts | 3 +- cmp/form/field/BaseFieldModel.ts | 60 +++++++++++++++----- cmp/form/field/SubformsFieldModel.ts | 6 ++ cmp/grid/Grid.scss | 29 +++++++--- cmp/grid/columns/Column.ts | 16 ++++-- cmp/input/HoistInput.scss | 6 ++ cmp/input/HoistInputModel.ts | 10 +++- data/Store.ts | 25 ++++++++- data/StoreRecord.ts | 24 +++++++- data/impl/RecordValidator.ts | 83 ++++++++++++++++++++-------- data/impl/StoreValidator.ts | 39 ++++++++++--- data/validation/Rule.ts | 14 ++++- data/validation/ValidationState.ts | 3 +- desktop/cmp/form/FormField.scss | 39 ++++++++++++- desktop/cmp/form/FormField.ts | 53 ++++++++++++------ desktop/cmp/input/CodeInput.scss | 6 ++ desktop/cmp/input/RadioInput.scss | 8 +++ desktop/cmp/input/SwitchInput.scss | 10 ++++ desktop/cmp/input/TextArea.scss | 4 ++ kit/onsen/styles.scss | 6 +- mobile/cmp/form/FormField.scss | 10 +++- mobile/cmp/form/FormField.ts | 14 +++-- styles/vars.scss | 6 ++ 24 files changed, 384 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8202a581e1..1f4ff2f39c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ this release, but is not strictly required. `react-grid-layout` v2+ (not common). * Modified `DashCanvasModel.containerPadding` to apply to the `react-grid-layout` div created by the library, instead of the Hoist-created containing div. This may affect printing layouts. +* Enhanced `Field.rules` to support warnings, handled via `Grid` and `Form` validation APIs. ### 🎁 New Features @@ -65,6 +66,10 @@ this release, but is not strictly required. * Introduced opt-in `Grid` performance optimizations on an experimental basis with `GridExperimentalFlags.deltaSort` and `GridExperimentalFlags.disableScrollOptimization` +### ⚙️ Typescript API Adjustments + +* Removed `RecordErrorMap` type (not expected to impact most applications). + ### 📚 Libraries * @blueprintjs/core: `5.10 -> 6.3` diff --git a/cmp/form/FormModel.ts b/cmp/form/FormModel.ts index af9e8a9b50..e739ed0d73 100644 --- a/cmp/form/FormModel.ts +++ b/cmp/form/FormModel.ts @@ -245,6 +245,7 @@ export class FormModel extends HoistModel { const states = map(this.fields, m => m.validationState); if (states.includes('NotValid')) return 'NotValid'; if (states.includes('Unknown')) return 'Unknown'; + if (states.includes('ValidWithWarnings')) return 'ValidWithWarnings'; return 'Valid'; } @@ -256,7 +257,7 @@ export class FormModel extends HoistModel { /** True if all fields are valid. */ get isValid(): boolean { - return this.validationState == 'Valid'; + return this.validationState === 'Valid' || this.validationState === 'ValidWithWarnings'; } /** List of all validation errors for this form. */ diff --git a/cmp/form/field/BaseFieldModel.ts b/cmp/form/field/BaseFieldModel.ts index b5a6d78f58..771bf63462 100644 --- a/cmp/form/field/BaseFieldModel.ts +++ b/cmp/form/field/BaseFieldModel.ts @@ -5,12 +5,19 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import {HoistModel, managed, TaskObserver} from '@xh/hoist/core'; -import {genDisplayName, required, Rule, RuleLike, ValidationState} from '@xh/hoist/data'; +import { + genDisplayName, + required, + Rule, + RuleLike, + ValidationIssue, + ValidationState +} from '@xh/hoist/data'; import {action, bindable, computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; import {wait} from '@xh/hoist/promise'; import {executeIfFunction, withDefault} from '@xh/hoist/utils/js'; import {createObservableRef} from '@xh/hoist/utils/react'; -import {compact, flatten, isEmpty, isEqual, isFunction, isNil} from 'lodash'; +import {compact, flatten, isEmpty, isEqual, isFunction, isNil, isString} from 'lodash'; import {FormModel} from '../FormModel'; export interface BaseFieldConfig { @@ -95,7 +102,7 @@ export abstract class BaseFieldModel extends HoistModel { // containing any validation errors for the rule. If validation for the rule has not // completed will contain null @observable - private _errors: string[][]; + private validationIssues: ValidationIssue[][]; @managed private validationTask = TaskObserver.trackLast(); @@ -118,7 +125,7 @@ export abstract class BaseFieldModel extends HoistModel { this._disabled = disabled; this._readonly = readonly; this.rules = this.processRuleSpecs(rules); - this._errors = this.rules.map(() => null); + this.validationIssues = this.rules.map(() => null); } //----------------------------- @@ -175,7 +182,19 @@ export abstract class BaseFieldModel extends HoistModel { /** All validation errors for this field. */ @computed get errors(): string[] { - return compact(flatten(this._errors)); + return compact( + flatten(this.validationIssues).map(it => (it?.severity === 'error' ? it.message : null)) + ); + } + + /** All validation warnings for this field. */ + @computed + get warnings(): string[] { + return compact( + flatten(this.validationIssues).map(it => + it?.severity === 'warning' ? it.message : null + ) + ); } /** All validation errors for this field and its sub-forms. */ @@ -183,6 +202,11 @@ export abstract class BaseFieldModel extends HoistModel { return this.errors; } + /** All validation warnings for this field and its sub-forms. */ + get allWarnings(): string[] { + return this.warnings; + } + /** * Set the initial value of the field, reset to that value, and reset validation state. * @@ -202,7 +226,7 @@ export abstract class BaseFieldModel extends HoistModel { // Force an immediate 'Unknown' state -- the async recompute leaves the old state in place until it completed. // (We want that for a value change, but not reset/init) Force the recompute only if needed. - this._errors.fill(null); + this.validationIssues.fill(null); wait().then(() => { if (!this.isValidationPending && this.validationState === 'Unknown') { this.computeValidationAsync(); @@ -272,9 +296,14 @@ export abstract class BaseFieldModel extends HoistModel { return this.deriveValidationState(); } - /** True if this field is confirmed to be Valid. */ + /** True if this field is confirmed to be Valid (with or without warnings). */ get isValid(): boolean { - return this.validationState === 'Valid'; + return this.validationState === 'Valid' || this.validationState === 'ValidWithWarnings'; + } + + /** True if this field is confirmed to be Valid but has warnings. */ + get isValidWithWarnings(): boolean { + return this.validationState === 'ValidWithWarnings'; } /** True if this field is confirmed to be NotValid. */ @@ -339,13 +368,13 @@ export abstract class BaseFieldModel extends HoistModel { const promises = this.rules.map(async (rule, idx) => { const result = await this.evaluateRuleAsync(rule); if (runId === this.validationRunId) { - runInAction(() => (this._errors[idx] = result)); + runInAction(() => (this.validationIssues[idx] = result)); } }); await Promise.all(promises); } - private async evaluateRuleAsync(rule): Promise { + private async evaluateRuleAsync(rule: Rule): Promise { if (this.ruleIsActive(rule)) { const promises = rule.check.map(async constraint => { const {value, name, displayName} = this, @@ -355,7 +384,9 @@ export abstract class BaseFieldModel extends HoistModel { }); const ret = await Promise.all(promises); - return compact(flatten(ret)); + return compact(flatten(ret)).map(issue => + isString(issue) ? {message: issue, severity: 'error'} : issue + ); } return []; } @@ -367,10 +398,11 @@ export abstract class BaseFieldModel extends HoistModel { } protected deriveValidationState(): ValidationState { - const {_errors} = this; + const {errors, warnings, validationIssues} = this; - if (_errors.some(e => !isEmpty(e))) return 'NotValid'; - if (_errors.some(e => isNil(e))) return 'Unknown'; + if (!isEmpty(errors)) return 'NotValid'; + if (validationIssues.some(e => isNil(e))) return 'Unknown'; + if (!isEmpty(warnings)) return 'ValidWithWarnings'; return 'Valid'; } } diff --git a/cmp/form/field/SubformsFieldModel.ts b/cmp/form/field/SubformsFieldModel.ts index 537a7b7352..470d0efd19 100644 --- a/cmp/form/field/SubformsFieldModel.ts +++ b/cmp/form/field/SubformsFieldModel.ts @@ -119,6 +119,12 @@ export class SubformsFieldModel extends BaseFieldModel { return [...this.errors, ...subErrs]; } + @computed + override get allWarnings(): string[] { + const subWarns = flatMap(this.value, s => s.allWarnings); + return [...this.warnings, ...subWarns]; + } + @override override reset() { super.reset(); diff --git a/cmp/grid/Grid.scss b/cmp/grid/Grid.scss index 0cec05400b..e9a93d923b 100644 --- a/cmp/grid/Grid.scss +++ b/cmp/grid/Grid.scss @@ -104,16 +104,15 @@ padding-right: 0; } - // Render badge on invalid cells - .ag-cell.xh-cell--invalid { + // Render badge on cells with validation issues + .ag-cell.xh-cell--invalid, + .xh-cell--warning { &::before { content: ''; position: absolute; top: 0; right: 0; border-color: transparent; - border-right-color: var(--xh-intent-danger); - border-top-color: var(--xh-intent-danger); border-style: solid; } @@ -125,6 +124,16 @@ } } + .ag-cell.xh-cell--invalid::before { + border-right-color: var(--xh-intent-danger); + border-top-color: var(--xh-intent-danger); + } + + .ag-cell.xh-cell--warning::before { + border-right-color: var(--xh-intent-warning); + border-top-color: var(--xh-intent-warning); + } + // Render left / right group borders .ag-cell.xh-cell--group-border-left { @include AgGrid.group-border(left); @@ -136,25 +145,29 @@ .xh-ag-grid { &--tiny { - .ag-cell.xh-cell--invalid::before { + .ag-cell.xh-cell--invalid::before, + .ag-cell.xh-cell--warning::before { border-width: 3px; } } &--compact { - .ag-cell.xh-cell--invalid::before { + .ag-cell.xh-cell--invalid::before, + .ag-cell.xh-cell--warning::before { border-width: 4px; } } &--standard { - .ag-cell.xh-cell--invalid::before { + .ag-cell.xh-cell--invalid::before, + .ag-cell.xh-cell--warning::before { border-width: 5px; } } &--large { - .ag-cell.xh-cell--invalid::before { + .ag-cell.xh-cell--invalid::before, + .ag-cell.xh-cell--warning::before { border-width: 6px; } } diff --git a/cmp/grid/columns/Column.ts b/cmp/grid/columns/Column.ts index d02f50476a..40fbee5143 100644 --- a/cmp/grid/columns/Column.ts +++ b/cmp/grid/columns/Column.ts @@ -869,18 +869,20 @@ export class Column { // Override with validation errors, if present if (editor) { - const errors = record.errors[field]; - if (!isEmpty(errors)) { + const errors = record.errors[field], + warnings = record.warnings[field], + validationMessages = !isEmpty(errors) ? errors : warnings; + if (!isEmpty(validationMessages)) { return div({ ref: wrapperRef, item: ul({ className: classNames( 'xh-grid-tooltip--validation', - errors.length === 1 + validationMessages.length === 1 ? 'xh-grid-tooltip--validation--single' : null ), - items: errors.map((it, idx) => li({key: idx, item: it})) + items: validationMessages.map((it, idx) => li({key: idx, item: it})) }) }); } @@ -1011,6 +1013,12 @@ export class Column { const record = agParams.data; return record && !isEmpty(record.errors[field]); }, + 'xh-cell--warning': agParams => { + const record = agParams.data; + return ( + record && isEmpty(record.errors[field]) && !isEmpty(record.warnings[field]) + ); + }, 'xh-cell--editable': agParams => { return this.isEditableForRecord(agParams.data); }, diff --git a/cmp/input/HoistInput.scss b/cmp/input/HoistInput.scss index cf8b660afb..4b9547c17e 100644 --- a/cmp/input/HoistInput.scss +++ b/cmp/input/HoistInput.scss @@ -11,4 +11,10 @@ border: var(--xh-form-field-invalid-border); } } + + &.xh-input-warning { + input { + border: var(--xh-form-field-warning-border); + } + } } diff --git a/cmp/input/HoistInputModel.ts b/cmp/input/HoistInputModel.ts index fdc15ad9b6..68b8b93a3a 100644 --- a/cmp/input/HoistInputModel.ts +++ b/cmp/input/HoistInputModel.ts @@ -337,12 +337,20 @@ export function useHoistInputModel( const field = inputModel.getField(), validityClass = field?.isNotValid && field?.validationDisplayed ? 'xh-input-invalid' : null, + warningClass = + field?.isValidWithWarnings && field?.validationDisplayed ? 'xh-input-warning' : null, disabledClass = props.disabled ? 'xh-input-disabled' : null; return component({ ...props, model: inputModel, ref: inputModel.domRef, - className: classNames('xh-input', validityClass, disabledClass, props.className) + className: classNames( + 'xh-input', + validityClass, + warningClass, + disabledClass, + props.className + ) }); } diff --git a/data/Store.ts b/data/Store.ts index e181675e66..457f3be14d 100644 --- a/data/Store.ts +++ b/data/Store.ts @@ -30,7 +30,7 @@ import { import {Field, FieldSpec} from './Field'; import {parseFilter} from './filter/Utils'; import {RecordSet} from './impl/RecordSet'; -import {StoreErrorMap, StoreValidator} from './impl/StoreValidator'; +import {StoreValidationMessagesMap, StoreValidator} from './impl/StoreValidator'; import {StoreRecord, StoreRecordId, StoreRecordOrId} from './StoreRecord'; import {instanceManager} from '../core/impl/InstanceManager'; import {Filter} from './filter/Filter'; @@ -859,7 +859,7 @@ export class Store extends HoistBase { return this._current.maxDepth; // maxDepth should not be effected by filtering. } - get errors(): StoreErrorMap { + get errors(): StoreValidationMessagesMap { return this.validator.errors; } @@ -868,11 +868,25 @@ export class Store extends HoistBase { return this.validator.errorCount; } + get warnings(): StoreValidationMessagesMap { + return this.validator.warnings; + } + + /** Count of all validation warnings for the store. */ + get warningCount(): number { + return this.validator.warningCount; + } + /** Array of all errors for this store. */ get allErrors(): string[] { return uniq(flatMapDeep(this.errors, values)); } + /** Array of all warnings for this store. */ + get allWarnings(): string[] { + return uniq(flatMapDeep(this.warnings, values)); + } + /** * Get a record by ID, or null if no matching record found. * @@ -935,11 +949,16 @@ export class Store extends HoistBase { return ret ? ret : []; } - /** True if the store is confirmed to be Valid. */ + /** True if the store is confirmed to be Valid (with or without warnings). */ get isValid(): boolean { return this.validator.isValid; } + /** True if the store is confirmed to be Valid but has warnings. */ + get isValidWithWarnings(): boolean { + return this.validator.isValidWithWarnings; + } + /** True if the store is confirmed to be NotValid. */ get isNotValid(): boolean { return this.validator.isNotValid; diff --git a/data/StoreRecord.ts b/data/StoreRecord.ts index 73fca376f8..b7d313d996 100644 --- a/data/StoreRecord.ts +++ b/data/StoreRecord.ts @@ -133,9 +133,14 @@ export class StoreRecord { return this.store.getAncestorsById(this.id, false); } - /** True if the record is confirmed to be Valid. */ + /** True if the record is confirmed to be Valid (with or without warnings). */ get isValid(): boolean { - return this.validationState === 'Valid'; + return this.validationState === 'Valid' || this.validationState === 'ValidWithWarnings'; + } + + /** True if the record is confirmed to be Valid but has warnings. */ + get isValidWithWarnings(): boolean { + return this.validationState === 'ValidWithWarnings'; } /** True if the record is confirmed to be NotValid. */ @@ -153,16 +158,31 @@ export class StoreRecord { return this.validator?.errors ?? {}; } + /** Map of field names to list of warnings. */ + get warnings(): Record { + return this.validator?.warnings ?? {}; + } + /** Array of all errors for this record. */ get allErrors() { return flatMap(this.errors); } + /** Array of all warnings for this record. */ + get allWarnings() { + return flatMap(this.warnings); + } + /** Count of all validation errors for the record. */ get errorCount(): number { return this.validator?.errorCount ?? 0; } + /** Count of all validation warnings for the record. */ + get warningCount(): number { + return this.validator?.warningCount ?? 0; + } + /** True if any fields are currently recomputing their validation state. */ get isValidationPending(): boolean { return this.validator?.isPending ?? false; diff --git a/data/impl/RecordValidator.ts b/data/impl/RecordValidator.ts index c527be88c6..d1cc850256 100644 --- a/data/impl/RecordValidator.ts +++ b/data/impl/RecordValidator.ts @@ -4,9 +4,16 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {Field, Rule, StoreRecord, StoreRecordId, ValidationState} from '@xh/hoist/data'; +import { + Field, + Rule, + StoreRecord, + StoreRecordId, + ValidationIssue, + ValidationState +} from '@xh/hoist/data'; import {computed, observable, makeObservable, runInAction} from '@xh/hoist/mobx'; -import {compact, flatten, isEmpty, mapValues, values} from 'lodash'; +import {compact, flatten, isEmpty, isString, mapValues, values} from 'lodash'; import {TaskObserver} from '../../core'; /** @@ -16,18 +23,24 @@ import {TaskObserver} from '../../core'; export class RecordValidator { record: StoreRecord; - @observable.ref _fieldErrors: RecordErrorMap = null; - _validationTask = TaskObserver.trackLast(); - _validationRunId = 0; + @observable.ref private fieldValidationIssues: RecordValidationIssueMap = null; + private validationTask = TaskObserver.trackLast(); + private validationRunId = 0; get id(): StoreRecordId { return this.record.id; } - /** True if the record is confirmed to be Valid. */ + /** True if the record is confirmed to be Valid (with or without warnings). */ @computed get isValid(): boolean { - return this.validationState === 'Valid'; + return this.validationState === 'Valid' || this.validationState === 'ValidWithWarnings'; + } + + /** True if the record is confirmed to be Valid but has warnings. */ + @computed + get isValidWithWarnings(): boolean { + return this.validationState === 'ValidWithWarnings'; } /** True if the record is confirmed to be NotValid. */ @@ -44,20 +57,36 @@ export class RecordValidator { /** Map of field names to field-level errors. */ @computed.struct - get errors(): RecordErrorMap { - return this._fieldErrors ?? {}; + get errors(): RecordValidationMessagesMap { + return mapValues(this.fieldValidationIssues ?? {}, issues => + compact(issues.map(it => (it?.severity === 'error' ? it?.message : null))) + ); + } + + /** Map of field names to field-level warnings. */ + @computed.struct + get warnings(): RecordValidationMessagesMap { + return mapValues(this.fieldValidationIssues ?? {}, issues => + compact(issues.map(it => (it?.severity === 'warning' ? it?.message : null))) + ); } /** Count of all validation errors for the record. */ @computed get errorCount(): number { - return flatten(values(this._fieldErrors)).length; + return flatten(values(this.errors)).length; + } + + /** Count of all validation warnings for the record. */ + @computed + get warningCount(): number { + return flatten(values(this.warnings)).length; } /** True if any fields are currently recomputing their validation state. */ @computed get isPending(): boolean { - return this._validationTask.isPending; + return this.validationTask.isPending; } private _validators = []; @@ -71,7 +100,7 @@ export class RecordValidator { * Recompute validations for the record and return true if valid. */ async validateAsync(): Promise { - let runId = ++this._validationRunId, + let runId = ++this.validationRunId, fieldErrors = {}, {record} = this, fieldsToValidate = record.store.fields.filter(it => !isEmpty(it.rules)); @@ -83,26 +112,29 @@ export class RecordValidator { fieldErrors[field.name].push(result); }); }); - await Promise.all(promises).linkTo(this._validationTask); + await Promise.all(promises).linkTo(this.validationTask); - if (runId !== this._validationRunId) return; + if (runId !== this.validationRunId) return; fieldErrors = mapValues(fieldErrors, it => compact(flatten(it))); - runInAction(() => (this._fieldErrors = fieldErrors)); + runInAction(() => (this.fieldValidationIssues = fieldErrors)); return this.isValid; } /** The current validation state for the record. */ getValidationState(): ValidationState { - const {_fieldErrors} = this; - - if (_fieldErrors === null) return 'Unknown'; // Before executing any rules - - return values(_fieldErrors).some(errors => !isEmpty(errors)) ? 'NotValid' : 'Valid'; + if (this.fieldValidationIssues === null) return 'Unknown'; // Before executing any rules + if (this.errorCount) return 'NotValid'; + if (this.warningCount) return 'ValidWithWarnings'; + return 'Valid'; } - async evaluateRuleAsync(record: StoreRecord, field: Field, rule: Rule): Promise { + async evaluateRuleAsync( + record: StoreRecord, + field: Field, + rule: Rule + ): Promise { const values = record.getValues(), {name, displayName} = field, value = record.get(name); @@ -114,7 +146,9 @@ export class RecordValidator { }); const ret = await Promise.all(promises); - return compact(flatten(ret)); + return compact(flatten(ret)).map(issue => + isString(issue) ? {message: issue, severity: 'error'} : issue + ); } } @@ -124,5 +158,6 @@ export class RecordValidator { } } -/** Map of Field names to Field-level error lists. */ -export type RecordErrorMap = Record; +/** Map of Field names to Field-level ValidationIssue lists. */ +export type RecordValidationIssueMap = Record; +export type RecordValidationMessagesMap = Record; diff --git a/data/impl/StoreValidator.ts b/data/impl/StoreValidator.ts index a66d7978af..c0a2538c4c 100644 --- a/data/impl/StoreValidator.ts +++ b/data/impl/StoreValidator.ts @@ -9,7 +9,7 @@ import {HoistBase} from '@xh/hoist/core'; import {computed, makeObservable, runInAction, observable} from '@xh/hoist/mobx'; import {sumBy, chunk} from 'lodash'; import {findIn} from '@xh/hoist/utils/js'; -import {RecordErrorMap, RecordValidator} from './RecordValidator'; +import {RecordValidationMessagesMap, RecordValidator} from './RecordValidator'; import {ValidationState} from '../validation/ValidationState'; import {Store} from '../Store'; import {StoreRecordId} from '../StoreRecord'; @@ -21,10 +21,16 @@ import {StoreRecordId} from '../StoreRecord'; export class StoreValidator extends HoistBase { store: Store; - /** True if the store is confirmed to be Valid. */ + /** True if the store is confirmed to be Valid (with or without warnings). */ @computed get isValid(): boolean { - return this.validationState === 'Valid'; + return this.validationState === 'Valid' || this.validationState === 'ValidWithWarnings'; + } + + /** True if the store is confirmed to be Valid but has warnings. */ + @computed + get isValidWithWarnings(): boolean { + return this.validationState === 'ValidWithWarnings'; } /** True if the store is confirmed to be NotValid. */ @@ -41,7 +47,7 @@ export class StoreValidator extends HoistBase { /** Map of StoreRecord IDs to StoreRecord-level error maps. */ @computed.struct - get errors(): StoreErrorMap { + get errors(): StoreValidationMessagesMap { return this.getErrorMap(); } @@ -51,6 +57,18 @@ export class StoreValidator extends HoistBase { return sumBy(this.validators, 'errorCount'); } + /** Map of StoreRecord IDs to StoreRecord-level warning maps. */ + @computed.struct + get warnings(): StoreValidationMessagesMap { + return this.getWarningMap(); + } + + /** Count of all validation warnings for the store. */ + @computed + get warningCount(): number { + return sumBy(this.validators, 'warningCount'); + } + /** True if any records are currently recomputing their validation state. */ @computed get isPending() { @@ -88,16 +106,23 @@ export class StoreValidator extends HoistBase { const states = this.mapValidators(v => v.validationState); if (states.includes('NotValid')) return 'NotValid'; if (states.includes('Unknown')) return 'Unknown'; + if (states.includes('ValidWithWarnings')) return 'ValidWithWarnings'; return 'Valid'; } /** @returns map of StoreRecord IDs to StoreRecord-level error maps. */ - getErrorMap(): StoreErrorMap { - const ret = {}; + getErrorMap(): StoreValidationMessagesMap { + const ret: StoreValidationMessagesMap = {}; this._validators.forEach(v => (ret[v.id] = v.errors)); return ret; } + getWarningMap(): StoreValidationMessagesMap { + const ret: StoreValidationMessagesMap = {}; + this._validators.forEach(v => (ret[v.id] = v.warnings)); + return ret; + } + /** * @param id - ID of RecordValidator (should match record.id) */ @@ -157,4 +182,4 @@ export class StoreValidator extends HoistBase { } /** Map of StoreRecord IDs to StoreRecord-level error maps. */ -export type StoreErrorMap = Record; +export type StoreValidationMessagesMap = Record; diff --git a/data/validation/Rule.ts b/data/validation/Rule.ts index ee1d11aee1..4bab6fee39 100644 --- a/data/validation/Rule.ts +++ b/data/validation/Rule.ts @@ -27,13 +27,14 @@ export class Rule { * * @param fieldState - context w/value for the constraint's target Field. * @param allValues - current values for all fields in form, keyed by field name. - * @returns String(s) or array of strings describing errors, or null or undefined if rule passes - * successfully. May return a Promise of strings for async validation. + * @returns String or array of strings describing errors, or ValidationIssue or an array of + * ValidationIssues, or null or undefined if rule passes successfully. May return a Promise + * resolving to same for async validation. */ export type Constraint = ( fieldState: FieldState, allValues: PlainObject -) => Awaitable>; +) => Awaitable>; /** * Function to determine when to perform validation on a value. @@ -74,3 +75,10 @@ export interface RuleSpec { } export type RuleLike = RuleSpec | Constraint | Rule; + +export interface ValidationIssue { + severity: ValidationSeverity; + message: string; +} + +export type ValidationSeverity = 'error' | 'warning'; diff --git a/data/validation/ValidationState.ts b/data/validation/ValidationState.ts index 5a394d9591..403db26679 100644 --- a/data/validation/ValidationState.ts +++ b/data/validation/ValidationState.ts @@ -8,7 +8,8 @@ export const ValidationState = Object.freeze({ Unknown: 'Unknown', NotValid: 'NotValid', - Valid: 'Valid' + Valid: 'Valid', + ValidWithWarnings: 'ValidWithWarnings' }); // eslint-disable-next-line export type ValidationState = (typeof ValidationState)[keyof typeof ValidationState]; diff --git a/desktop/cmp/form/FormField.scss b/desktop/cmp/form/FormField.scss index a481ff5713..eb747a34f6 100644 --- a/desktop/cmp/form/FormField.scss +++ b/desktop/cmp/form/FormField.scss @@ -48,7 +48,8 @@ } .xh-form-field-info, - .xh-form-field-error-msg { + .xh-form-field-error-msg, + .xh-form-field-warning-msg { font-size: var(--xh-font-size-small-px); line-height: calc(var(--xh-font-size-small-px) + var(--xh-pad-px)); white-space: nowrap; @@ -60,6 +61,10 @@ color: var(--xh-red); } + .xh-form-field-warning-msg { + color: var(--xh-orange); + } + &.xh-form-field-inline { flex-direction: row; align-items: baseline; @@ -85,7 +90,8 @@ } } - .xh-form-field-error-msg { + .xh-form-field-error-msg, + .xh-form-field-warning-msg { display: none; } @@ -126,6 +132,32 @@ border: var(--xh-form-field-invalid-border) !important; } } + + &.xh-form-field-warning:not(.xh-form-field-readonly) { + .xh-check-box span { + box-shadow: var(--xh-form-field-warning-box-shadow) !important; + } + + .xh-button-group-input button.xh-button { + box-shadow: var(--xh-form-field-warning-box-shadow); + } + + .xh-slider span { + box-shadow: var(--xh-form-field-warning-box-shadow); + } + + div.xh-select__control { + border: var(--xh-form-field-warning-border); + } + + .xh-text-input > svg { + color: var(--xh-intent-warning); + } + + .xh-text-area.textarea { + border: var(--xh-form-field-warning-border) !important; + } + } } ul.xh-form-field-error-tooltip { @@ -146,7 +178,8 @@ ul.xh-form-field-error-tooltip { align-items: center; } - .xh-form-field-error-msg { + .xh-form-field-error-msg, + .xh-form-field-warning-msg { margin: 0 var(--xh-pad-px); } } diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index ec922e6219..7580209a68 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -26,7 +26,7 @@ import {isLocalDate} from '@xh/hoist/utils/datetime'; import {errorIf, getTestId, logWarn, TEST_ID, throwIf, withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps, getReactElementName, useOnMount, useOnUnmount} from '@xh/hoist/utils/react'; import classNames from 'classnames'; -import {isBoolean, isDate, isEmpty, isFinite, isNil, isUndefined, kebabCase} from 'lodash'; +import {first, isBoolean, isDate, isEmpty, isFinite, isNil, isUndefined, kebabCase} from 'lodash'; import {Children, cloneElement, ReactElement, ReactNode, useContext, useState} from 'react'; import './FormField.scss'; @@ -124,8 +124,11 @@ export const [FormField, formField] = hoistCmp.withFactory({ disabled = props.disabled || model?.disabled, validationDisplayed = model?.validationDisplayed || false, notValid = model?.isNotValid || false, + validWithWarnings = model?.isValidWithWarnings || false, displayNotValid = validationDisplayed && notValid, + displayWithWarnings = validationDisplayed && validWithWarnings, errors = model?.errors || [], + warnings = model?.warnings || [], requiredStr = defaultProp('requiredIndicator', props, formContext, '*'), requiredIndicator = isRequired && !readonly && requiredStr @@ -170,6 +173,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ if (readonly) classes.push('xh-form-field-readonly'); if (disabled) classes.push('xh-form-field-disabled'); if (displayNotValid) classes.push('xh-form-field-invalid'); + if (displayWithWarnings) classes.push('xh-form-field-warning'); const testId = getFormFieldTestId(props, formContext, model?.name); useOnMount(() => instanceManager.registerModelWithTestId(testId, model)); @@ -190,6 +194,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ childId, disabled, displayNotValid, + displayWithWarnings, leftErrorIcon, commitOnChange, testId: getTestId(testId, 'input') @@ -198,13 +203,17 @@ export const [FormField, formField] = hoistCmp.withFactory({ if (minimal) { childEl = tooltip({ item: childEl, - className: `xh-input ${displayNotValid ? 'xh-input-invalid' : ''}`, + className: classNames( + 'xh-input', + displayNotValid && 'xh-input-invalid', + displayWithWarnings && 'xh-input-warning' + ), targetTagName: !blockChildren.includes(childElementName) || childWidth ? 'span' : 'div', position: tooltipPosition, boundary: tooltipBoundary, - disabled: !displayNotValid, - content: getErrorTooltipContent(errors) + disabled: !displayNotValid && !displayWithWarnings, + content: getValidationTooltipContent(errors, warnings) }); } @@ -239,11 +248,13 @@ export const [FormField, formField] = hoistCmp.withFactory({ item: info }), tooltip({ - omit: minimal || !displayNotValid, + omit: minimal || !(displayNotValid || displayWithWarnings), openOnTargetFocus: false, - className: 'xh-form-field-error-msg', - item: errors ? errors[0] : null, - content: getErrorTooltipContent(errors) as ReactElement + className: displayNotValid + ? 'xh-form-field-error-msg' + : 'xh-form-field-warning-msg', + item: first(errors) ?? first(warnings), + content: getValidationTooltipContent(errors, warnings) as ReactElement }) ] }) @@ -359,21 +370,29 @@ function getValidChild(children) { return child; } -function getErrorTooltipContent(errors: string[]): ReactElement | string { - // If no errors, something other than null must be returned. +function getValidationTooltipContent(errors: string[], warnings: string[]): ReactElement | string { + // If no issues, something other than null must be returned. // If null is returned, as of Blueprint v5, the Blueprint Tooltip component causes deep re-renders of its target // when content changes from null <-> not null. // In `formField` `minimal:true` mode with `commitonchange:true`, this causes the // TextInput component to lose focus when its validation state changes, which is undesirable. // It is not clear if this is a bug or intended behavior in BP v5, but this workaround prevents the issue. // `Tooltip:content` has been a required prop since at least BP v4, but something about the way it is used in BP v5 changed. - if (isEmpty(errors)) return 'Is Valid'; - - if (errors.length === 1) return errors[0]; - return ul({ - className: 'xh-form-field-error-tooltip', - items: errors.map((it, idx) => li({key: idx, item: it})) - }); + if (!isEmpty(errors)) { + if (errors.length === 1) return errors[0]; + return ul({ + className: 'xh-form-field-error-tooltip', + items: errors.map((it, idx) => li({key: idx, item: it})) + }); + } else if (!isEmpty(warnings)) { + if (warnings.length === 1) return warnings[0]; + return ul({ + className: 'xh-form-field-warning-tooltip', + items: warnings.map((it, idx) => li({key: idx, item: it})) + }); + } else { + return 'Is Valid'; + } } function defaultProp>( diff --git a/desktop/cmp/input/CodeInput.scss b/desktop/cmp/input/CodeInput.scss index f238ad7d19..4724081810 100644 --- a/desktop/cmp/input/CodeInput.scss +++ b/desktop/cmp/input/CodeInput.scss @@ -35,6 +35,12 @@ } } + &.xh-input-warning { + div.CodeMirror { + border: var(--xh-form-field-warning-border); + } + } + &.xh-input-disabled { .CodeMirror { background-color: var(--xh-input-disabled-bg); diff --git a/desktop/cmp/input/RadioInput.scss b/desktop/cmp/input/RadioInput.scss index 156df33d18..2aee230054 100644 --- a/desktop/cmp/input/RadioInput.scss +++ b/desktop/cmp/input/RadioInput.scss @@ -17,6 +17,14 @@ margin: -1px; } } + + &.xh-input-warning .xh-radio-input-option .bp6-control-indicator { + border: var(--xh-form-field-warning-border); + + &::before { + margin: -1px; + } + } } // Toolbar specific style diff --git a/desktop/cmp/input/SwitchInput.scss b/desktop/cmp/input/SwitchInput.scss index 70f6bc7145..d2cf6cd2d7 100644 --- a/desktop/cmp/input/SwitchInput.scss +++ b/desktop/cmp/input/SwitchInput.scss @@ -14,3 +14,13 @@ } } } + +.xh-switch-input.xh-input-warning { + .bp6-control-indicator { + border: var(--xh-form-field-warning-border); + + &::before { + margin: 1px; + } + } +} diff --git a/desktop/cmp/input/TextArea.scss b/desktop/cmp/input/TextArea.scss index a108919df6..c8c45b957f 100644 --- a/desktop/cmp/input/TextArea.scss +++ b/desktop/cmp/input/TextArea.scss @@ -15,4 +15,8 @@ &.xh-input-invalid { border: var(--xh-form-field-invalid-border); } + + &.xh-input-warning { + border: var(--xh-form-field-warning-border); + } } diff --git a/kit/onsen/styles.scss b/kit/onsen/styles.scss index 0bc2161df0..817b5f782c 100644 --- a/kit/onsen/styles.scss +++ b/kit/onsen/styles.scss @@ -24,13 +24,17 @@ border: var(--xh-border-solid); border-radius: var(--xh-border-radius-px); - &:not(.xh-input-invalid):focus-within { + &:not(.xh-input-invalid):not(.xh-input-warning):focus-within { border-color: var(--xh-focus-outline-color); } &.xh-input-invalid { border: var(--xh-form-field-invalid-border); } + + &.xh-input-warning { + border: var(--xh-form-field-warning-border); + } } } diff --git a/mobile/cmp/form/FormField.scss b/mobile/cmp/form/FormField.scss index 91a2403070..0ff23407b8 100644 --- a/mobile/cmp/form/FormField.scss +++ b/mobile/cmp/form/FormField.scss @@ -23,7 +23,8 @@ .xh-form-field-info, .xh-form-field-error-msg, - .xh-form-field-pending-msg { + .xh-form-field-pending-msg, + .xh-form-field-warning-msg { font-size: var(--xh-font-size-small-px); line-height: calc(var(--xh-font-size-small-px) + var(--xh-pad-px)); color: var(--xh-text-color-muted); @@ -36,12 +37,17 @@ color: var(--xh-red); } + .xh-form-field-warning-msg { + color: var(--xh-orange); + } + &.xh-form-field-invalid .xh-form-field-label { color: var(--xh-red); } &.xh-form-field-readonly { - .xh-form-field-error-msg { + .xh-form-field-error-msg, + .xh-form-field-warning-msg { display: none; } diff --git a/mobile/cmp/form/FormField.ts b/mobile/cmp/form/FormField.ts index fe4be8651c..1ef6683ce5 100644 --- a/mobile/cmp/form/FormField.ts +++ b/mobile/cmp/form/FormField.ts @@ -15,7 +15,7 @@ import {isLocalDate} from '@xh/hoist/utils/datetime'; import {errorIf, throwIf, withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps} from '@xh/hoist/utils/react'; import classNames from 'classnames'; -import {isBoolean, isDate, isEmpty, isFinite, isUndefined} from 'lodash'; +import {first, isBoolean, isDate, isEmpty, isFinite, isUndefined} from 'lodash'; import {Children, cloneElement, ReactNode, useContext} from 'react'; import './FormField.scss'; @@ -67,8 +67,11 @@ export const [FormField, formField] = hoistCmp.withFactory({ disabled = props.disabled || model?.disabled, validationDisplayed = model?.validationDisplayed || false, notValid = model?.isNotValid || false, + validWithWarnings = model?.isValidWithWarnings || false, displayNotValid = validationDisplayed && notValid, + displayWithWarnings = validationDisplayed && validWithWarnings, errors = model?.errors || [], + warnings = model?.warnings || [], requiredStr = defaultProp('requiredIndicator', props, formContext, '*'), requiredIndicator = isRequired && !readonly && requiredStr @@ -102,6 +105,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ if (readonly) classes.push('xh-form-field-readonly'); if (disabled) classes.push('xh-form-field-disabled'); if (displayNotValid) classes.push('xh-form-field-invalid'); + if (displayWithWarnings) classes.push('xh-form-field-warning'); let childEl = readonly || !child @@ -145,9 +149,11 @@ export const [FormField, formField] = hoistCmp.withFactory({ item: 'Validating...' }), div({ - omit: minimal || !displayNotValid, - className: 'xh-form-field-error-msg', - items: notValid ? errors[0] : null + omit: minimal || !(displayNotValid || displayWithWarnings), + className: displayNotValid + ? 'xh-form-field-error-msg' + : 'xh-form-field-warning-msg', + item: first(errors) ?? first(warnings) }) ] }) diff --git a/styles/vars.scss b/styles/vars.scss index 36dc29eae3..b3a92033a4 100644 --- a/styles/vars.scss +++ b/styles/vars.scss @@ -417,6 +417,12 @@ body { --xh-form-field-invalid-message-text-color: var(--form-field-invalid-message-text-color, var(--xh-intent-danger)); --xh-form-field-margin-bottom: var(--form-field-margin-bottom, 0); --xh-form-field-margin-right: var(--form-field-margin-right, 0); + --xh-form-field-warning-border-color: var(--form-field-warning-border-color, var(--xh-intent-warning)); + --xh-form-field-warning-box-shadow: var(--form-field-warning-border-color, #{inset 0 0 0 1px var(--xh-form-field-warning-border-color), inset 0 1px 1px var(--xh-form-field-warning-border-color)}); + --xh-form-field-warning-border-width: var(--form-field-warning-border-width, 1); + --xh-form-field-warning-border-width-px: calc(var(--xh-form-field-warning-border-width) * 1px); + --xh-form-field-warning-border: #{(var(--xh-form-field-warning-border-width-px) solid var(--xh-form-field-warning-border-color))}; + --xh-form-field-warning-message-text-color: var(--form-field-warning-message-text-color, var(--xh-intent-warning)); &.xh-dark { --xh-form-field-box-shadow-color-top: var(--form-field-box-shadow-color-top, #{mc-trans('blue-grey', '800', 0.15)}); From f6e9e3178508a6fbb6ecc6b871ae62e00451589f Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Wed, 7 Jan 2026 13:23:06 -0500 Subject: [PATCH 02/13] Add "info" severity + implement Lee's feedback --- CHANGELOG.md | 2 +- cmp/form/FormModel.ts | 3 +- cmp/form/field/BaseFieldModel.ts | 46 ++++++-------- cmp/form/field/SubformsFieldModel.ts | 8 +-- cmp/grid/Grid.scss | 20 +++++-- cmp/grid/columns/Column.ts | 40 +++++++++++-- cmp/input/HoistInput.scss | 6 ++ cmp/input/HoistInputModel.ts | 18 ++++-- data/Store.ts | 32 ++++------ data/StoreRecord.ts | 27 +++------ data/impl/RecordValidator.ts | 53 +++++------------ data/impl/StoreValidator.ts | 39 +++++------- data/validation/Rule.ts | 10 ++-- data/validation/ValidationState.ts | 3 +- desktop/cmp/form/FormField.scss | 39 +++++++++++- desktop/cmp/form/FormField.ts | 89 ++++++++++++++++++++-------- desktop/cmp/input/CodeInput.scss | 6 ++ desktop/cmp/input/RadioInput.scss | 18 +++--- desktop/cmp/input/SwitchInput.scss | 20 ++++--- desktop/cmp/input/TextArea.scss | 4 ++ kit/onsen/styles.scss | 6 +- mobile/cmp/form/FormField.scss | 10 +++- mobile/cmp/form/FormField.ts | 29 ++++++--- styles/vars.scss | 6 ++ 24 files changed, 326 insertions(+), 208 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4ff2f39c..315cf41e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ this release, but is not strictly required. `react-grid-layout` v2+ (not common). * Modified `DashCanvasModel.containerPadding` to apply to the `react-grid-layout` div created by the library, instead of the Hoist-created containing div. This may affect printing layouts. -* Enhanced `Field.rules` to support warnings, handled via `Grid` and `Form` validation APIs. +* Enhanced `Field.rules` to support `warning` and `info` severity. ### 🎁 New Features diff --git a/cmp/form/FormModel.ts b/cmp/form/FormModel.ts index e739ed0d73..3bdac656cd 100644 --- a/cmp/form/FormModel.ts +++ b/cmp/form/FormModel.ts @@ -245,7 +245,6 @@ export class FormModel extends HoistModel { const states = map(this.fields, m => m.validationState); if (states.includes('NotValid')) return 'NotValid'; if (states.includes('Unknown')) return 'Unknown'; - if (states.includes('ValidWithWarnings')) return 'ValidWithWarnings'; return 'Valid'; } @@ -257,7 +256,7 @@ export class FormModel extends HoistModel { /** True if all fields are valid. */ get isValid(): boolean { - return this.validationState === 'Valid' || this.validationState === 'ValidWithWarnings'; + return this.validationState === 'Valid'; } /** List of all validation errors for this form. */ diff --git a/cmp/form/field/BaseFieldModel.ts b/cmp/form/field/BaseFieldModel.ts index 771bf63462..c792dbca05 100644 --- a/cmp/form/field/BaseFieldModel.ts +++ b/cmp/form/field/BaseFieldModel.ts @@ -10,7 +10,7 @@ import { required, Rule, RuleLike, - ValidationIssue, + Validation, ValidationState } from '@xh/hoist/data'; import {action, bindable, computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; @@ -102,7 +102,7 @@ export abstract class BaseFieldModel extends HoistModel { // containing any validation errors for the rule. If validation for the rule has not // completed will contain null @observable - private validationIssues: ValidationIssue[][]; + private validationResults: Validation[][]; @managed private validationTask = TaskObserver.trackLast(); @@ -125,7 +125,7 @@ export abstract class BaseFieldModel extends HoistModel { this._disabled = disabled; this._readonly = readonly; this.rules = this.processRuleSpecs(rules); - this.validationIssues = this.rules.map(() => null); + this.validationResults = this.rules.map(() => null); } //----------------------------- @@ -183,18 +183,16 @@ export abstract class BaseFieldModel extends HoistModel { @computed get errors(): string[] { return compact( - flatten(this.validationIssues).map(it => (it?.severity === 'error' ? it.message : null)) + flatten(this.validationResults).map(it => + it?.severity === 'error' ? it.message : null + ) ); } - /** All validation warnings for this field. */ + /** All validations for this field. */ @computed - get warnings(): string[] { - return compact( - flatten(this.validationIssues).map(it => - it?.severity === 'warning' ? it.message : null - ) - ); + get validations(): Validation[] { + return compact(flatten(this.validationResults)); } /** All validation errors for this field and its sub-forms. */ @@ -203,8 +201,8 @@ export abstract class BaseFieldModel extends HoistModel { } /** All validation warnings for this field and its sub-forms. */ - get allWarnings(): string[] { - return this.warnings; + get allValidations(): Validation[] { + return this.validations; } /** @@ -226,7 +224,7 @@ export abstract class BaseFieldModel extends HoistModel { // Force an immediate 'Unknown' state -- the async recompute leaves the old state in place until it completed. // (We want that for a value change, but not reset/init) Force the recompute only if needed. - this.validationIssues.fill(null); + this.validationResults.fill(null); wait().then(() => { if (!this.isValidationPending && this.validationState === 'Unknown') { this.computeValidationAsync(); @@ -296,14 +294,9 @@ export abstract class BaseFieldModel extends HoistModel { return this.deriveValidationState(); } - /** True if this field is confirmed to be Valid (with or without warnings). */ + /** True if this field is confirmed to be Valid. */ get isValid(): boolean { - return this.validationState === 'Valid' || this.validationState === 'ValidWithWarnings'; - } - - /** True if this field is confirmed to be Valid but has warnings. */ - get isValidWithWarnings(): boolean { - return this.validationState === 'ValidWithWarnings'; + return this.validationState === 'Valid'; } /** True if this field is confirmed to be NotValid. */ @@ -368,13 +361,13 @@ export abstract class BaseFieldModel extends HoistModel { const promises = this.rules.map(async (rule, idx) => { const result = await this.evaluateRuleAsync(rule); if (runId === this.validationRunId) { - runInAction(() => (this.validationIssues[idx] = result)); + runInAction(() => (this.validationResults[idx] = result)); } }); await Promise.all(promises); } - private async evaluateRuleAsync(rule: Rule): Promise { + private async evaluateRuleAsync(rule: Rule): Promise { if (this.ruleIsActive(rule)) { const promises = rule.check.map(async constraint => { const {value, name, displayName} = this, @@ -398,11 +391,8 @@ export abstract class BaseFieldModel extends HoistModel { } protected deriveValidationState(): ValidationState { - const {errors, warnings, validationIssues} = this; - - if (!isEmpty(errors)) return 'NotValid'; - if (validationIssues.some(e => isNil(e))) return 'Unknown'; - if (!isEmpty(warnings)) return 'ValidWithWarnings'; + if (!isEmpty(this.errors)) return 'NotValid'; + if (this.validationResults.some(e => isNil(e))) return 'Unknown'; return 'Valid'; } } diff --git a/cmp/form/field/SubformsFieldModel.ts b/cmp/form/field/SubformsFieldModel.ts index 470d0efd19..69b56b8240 100644 --- a/cmp/form/field/SubformsFieldModel.ts +++ b/cmp/form/field/SubformsFieldModel.ts @@ -5,7 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import {managed, PlainObject, XH} from '@xh/hoist/core'; -import {ValidationState} from '@xh/hoist/data'; +import {Validation, ValidationState} from '@xh/hoist/data'; import {action, computed, makeObservable, override} from '@xh/hoist/mobx'; import {throwIf} from '@xh/hoist/utils/js'; import {clone, defaults, isEqual, flatMap, isArray, partition, without} from 'lodash'; @@ -120,9 +120,9 @@ export class SubformsFieldModel extends BaseFieldModel { } @computed - override get allWarnings(): string[] { - const subWarns = flatMap(this.value, s => s.allWarnings); - return [...this.warnings, ...subWarns]; + override get allValidations(): Validation[] { + const subVals = flatMap(this.value, s => s.allValidations); + return [...this.validations, ...subVals]; } @override diff --git a/cmp/grid/Grid.scss b/cmp/grid/Grid.scss index e9a93d923b..423b69ff46 100644 --- a/cmp/grid/Grid.scss +++ b/cmp/grid/Grid.scss @@ -106,7 +106,8 @@ // Render badge on cells with validation issues .ag-cell.xh-cell--invalid, - .xh-cell--warning { + .ag-cell.xh-cell--warning, + .ag-cell.xh-cell--info { &::before { content: ''; position: absolute; @@ -134,6 +135,11 @@ border-top-color: var(--xh-intent-warning); } + .ag-cell.xh-cell--info::before { + border-right-color: var(--xh-intent-primary); + border-top-color: var(--xh-intent-primary); + } + // Render left / right group borders .ag-cell.xh-cell--group-border-left { @include AgGrid.group-border(left); @@ -146,28 +152,32 @@ .xh-ag-grid { &--tiny { .ag-cell.xh-cell--invalid::before, - .ag-cell.xh-cell--warning::before { + .ag-cell.xh-cell--warning::before, + .ag-cell.xh-cell--info::before { border-width: 3px; } } &--compact { .ag-cell.xh-cell--invalid::before, - .ag-cell.xh-cell--warning::before { + .ag-cell.xh-cell--warning::before, + .ag-cell.xh-cell--info::before { border-width: 4px; } } &--standard { .ag-cell.xh-cell--invalid::before, - .ag-cell.xh-cell--warning::before { + .ag-cell.xh-cell--warning::before, + .ag-cell.xh-cell--info::before { border-width: 5px; } } &--large { .ag-cell.xh-cell--invalid::before, - .ag-cell.xh-cell--warning::before { + .ag-cell.xh-cell--warning::before, + .ag-cell.xh-cell--info::before { border-width: 6px; } } diff --git a/cmp/grid/columns/Column.ts b/cmp/grid/columns/Column.ts index 40fbee5143..5734869672 100644 --- a/cmp/grid/columns/Column.ts +++ b/cmp/grid/columns/Column.ts @@ -12,7 +12,9 @@ import { genDisplayName, RecordAction, RecordActionSpec, - StoreRecord + StoreRecord, + Validation, + ValidationSeverity } from '@xh/hoist/data'; import {logDebug, logWarn, throwIf, warnIf, withDefault} from '@xh/hoist/utils/js'; import classNames from 'classnames'; @@ -21,6 +23,7 @@ import { clone, find, get, + groupBy, isArray, isEmpty, isFinite, @@ -869,9 +872,15 @@ export class Column { // Override with validation errors, if present if (editor) { - const errors = record.errors[field], - warnings = record.warnings[field], - validationMessages = !isEmpty(errors) ? errors : warnings; + const validationsBySeverity = groupBy( + record.validations[field], + 'severity' + ) as Record, + validationMessages = ( + validationsBySeverity.error ?? + validationsBySeverity.warning ?? + validationsBySeverity.info + )?.map(v => v.message); if (!isEmpty(validationMessages)) { return div({ ref: wrapperRef, @@ -1014,9 +1023,28 @@ export class Column { return record && !isEmpty(record.errors[field]); }, 'xh-cell--warning': agParams => { - const record = agParams.data; + const record = agParams.data, + validationsBySeverity = groupBy( + record.validations[field], + 'severity' + ) as Record; + return ( + record && + isEmpty(validationsBySeverity.error) && + !isEmpty(validationsBySeverity.warning) + ); + }, + 'xh-cell--info': agParams => { + const record = agParams.data, + validationsBySeverity = groupBy( + record.validations[field], + 'severity' + ) as Record; return ( - record && isEmpty(record.errors[field]) && !isEmpty(record.warnings[field]) + record && + isEmpty(validationsBySeverity.error) && + isEmpty(validationsBySeverity.warning) && + !isEmpty(validationsBySeverity.info) ); }, 'xh-cell--editable': agParams => { diff --git a/cmp/input/HoistInput.scss b/cmp/input/HoistInput.scss index 4b9547c17e..053cecd4cc 100644 --- a/cmp/input/HoistInput.scss +++ b/cmp/input/HoistInput.scss @@ -17,4 +17,10 @@ border: var(--xh-form-field-warning-border); } } + + &.xh-input-info { + input { + border: var(--xh-form-field-info-border); + } + } } diff --git a/cmp/input/HoistInputModel.ts b/cmp/input/HoistInputModel.ts index 68b8b93a3a..64ecf5fe8b 100644 --- a/cmp/input/HoistInputModel.ts +++ b/cmp/input/HoistInputModel.ts @@ -6,10 +6,11 @@ */ import {FieldModel} from '@xh/hoist/cmp/form'; import {DefaultHoistProps, HoistModel, HoistModelClass, useLocalModel} from '@xh/hoist/core'; +import {Validation, ValidationSeverity} from '@xh/hoist/data'; import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {createObservableRef} from '@xh/hoist/utils/react'; import classNames from 'classnames'; -import {isEqual} from 'lodash'; +import {groupBy, isEmpty, isEqual} from 'lodash'; import {FocusEvent, ForwardedRef, ReactElement, ReactInstance, useImperativeHandle} from 'react'; import {findDOMNode} from 'react-dom'; import './HoistInput.scss'; @@ -337,8 +338,18 @@ export function useHoistInputModel( const field = inputModel.getField(), validityClass = field?.isNotValid && field?.validationDisplayed ? 'xh-input-invalid' : null, + validationsBySeverity = groupBy(field?.validations, 'severity') as Record< + ValidationSeverity, + Validation[] + >, warningClass = - field?.isValidWithWarnings && field?.validationDisplayed ? 'xh-input-warning' : null, + !isEmpty(validationsBySeverity.warning) && field?.validationDisplayed + ? 'xh-input-warning' + : null, + infoClass = + !isEmpty(validationsBySeverity.info) && field?.validationDisplayed + ? 'xh-input-info' + : null, disabledClass = props.disabled ? 'xh-input-disabled' : null; return component({ @@ -347,8 +358,7 @@ export function useHoistInputModel( ref: inputModel.domRef, className: classNames( 'xh-input', - validityClass, - warningClass, + validityClass ?? warningClass ?? infoClass, disabledClass, props.className ) diff --git a/data/Store.ts b/data/Store.ts index 457f3be14d..32002ca7eb 100644 --- a/data/Store.ts +++ b/data/Store.ts @@ -30,7 +30,11 @@ import { import {Field, FieldSpec} from './Field'; import {parseFilter} from './filter/Utils'; import {RecordSet} from './impl/RecordSet'; -import {StoreValidationMessagesMap, StoreValidator} from './impl/StoreValidator'; +import { + StoreValidationMessagesMap, + StoreValidationsMap, + StoreValidator +} from './impl/StoreValidator'; import {StoreRecord, StoreRecordId, StoreRecordOrId} from './StoreRecord'; import {instanceManager} from '../core/impl/InstanceManager'; import {Filter} from './filter/Filter'; @@ -863,28 +867,23 @@ export class Store extends HoistBase { return this.validator.errors; } + get validations(): StoreValidationsMap { + return this.validator.validations; + } + /** Count of all validation errors for the store. */ get errorCount(): number { return this.validator.errorCount; } - get warnings(): StoreValidationMessagesMap { - return this.validator.warnings; - } - - /** Count of all validation warnings for the store. */ - get warningCount(): number { - return this.validator.warningCount; - } - /** Array of all errors for this store. */ get allErrors(): string[] { return uniq(flatMapDeep(this.errors, values)); } - /** Array of all warnings for this store. */ - get allWarnings(): string[] { - return uniq(flatMapDeep(this.warnings, values)); + /** Array of all validations for this store. */ + get allValidations(): string[] { + return uniq(flatMapDeep(this.validations, values)); } /** @@ -949,16 +948,11 @@ export class Store extends HoistBase { return ret ? ret : []; } - /** True if the store is confirmed to be Valid (with or without warnings). */ + /** True if the store is confirmed to be Valid. */ get isValid(): boolean { return this.validator.isValid; } - /** True if the store is confirmed to be Valid but has warnings. */ - get isValidWithWarnings(): boolean { - return this.validator.isValidWithWarnings; - } - /** True if the store is confirmed to be NotValid. */ get isNotValid(): boolean { return this.validator.isNotValid; diff --git a/data/StoreRecord.ts b/data/StoreRecord.ts index b7d313d996..1df5fd4c8c 100644 --- a/data/StoreRecord.ts +++ b/data/StoreRecord.ts @@ -5,6 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import {PlainObject} from '@xh/hoist/core'; +import {Validation} from '@xh/hoist/data/validation/Rule'; import {throwIf} from '@xh/hoist/utils/js'; import {isNil, flatMap, isMatch, isEmpty, pickBy} from 'lodash'; import {Store} from './Store'; @@ -133,14 +134,9 @@ export class StoreRecord { return this.store.getAncestorsById(this.id, false); } - /** True if the record is confirmed to be Valid (with or without warnings). */ + /** True if the record is confirmed to be Valid. */ get isValid(): boolean { - return this.validationState === 'Valid' || this.validationState === 'ValidWithWarnings'; - } - - /** True if the record is confirmed to be Valid but has warnings. */ - get isValidWithWarnings(): boolean { - return this.validationState === 'ValidWithWarnings'; + return this.validationState === 'Valid'; } /** True if the record is confirmed to be NotValid. */ @@ -158,9 +154,9 @@ export class StoreRecord { return this.validator?.errors ?? {}; } - /** Map of field names to list of warnings. */ - get warnings(): Record { - return this.validator?.warnings ?? {}; + /** Map of field names to list of validations. */ + get validations(): Record { + return this.validator?.validations ?? {}; } /** Array of all errors for this record. */ @@ -168,9 +164,9 @@ export class StoreRecord { return flatMap(this.errors); } - /** Array of all warnings for this record. */ - get allWarnings() { - return flatMap(this.warnings); + /** Array of all validations for this record. */ + get allValidations(): Validation[] { + return flatMap(this.validations); } /** Count of all validation errors for the record. */ @@ -178,11 +174,6 @@ export class StoreRecord { return this.validator?.errorCount ?? 0; } - /** Count of all validation warnings for the record. */ - get warningCount(): number { - return this.validator?.warningCount ?? 0; - } - /** True if any fields are currently recomputing their validation state. */ get isValidationPending(): boolean { return this.validator?.isPending ?? false; diff --git a/data/impl/RecordValidator.ts b/data/impl/RecordValidator.ts index d1cc850256..c349ab924c 100644 --- a/data/impl/RecordValidator.ts +++ b/data/impl/RecordValidator.ts @@ -4,14 +4,7 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import { - Field, - Rule, - StoreRecord, - StoreRecordId, - ValidationIssue, - ValidationState -} from '@xh/hoist/data'; +import {Field, Rule, StoreRecord, StoreRecordId, Validation, ValidationState} from '@xh/hoist/data'; import {computed, observable, makeObservable, runInAction} from '@xh/hoist/mobx'; import {compact, flatten, isEmpty, isString, mapValues, values} from 'lodash'; import {TaskObserver} from '../../core'; @@ -23,7 +16,7 @@ import {TaskObserver} from '../../core'; export class RecordValidator { record: StoreRecord; - @observable.ref private fieldValidationIssues: RecordValidationIssueMap = null; + @observable.ref private fieldValidations: RecordValidationsMap = null; private validationTask = TaskObserver.trackLast(); private validationRunId = 0; @@ -31,16 +24,10 @@ export class RecordValidator { return this.record.id; } - /** True if the record is confirmed to be Valid (with or without warnings). */ + /** True if the record is confirmed to be Valid. */ @computed get isValid(): boolean { - return this.validationState === 'Valid' || this.validationState === 'ValidWithWarnings'; - } - - /** True if the record is confirmed to be Valid but has warnings. */ - @computed - get isValidWithWarnings(): boolean { - return this.validationState === 'ValidWithWarnings'; + return this.validationState === 'Valid'; } /** True if the record is confirmed to be NotValid. */ @@ -58,17 +45,15 @@ export class RecordValidator { /** Map of field names to field-level errors. */ @computed.struct get errors(): RecordValidationMessagesMap { - return mapValues(this.fieldValidationIssues ?? {}, issues => + return mapValues(this.fieldValidations ?? {}, issues => compact(issues.map(it => (it?.severity === 'error' ? it?.message : null))) ); } - /** Map of field names to field-level warnings. */ + /** Map of field names to field-level validations. */ @computed.struct - get warnings(): RecordValidationMessagesMap { - return mapValues(this.fieldValidationIssues ?? {}, issues => - compact(issues.map(it => (it?.severity === 'warning' ? it?.message : null))) - ); + get validations(): RecordValidationsMap { + return this.fieldValidations ?? {}; } /** Count of all validation errors for the record. */ @@ -77,12 +62,6 @@ export class RecordValidator { return flatten(values(this.errors)).length; } - /** Count of all validation warnings for the record. */ - @computed - get warningCount(): number { - return flatten(values(this.warnings)).length; - } - /** True if any fields are currently recomputing their validation state. */ @computed get isPending(): boolean { @@ -117,24 +96,19 @@ export class RecordValidator { if (runId !== this.validationRunId) return; fieldErrors = mapValues(fieldErrors, it => compact(flatten(it))); - runInAction(() => (this.fieldValidationIssues = fieldErrors)); + runInAction(() => (this.fieldValidations = fieldErrors)); return this.isValid; } /** The current validation state for the record. */ getValidationState(): ValidationState { - if (this.fieldValidationIssues === null) return 'Unknown'; // Before executing any rules + if (this.fieldValidations === null) return 'Unknown'; // Before executing any rules if (this.errorCount) return 'NotValid'; - if (this.warningCount) return 'ValidWithWarnings'; return 'Valid'; } - async evaluateRuleAsync( - record: StoreRecord, - field: Field, - rule: Rule - ): Promise { + async evaluateRuleAsync(record: StoreRecord, field: Field, rule: Rule): Promise { const values = record.getValues(), {name, displayName} = field, value = record.get(name); @@ -158,6 +132,7 @@ export class RecordValidator { } } -/** Map of Field names to Field-level ValidationIssue lists. */ -export type RecordValidationIssueMap = Record; +/** Map of Field names to Field-level Validation lists. */ +export type RecordValidationsMap = Record; +/** Map of Field names to Field-level validation message lists. */ export type RecordValidationMessagesMap = Record; diff --git a/data/impl/StoreValidator.ts b/data/impl/StoreValidator.ts index c0a2538c4c..04c3f0d193 100644 --- a/data/impl/StoreValidator.ts +++ b/data/impl/StoreValidator.ts @@ -9,7 +9,11 @@ import {HoistBase} from '@xh/hoist/core'; import {computed, makeObservable, runInAction, observable} from '@xh/hoist/mobx'; import {sumBy, chunk} from 'lodash'; import {findIn} from '@xh/hoist/utils/js'; -import {RecordValidationMessagesMap, RecordValidator} from './RecordValidator'; +import { + RecordValidationMessagesMap, + RecordValidationsMap, + RecordValidator +} from './RecordValidator'; import {ValidationState} from '../validation/ValidationState'; import {Store} from '../Store'; import {StoreRecordId} from '../StoreRecord'; @@ -21,16 +25,10 @@ import {StoreRecordId} from '../StoreRecord'; export class StoreValidator extends HoistBase { store: Store; - /** True if the store is confirmed to be Valid (with or without warnings). */ + /** True if the store is confirmed to be Valid. */ @computed get isValid(): boolean { - return this.validationState === 'Valid' || this.validationState === 'ValidWithWarnings'; - } - - /** True if the store is confirmed to be Valid but has warnings. */ - @computed - get isValidWithWarnings(): boolean { - return this.validationState === 'ValidWithWarnings'; + return this.validationState === 'Valid'; } /** True if the store is confirmed to be NotValid. */ @@ -57,16 +55,10 @@ export class StoreValidator extends HoistBase { return sumBy(this.validators, 'errorCount'); } - /** Map of StoreRecord IDs to StoreRecord-level warning maps. */ + /** Map of StoreRecord IDs to StoreRecord-level validations maps. */ @computed.struct - get warnings(): StoreValidationMessagesMap { - return this.getWarningMap(); - } - - /** Count of all validation warnings for the store. */ - @computed - get warningCount(): number { - return sumBy(this.validators, 'warningCount'); + get validations(): StoreValidationsMap { + return this.getValidationsMap(); } /** True if any records are currently recomputing their validation state. */ @@ -106,7 +98,6 @@ export class StoreValidator extends HoistBase { const states = this.mapValidators(v => v.validationState); if (states.includes('NotValid')) return 'NotValid'; if (states.includes('Unknown')) return 'Unknown'; - if (states.includes('ValidWithWarnings')) return 'ValidWithWarnings'; return 'Valid'; } @@ -117,9 +108,9 @@ export class StoreValidator extends HoistBase { return ret; } - getWarningMap(): StoreValidationMessagesMap { - const ret: StoreValidationMessagesMap = {}; - this._validators.forEach(v => (ret[v.id] = v.warnings)); + getValidationsMap(): StoreValidationsMap { + const ret: StoreValidationsMap = {}; + this._validators.forEach(v => (ret[v.id] = v.validations)); return ret; } @@ -181,5 +172,7 @@ export class StoreValidator extends HoistBase { } } -/** Map of StoreRecord IDs to StoreRecord-level error maps. */ +/** Map of StoreRecord IDs to StoreRecord-level messages maps. */ export type StoreValidationMessagesMap = Record; +/** Map of StoreRecord IDs to StoreRecord-level validations maps. */ +export type StoreValidationsMap = Record; diff --git a/data/validation/Rule.ts b/data/validation/Rule.ts index 4bab6fee39..a2f39c9bf1 100644 --- a/data/validation/Rule.ts +++ b/data/validation/Rule.ts @@ -27,14 +27,14 @@ export class Rule { * * @param fieldState - context w/value for the constraint's target Field. * @param allValues - current values for all fields in form, keyed by field name. - * @returns String or array of strings describing errors, or ValidationIssue or an array of - * ValidationIssues, or null or undefined if rule passes successfully. May return a Promise + * @returns String or array of strings describing errors, or Validation object or an array of + * Validation objects, or null or undefined if rule passes successfully. May return a Promise * resolving to same for async validation. */ export type Constraint = ( fieldState: FieldState, allValues: PlainObject -) => Awaitable>; +) => Awaitable>; /** * Function to determine when to perform validation on a value. @@ -76,9 +76,9 @@ export interface RuleSpec { export type RuleLike = RuleSpec | Constraint | Rule; -export interface ValidationIssue { +export interface Validation { severity: ValidationSeverity; message: string; } -export type ValidationSeverity = 'error' | 'warning'; +export type ValidationSeverity = 'error' | 'warning' | 'info'; diff --git a/data/validation/ValidationState.ts b/data/validation/ValidationState.ts index 403db26679..5a394d9591 100644 --- a/data/validation/ValidationState.ts +++ b/data/validation/ValidationState.ts @@ -8,8 +8,7 @@ export const ValidationState = Object.freeze({ Unknown: 'Unknown', NotValid: 'NotValid', - Valid: 'Valid', - ValidWithWarnings: 'ValidWithWarnings' + Valid: 'Valid' }); // eslint-disable-next-line export type ValidationState = (typeof ValidationState)[keyof typeof ValidationState]; diff --git a/desktop/cmp/form/FormField.scss b/desktop/cmp/form/FormField.scss index eb747a34f6..3327349d27 100644 --- a/desktop/cmp/form/FormField.scss +++ b/desktop/cmp/form/FormField.scss @@ -49,7 +49,8 @@ .xh-form-field-info, .xh-form-field-error-msg, - .xh-form-field-warning-msg { + .xh-form-field-warning-msg, + .xh-form-field-info-msg { font-size: var(--xh-font-size-small-px); line-height: calc(var(--xh-font-size-small-px) + var(--xh-pad-px)); white-space: nowrap; @@ -65,6 +66,10 @@ color: var(--xh-orange); } + .xh-form-field-info-msg { + color: var(--xh-blue); + } + &.xh-form-field-inline { flex-direction: row; align-items: baseline; @@ -91,7 +96,8 @@ } .xh-form-field-error-msg, - .xh-form-field-warning-msg { + .xh-form-field-warning-msg, + .xh-form-field-info-msg { display: none; } @@ -158,6 +164,32 @@ border: var(--xh-form-field-warning-border) !important; } } + + &.xh-form-field-info:not(.xh-form-field-readonly) { + .xh-check-box span { + box-shadow: var(--xh-form-field-info-box-shadow) !important; + } + + .xh-button-group-input button.xh-button { + box-shadow: var(--xh-form-field-info-box-shadow); + } + + .xh-slider span { + box-shadow: var(--xh-form-field-info-box-shadow); + } + + div.xh-select__control { + border: var(--xh-form-field-info-border); + } + + .xh-text-input > svg { + color: var(--xh-intent-primary); + } + + .xh-text-area.textarea { + border: var(--xh-form-field-info-border) !important; + } + } } ul.xh-form-field-error-tooltip { @@ -179,7 +211,8 @@ ul.xh-form-field-error-tooltip { } .xh-form-field-error-msg, - .xh-form-field-warning-msg { + .xh-form-field-warning-msg, + .xh-form-field-info-msg { margin: 0 var(--xh-pad-px); } } diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index 7580209a68..7e4f7bc58c 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -19,6 +19,7 @@ import { } from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {instanceManager} from '@xh/hoist/core/impl/InstanceManager'; +import {Validation, ValidationSeverity} from '@xh/hoist/data'; import {fmtDate, fmtDateTime, fmtJson, fmtNumber} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {tooltip} from '@xh/hoist/kit/blueprint'; @@ -26,7 +27,17 @@ import {isLocalDate} from '@xh/hoist/utils/datetime'; import {errorIf, getTestId, logWarn, TEST_ID, throwIf, withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps, getReactElementName, useOnMount, useOnUnmount} from '@xh/hoist/utils/react'; import classNames from 'classnames'; -import {first, isBoolean, isDate, isEmpty, isFinite, isNil, isUndefined, kebabCase} from 'lodash'; +import { + first, + groupBy, + isBoolean, + isDate, + isEmpty, + isFinite, + isNil, + isUndefined, + kebabCase +} from 'lodash'; import {Children, cloneElement, ReactElement, ReactNode, useContext, useState} from 'react'; import './FormField.scss'; @@ -124,11 +135,18 @@ export const [FormField, formField] = hoistCmp.withFactory({ disabled = props.disabled || model?.disabled, validationDisplayed = model?.validationDisplayed || false, notValid = model?.isNotValid || false, - validWithWarnings = model?.isValidWithWarnings || false, + validationsBySeverity = groupBy(model?.validations, 'severity') as Record< + ValidationSeverity, + Validation[] + >, + validWithWarnings = !notValid && !isEmpty(validationsBySeverity.warning), + validWithInfo = !notValid && !validWithWarnings && !isEmpty(validationsBySeverity.info), displayNotValid = validationDisplayed && notValid, displayWithWarnings = validationDisplayed && validWithWarnings, + displayWithInfo = validationDisplayed && validWithInfo, errors = model?.errors || [], - warnings = model?.warnings || [], + warnings = validationsBySeverity.warning?.map(v => v.message) ?? [], + infos = validationsBySeverity.info?.map(v => v.message) ?? [], requiredStr = defaultProp('requiredIndicator', props, formContext, '*'), requiredIndicator = isRequired && !readonly && requiredStr @@ -174,6 +192,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ if (disabled) classes.push('xh-form-field-disabled'); if (displayNotValid) classes.push('xh-form-field-invalid'); if (displayWithWarnings) classes.push('xh-form-field-warning'); + if (displayWithInfo) classes.push('xh-form-field-info'); const testId = getFormFieldTestId(props, formContext, model?.name); useOnMount(() => instanceManager.registerModelWithTestId(testId, model)); @@ -195,6 +214,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ disabled, displayNotValid, displayWithWarnings, + displayWithInfo, leftErrorIcon, commitOnChange, testId: getTestId(testId, 'input') @@ -206,14 +226,15 @@ export const [FormField, formField] = hoistCmp.withFactory({ className: classNames( 'xh-input', displayNotValid && 'xh-input-invalid', - displayWithWarnings && 'xh-input-warning' + displayWithWarnings && 'xh-input-warning', + displayWithInfo && 'xh-input-info' ), targetTagName: !blockChildren.includes(childElementName) || childWidth ? 'span' : 'div', position: tooltipPosition, boundary: tooltipBoundary, - disabled: !displayNotValid && !displayWithWarnings, - content: getValidationTooltipContent(errors, warnings) + disabled: !displayNotValid && !displayWithWarnings && !displayWithInfo, + content: getValidationTooltipContent(errors, warnings, infos) }); } @@ -248,13 +269,21 @@ export const [FormField, formField] = hoistCmp.withFactory({ item: info }), tooltip({ - omit: minimal || !(displayNotValid || displayWithWarnings), + omit: + minimal || + !(displayNotValid || displayWithWarnings || displayWithInfo), openOnTargetFocus: false, - className: displayNotValid - ? 'xh-form-field-error-msg' - : 'xh-form-field-warning-msg', - item: first(errors) ?? first(warnings), - content: getValidationTooltipContent(errors, warnings) as ReactElement + className: classNames( + displayNotValid && 'xh-form-field-error-msg', + displayWithWarnings && 'xh-form-field-warning-msg', + displayWithInfo && 'xh-form-field-info-msg' + ), + item: first(errors) ?? first(warnings) ?? first(infos), + content: getValidationTooltipContent( + errors, + warnings, + infos + ) as ReactElement }) ] }) @@ -370,28 +399,40 @@ function getValidChild(children) { return child; } -function getValidationTooltipContent(errors: string[], warnings: string[]): ReactElement | string { - // If no issues, something other than null must be returned. +function getValidationTooltipContent( + errors: string[], + warnings: string[], + infos: string[] +): ReactElement | string { + // If no validations, something other than null must be returned. // If null is returned, as of Blueprint v5, the Blueprint Tooltip component causes deep re-renders of its target // when content changes from null <-> not null. // In `formField` `minimal:true` mode with `commitonchange:true`, this causes the // TextInput component to lose focus when its validation state changes, which is undesirable. // It is not clear if this is a bug or intended behavior in BP v5, but this workaround prevents the issue. // `Tooltip:content` has been a required prop since at least BP v4, but something about the way it is used in BP v5 changed. + let messages: string[] = [], + className: string; if (!isEmpty(errors)) { - if (errors.length === 1) return errors[0]; - return ul({ - className: 'xh-form-field-error-tooltip', - items: errors.map((it, idx) => li({key: idx, item: it})) - }); + messages = errors; + className = 'xh-form-field-error-tooltip'; } else if (!isEmpty(warnings)) { - if (warnings.length === 1) return warnings[0]; + messages = warnings; + className = 'xh-form-field-warning-tooltip'; + } else if (!isEmpty(infos)) { + messages = infos; + className = 'xh-form-field-info-tooltip'; + } + + if (isEmpty(messages)) { + return 'Is Valid'; + } else if (messages.length === 1) { + return messages[0]; + } else { return ul({ - className: 'xh-form-field-warning-tooltip', - items: warnings.map((it, idx) => li({key: idx, item: it})) + className, + items: messages.map((it, idx) => li({key: idx, item: it})) }); - } else { - return 'Is Valid'; } } diff --git a/desktop/cmp/input/CodeInput.scss b/desktop/cmp/input/CodeInput.scss index 4724081810..e0f953bacc 100644 --- a/desktop/cmp/input/CodeInput.scss +++ b/desktop/cmp/input/CodeInput.scss @@ -41,6 +41,12 @@ } } + &.xh-input-info { + div.CodeMirror { + border: var(--xh-form-field-info-border); + } + } + &.xh-input-disabled { .CodeMirror { background-color: var(--xh-input-disabled-bg); diff --git a/desktop/cmp/input/RadioInput.scss b/desktop/cmp/input/RadioInput.scss index 2aee230054..17c98f8275 100644 --- a/desktop/cmp/input/RadioInput.scss +++ b/desktop/cmp/input/RadioInput.scss @@ -10,20 +10,24 @@ margin-right: var(--xh-pad-double-px); } - &.xh-input-invalid .xh-radio-input-option .bp6-control-indicator { - border: var(--xh-form-field-invalid-border); - - &::before { + &.xh-input-invalid, + &.xh-input-warning, + &.xh-input-info { + .xh-radio-input-option .bp6-control-indicator::before { margin: -1px; } } + &.xh-input-invalid .xh-radio-input-option .bp6-control-indicator { + border: var(--xh-form-field-invalid-border); + } + &.xh-input-warning .xh-radio-input-option .bp6-control-indicator { border: var(--xh-form-field-warning-border); + } - &::before { - margin: -1px; - } + &.xh-input-info .xh-radio-input-option .bp6-control-indicator { + border: var(--xh-form-field-info-border); } } diff --git a/desktop/cmp/input/SwitchInput.scss b/desktop/cmp/input/SwitchInput.scss index d2cf6cd2d7..1a1f106a11 100644 --- a/desktop/cmp/input/SwitchInput.scss +++ b/desktop/cmp/input/SwitchInput.scss @@ -5,22 +5,28 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ +.xh-switch-input.xh-input-invalid, +.xh-switch-input.xh-input-warning, +.xh-switch-input.xh-input-info { + .bp6-control-indicator::before { + margin: 1px; + } +} + .xh-switch-input.xh-input-invalid { .bp6-control-indicator { border: var(--xh-form-field-invalid-border); - - &::before { - margin: 1px; - } } } .xh-switch-input.xh-input-warning { .bp6-control-indicator { border: var(--xh-form-field-warning-border); + } +} - &::before { - margin: 1px; - } +.xh-switch-input.xh-input-info { + .bp6-control-indicator { + border: var(--xh-form-field-info-border); } } diff --git a/desktop/cmp/input/TextArea.scss b/desktop/cmp/input/TextArea.scss index c8c45b957f..8dd2de38bd 100644 --- a/desktop/cmp/input/TextArea.scss +++ b/desktop/cmp/input/TextArea.scss @@ -19,4 +19,8 @@ &.xh-input-warning { border: var(--xh-form-field-warning-border); } + + &.xh-input-info { + border: var(--xh-form-field-info-border); + } } diff --git a/kit/onsen/styles.scss b/kit/onsen/styles.scss index 817b5f782c..a72dd39abf 100644 --- a/kit/onsen/styles.scss +++ b/kit/onsen/styles.scss @@ -24,7 +24,7 @@ border: var(--xh-border-solid); border-radius: var(--xh-border-radius-px); - &:not(.xh-input-invalid):not(.xh-input-warning):focus-within { + &:not(.xh-input-invalid):not(.xh-input-warning):not(.xh-input-info):focus-within { border-color: var(--xh-focus-outline-color); } @@ -35,6 +35,10 @@ &.xh-input-warning { border: var(--xh-form-field-warning-border); } + + &.xh-input-info { + border: var(--xh-form-field-info-border); + } } } diff --git a/mobile/cmp/form/FormField.scss b/mobile/cmp/form/FormField.scss index 0ff23407b8..37966b79a1 100644 --- a/mobile/cmp/form/FormField.scss +++ b/mobile/cmp/form/FormField.scss @@ -24,7 +24,8 @@ .xh-form-field-info, .xh-form-field-error-msg, .xh-form-field-pending-msg, - .xh-form-field-warning-msg { + .xh-form-field-warning-msg, + .xh-form-field-info-msg { font-size: var(--xh-font-size-small-px); line-height: calc(var(--xh-font-size-small-px) + var(--xh-pad-px)); color: var(--xh-text-color-muted); @@ -41,13 +42,18 @@ color: var(--xh-orange); } + .xh-form-field-info-msg { + color: var(--xh-blue); + } + &.xh-form-field-invalid .xh-form-field-label { color: var(--xh-red); } &.xh-form-field-readonly { .xh-form-field-error-msg, - .xh-form-field-warning-msg { + .xh-form-field-warning-msg, + .xh-form-field-info-msg { display: none; } diff --git a/mobile/cmp/form/FormField.ts b/mobile/cmp/form/FormField.ts index 1ef6683ce5..9aaf096c33 100644 --- a/mobile/cmp/form/FormField.ts +++ b/mobile/cmp/form/FormField.ts @@ -8,6 +8,7 @@ import composeRefs from '@seznam/compose-react-refs/composeRefs'; import {FieldModel, FormContext, FormContextType, BaseFormFieldProps} from '@xh/hoist/cmp/form'; import {box, div, span} from '@xh/hoist/cmp/layout'; import {DefaultHoistProps, hoistCmp, HoistProps, TestSupportProps, uses, XH} from '@xh/hoist/core'; +import {Validation, ValidationSeverity} from '@xh/hoist/data'; import {fmtDate, fmtDateTime, fmtNumber} from '@xh/hoist/format'; import {label as labelCmp} from '@xh/hoist/mobile/cmp/input'; import '@xh/hoist/mobile/register'; @@ -15,7 +16,7 @@ import {isLocalDate} from '@xh/hoist/utils/datetime'; import {errorIf, throwIf, withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps} from '@xh/hoist/utils/react'; import classNames from 'classnames'; -import {first, isBoolean, isDate, isEmpty, isFinite, isUndefined} from 'lodash'; +import {first, groupBy, isBoolean, isDate, isEmpty, isFinite, isUndefined} from 'lodash'; import {Children, cloneElement, ReactNode, useContext} from 'react'; import './FormField.scss'; @@ -67,11 +68,18 @@ export const [FormField, formField] = hoistCmp.withFactory({ disabled = props.disabled || model?.disabled, validationDisplayed = model?.validationDisplayed || false, notValid = model?.isNotValid || false, - validWithWarnings = model?.isValidWithWarnings || false, + validationsBySeverity = groupBy(model?.validations, 'severity') as Record< + ValidationSeverity, + Validation[] + >, + validWithWarnings = !notValid && !isEmpty(validationsBySeverity.warning), + validWithInfo = !notValid && !validWithWarnings && !isEmpty(validationsBySeverity.info), displayNotValid = validationDisplayed && notValid, displayWithWarnings = validationDisplayed && validWithWarnings, + displayWithInfo = validationDisplayed && validWithInfo, errors = model?.errors || [], - warnings = model?.warnings || [], + warnings = validationsBySeverity.warning?.map(v => v.message) ?? [], + infos = validationsBySeverity.info?.map(v => v.message) ?? [], requiredStr = defaultProp('requiredIndicator', props, formContext, '*'), requiredIndicator = isRequired && !readonly && requiredStr @@ -106,6 +114,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ if (disabled) classes.push('xh-form-field-disabled'); if (displayNotValid) classes.push('xh-form-field-invalid'); if (displayWithWarnings) classes.push('xh-form-field-warning'); + if (displayWithInfo) classes.push('xh-form-field-info'); let childEl = readonly || !child @@ -149,11 +158,15 @@ export const [FormField, formField] = hoistCmp.withFactory({ item: 'Validating...' }), div({ - omit: minimal || !(displayNotValid || displayWithWarnings), - className: displayNotValid - ? 'xh-form-field-error-msg' - : 'xh-form-field-warning-msg', - item: first(errors) ?? first(warnings) + omit: + minimal || + !(displayNotValid || displayWithWarnings || displayWithInfo), + className: classNames( + displayNotValid && 'xh-form-field-error-msg', + displayWithWarnings && 'xh-form-field-warning-msg', + displayWithInfo && 'xh-form-field-info-msg' + ), + item: first(errors) ?? first(warnings) ?? first(infos) }) ] }) diff --git a/styles/vars.scss b/styles/vars.scss index b3a92033a4..615218b916 100644 --- a/styles/vars.scss +++ b/styles/vars.scss @@ -423,6 +423,12 @@ body { --xh-form-field-warning-border-width-px: calc(var(--xh-form-field-warning-border-width) * 1px); --xh-form-field-warning-border: #{(var(--xh-form-field-warning-border-width-px) solid var(--xh-form-field-warning-border-color))}; --xh-form-field-warning-message-text-color: var(--form-field-warning-message-text-color, var(--xh-intent-warning)); + --xh-form-field-info-border-color: var(--form-field-info-border-color, var(--xh-intent-primary)); + --xh-form-field-info-box-shadow: var(--form-field-info-border-color, #{inset 0 0 0 1px var(--xh-form-field-info-border-color), inset 0 1px 1px var(--xh-form-field-info-border-color)}); + --xh-form-field-info-border-width: var(--form-field-info-border-width, 1); + --xh-form-field-info-border-width-px: calc(var(--xh-form-field-info-border-width) * 1px); + --xh-form-field-info-border: #{(var(--xh-form-field-info-border-width-px) solid var(--xh-form-field-info-border-color))}; + --xh-form-field-info-message-text-color: var(--form-field-info-message-text-color, var(--xh-intent-primary)); &.xh-dark { --xh-form-field-box-shadow-color-top: var(--form-field-box-shadow-color-top, #{mc-trans('blue-grey', '800', 0.15)}); From 97c6babedfa1eb97a764b63c1b76bfdb0eddfa72 Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Thu, 8 Jan 2026 10:33:41 -0500 Subject: [PATCH 03/13] CR Lee --- cmp/form/field/BaseFieldModel.ts | 6 +- cmp/grid/columns/Column.ts | 38 +++---------- cmp/input/HoistInputModel.ts | 21 ++----- data/Store.ts | 3 +- data/impl/RecordValidator.ts | 2 +- data/validation/Rule.ts | 22 +++++-- desktop/cmp/form/FormField.ts | 98 +++++++++----------------------- mobile/cmp/form/FormField.scss | 8 +++ mobile/cmp/form/FormField.ts | 43 +++++--------- 9 files changed, 83 insertions(+), 158 deletions(-) diff --git a/cmp/form/field/BaseFieldModel.ts b/cmp/form/field/BaseFieldModel.ts index c792dbca05..3f404e24fc 100644 --- a/cmp/form/field/BaseFieldModel.ts +++ b/cmp/form/field/BaseFieldModel.ts @@ -182,11 +182,7 @@ export abstract class BaseFieldModel extends HoistModel { /** All validation errors for this field. */ @computed get errors(): string[] { - return compact( - flatten(this.validationResults).map(it => - it?.severity === 'error' ? it.message : null - ) - ); + return this.validations.filter(it => it.severity === 'error').map(it => it.message); } /** All validations for this field. */ diff --git a/cmp/grid/columns/Column.ts b/cmp/grid/columns/Column.ts index 5734869672..10cc62e939 100644 --- a/cmp/grid/columns/Column.ts +++ b/cmp/grid/columns/Column.ts @@ -10,6 +10,7 @@ import { CubeFieldSpec, FieldSpec, genDisplayName, + maxSeverity, RecordAction, RecordActionSpec, StoreRecord, @@ -870,7 +871,7 @@ export class Column { if (location === 'header') return div({ref: wrapperRef, item: this.headerTooltip}); if (!hasRecord) return null; - // Override with validation errors, if present + // Override with validation errors, if present -- only show highest-severity level if (editor) { const validationsBySeverity = groupBy( record.validations[field], @@ -1018,35 +1019,12 @@ export class Column { }); ret.cellEditorPopup = this.editorIsPopup; ret.cellClassRules = { - 'xh-cell--invalid': agParams => { - const record = agParams.data; - return record && !isEmpty(record.errors[field]); - }, - 'xh-cell--warning': agParams => { - const record = agParams.data, - validationsBySeverity = groupBy( - record.validations[field], - 'severity' - ) as Record; - return ( - record && - isEmpty(validationsBySeverity.error) && - !isEmpty(validationsBySeverity.warning) - ); - }, - 'xh-cell--info': agParams => { - const record = agParams.data, - validationsBySeverity = groupBy( - record.validations[field], - 'severity' - ) as Record; - return ( - record && - isEmpty(validationsBySeverity.error) && - isEmpty(validationsBySeverity.warning) && - !isEmpty(validationsBySeverity.info) - ); - }, + 'xh-cell--invalid': agParams => + maxSeverity(agParams.data.validations[field]) === 'error', + 'xh-cell--warning': agParams => + maxSeverity(agParams.data.validations[field]) === 'warning', + 'xh-cell--info': agParams => + maxSeverity(agParams.data.validations[field]) === 'info', 'xh-cell--editable': agParams => { return this.isEditableForRecord(agParams.data); }, diff --git a/cmp/input/HoistInputModel.ts b/cmp/input/HoistInputModel.ts index 64ecf5fe8b..ad0d91cb21 100644 --- a/cmp/input/HoistInputModel.ts +++ b/cmp/input/HoistInputModel.ts @@ -6,11 +6,11 @@ */ import {FieldModel} from '@xh/hoist/cmp/form'; import {DefaultHoistProps, HoistModel, HoistModelClass, useLocalModel} from '@xh/hoist/core'; -import {Validation, ValidationSeverity} from '@xh/hoist/data'; +import {maxSeverity} from '@xh/hoist/data'; import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {createObservableRef} from '@xh/hoist/utils/react'; import classNames from 'classnames'; -import {groupBy, isEmpty, isEqual} from 'lodash'; +import {isEqual} from 'lodash'; import {FocusEvent, ForwardedRef, ReactElement, ReactInstance, useImperativeHandle} from 'react'; import {findDOMNode} from 'react-dom'; import './HoistInput.scss'; @@ -337,19 +337,7 @@ export function useHoistInputModel( useImperativeHandle(ref, () => inputModel); const field = inputModel.getField(), - validityClass = field?.isNotValid && field?.validationDisplayed ? 'xh-input-invalid' : null, - validationsBySeverity = groupBy(field?.validations, 'severity') as Record< - ValidationSeverity, - Validation[] - >, - warningClass = - !isEmpty(validationsBySeverity.warning) && field?.validationDisplayed - ? 'xh-input-warning' - : null, - infoClass = - !isEmpty(validationsBySeverity.info) && field?.validationDisplayed - ? 'xh-input-info' - : null, + severityToDisplay = field?.validationDisplayed && maxSeverity(field?.validations), disabledClass = props.disabled ? 'xh-input-disabled' : null; return component({ @@ -358,7 +346,8 @@ export function useHoistInputModel( ref: inputModel.domRef, className: classNames( 'xh-input', - validityClass ?? warningClass ?? infoClass, + severityToDisplay && + `xh-input-${severityToDisplay === 'error' ? 'invalid' : severityToDisplay}`, disabledClass, props.className ) diff --git a/data/Store.ts b/data/Store.ts index 32002ca7eb..938deba1d4 100644 --- a/data/Store.ts +++ b/data/Store.ts @@ -6,6 +6,7 @@ */ import {HoistBase, managed, PlainObject, Some, XH} from '@xh/hoist/core'; +import {Validation} from '@xh/hoist/data/validation/Rule'; import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {logWithDebug, throwIf, warnIf} from '@xh/hoist/utils/js'; import equal from 'fast-deep-equal'; @@ -882,7 +883,7 @@ export class Store extends HoistBase { } /** Array of all validations for this store. */ - get allValidations(): string[] { + get allValidations(): Validation[] { return uniq(flatMapDeep(this.validations, values)); } diff --git a/data/impl/RecordValidator.ts b/data/impl/RecordValidator.ts index c349ab924c..bae1659224 100644 --- a/data/impl/RecordValidator.ts +++ b/data/impl/RecordValidator.ts @@ -46,7 +46,7 @@ export class RecordValidator { @computed.struct get errors(): RecordValidationMessagesMap { return mapValues(this.fieldValidations ?? {}, issues => - compact(issues.map(it => (it?.severity === 'error' ? it?.message : null))) + compact(issues.map(it => (it?.severity === 'error' ? it.message : null))) ); } diff --git a/data/validation/Rule.ts b/data/validation/Rule.ts index a2f39c9bf1..a06b900f7b 100644 --- a/data/validation/Rule.ts +++ b/data/validation/Rule.ts @@ -5,7 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import {Awaitable, PlainObject, Some} from '../../core'; -import {castArray} from 'lodash'; +import {castArray, groupBy, isEmpty} from 'lodash'; import {StoreRecord} from '../StoreRecord'; import {BaseFieldModel} from '../../cmp/form'; @@ -22,14 +22,28 @@ export class Rule { } } +/** + * Utility to determine the maximum severity from a list of validations. + * + * @param validations - list of Validation objects + * @returns The highest severity level found, or null if none. + */ +export function maxSeverity(validations: Validation[]): ValidationSeverity { + if (isEmpty(validations)) return null; + const bySeverity = groupBy(validations, 'severity'); + if ('error' in bySeverity) return 'error'; + if ('warning' in bySeverity) return 'warning'; + if ('info' in bySeverity) return 'info'; + return null; +} + /** * Function to validate a value. * * @param fieldState - context w/value for the constraint's target Field. * @param allValues - current values for all fields in form, keyed by field name. - * @returns String or array of strings describing errors, or Validation object or an array of - * Validation objects, or null or undefined if rule passes successfully. May return a Promise - * resolving to same for async validation. + * @returns Validation(s) or string(s) describing errors or null / undefined if rule passes. + * May return a Promise resolving to the same for async validation. */ export type Constraint = ( fieldState: FieldState, diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index 7e4f7bc58c..9518d9000f 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -19,7 +19,7 @@ import { } from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {instanceManager} from '@xh/hoist/core/impl/InstanceManager'; -import {Validation, ValidationSeverity} from '@xh/hoist/data'; +import {maxSeverity, Validation} from '@xh/hoist/data'; import {fmtDate, fmtDateTime, fmtJson, fmtNumber} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {tooltip} from '@xh/hoist/kit/blueprint'; @@ -27,17 +27,7 @@ import {isLocalDate} from '@xh/hoist/utils/datetime'; import {errorIf, getTestId, logWarn, TEST_ID, throwIf, withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps, getReactElementName, useOnMount, useOnUnmount} from '@xh/hoist/utils/react'; import classNames from 'classnames'; -import { - first, - groupBy, - isBoolean, - isDate, - isEmpty, - isFinite, - isNil, - isUndefined, - kebabCase -} from 'lodash'; +import {first, isBoolean, isDate, isEmpty, isFinite, isNil, isUndefined, kebabCase} from 'lodash'; import {Children, cloneElement, ReactElement, ReactNode, useContext, useState} from 'react'; import './FormField.scss'; @@ -133,20 +123,10 @@ export const [FormField, formField] = hoistCmp.withFactory({ const isRequired = model?.isRequired || false, readonly = model?.readonly || false, disabled = props.disabled || model?.disabled, - validationDisplayed = model?.validationDisplayed || false, - notValid = model?.isNotValid || false, - validationsBySeverity = groupBy(model?.validations, 'severity') as Record< - ValidationSeverity, - Validation[] - >, - validWithWarnings = !notValid && !isEmpty(validationsBySeverity.warning), - validWithInfo = !notValid && !validWithWarnings && !isEmpty(validationsBySeverity.info), - displayNotValid = validationDisplayed && notValid, - displayWithWarnings = validationDisplayed && validWithWarnings, - displayWithInfo = validationDisplayed && validWithInfo, - errors = model?.errors || [], - warnings = validationsBySeverity.warning?.map(v => v.message) ?? [], - infos = validationsBySeverity.info?.map(v => v.message) ?? [], + severityToDisplay = model?.validationDisplayed ? maxSeverity(model.validations) : null, + validationsToDisplay = severityToDisplay + ? model.validations.filter(v => v.severity === severityToDisplay) + : [], requiredStr = defaultProp('requiredIndicator', props, formContext, '*'), requiredIndicator = isRequired && !readonly && requiredStr @@ -190,9 +170,10 @@ export const [FormField, formField] = hoistCmp.withFactory({ if (minimal) classes.push('xh-form-field-minimal'); if (readonly) classes.push('xh-form-field-readonly'); if (disabled) classes.push('xh-form-field-disabled'); - if (displayNotValid) classes.push('xh-form-field-invalid'); - if (displayWithWarnings) classes.push('xh-form-field-warning'); - if (displayWithInfo) classes.push('xh-form-field-info'); + if (severityToDisplay) + classes.push( + `xh-form-field-${severityToDisplay === 'error' ? 'invalid' : severityToDisplay}` + ); const testId = getFormFieldTestId(props, formContext, model?.name); useOnMount(() => instanceManager.registerModelWithTestId(testId, model)); @@ -212,9 +193,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ childIsSizeable, childId, disabled, - displayNotValid, - displayWithWarnings, - displayWithInfo, + displayNotValid: severityToDisplay === 'error', leftErrorIcon, commitOnChange, testId: getTestId(testId, 'input') @@ -225,16 +204,15 @@ export const [FormField, formField] = hoistCmp.withFactory({ item: childEl, className: classNames( 'xh-input', - displayNotValid && 'xh-input-invalid', - displayWithWarnings && 'xh-input-warning', - displayWithInfo && 'xh-input-info' + severityToDisplay && + `xh-input-${severityToDisplay === 'error' ? 'invalid' : severityToDisplay}` ), targetTagName: !blockChildren.includes(childElementName) || childWidth ? 'span' : 'div', position: tooltipPosition, boundary: tooltipBoundary, - disabled: !displayNotValid && !displayWithWarnings && !displayWithInfo, - content: getValidationTooltipContent(errors, warnings, infos) + disabled: !severityToDisplay, + content: getValidationTooltipContent(validationsToDisplay) }); } @@ -269,20 +247,12 @@ export const [FormField, formField] = hoistCmp.withFactory({ item: info }), tooltip({ - omit: - minimal || - !(displayNotValid || displayWithWarnings || displayWithInfo), + omit: minimal || !severityToDisplay, openOnTargetFocus: false, - className: classNames( - displayNotValid && 'xh-form-field-error-msg', - displayWithWarnings && 'xh-form-field-warning-msg', - displayWithInfo && 'xh-form-field-info-msg' - ), - item: first(errors) ?? first(warnings) ?? first(infos), + className: `xh-form-field-${severityToDisplay}-msg`, + item: first(validationsToDisplay)?.message, content: getValidationTooltipContent( - errors, - warnings, - infos + validationsToDisplay ) as ReactElement }) ] @@ -399,11 +369,7 @@ function getValidChild(children) { return child; } -function getValidationTooltipContent( - errors: string[], - warnings: string[], - infos: string[] -): ReactElement | string { +function getValidationTooltipContent(validations: Validation[]): ReactElement | string { // If no validations, something other than null must be returned. // If null is returned, as of Blueprint v5, the Blueprint Tooltip component causes deep re-renders of its target // when content changes from null <-> not null. @@ -411,27 +377,15 @@ function getValidationTooltipContent( // TextInput component to lose focus when its validation state changes, which is undesirable. // It is not clear if this is a bug or intended behavior in BP v5, but this workaround prevents the issue. // `Tooltip:content` has been a required prop since at least BP v4, but something about the way it is used in BP v5 changed. - let messages: string[] = [], - className: string; - if (!isEmpty(errors)) { - messages = errors; - className = 'xh-form-field-error-tooltip'; - } else if (!isEmpty(warnings)) { - messages = warnings; - className = 'xh-form-field-warning-tooltip'; - } else if (!isEmpty(infos)) { - messages = infos; - className = 'xh-form-field-info-tooltip'; - } - - if (isEmpty(messages)) { + if (isEmpty(validations)) { return 'Is Valid'; - } else if (messages.length === 1) { - return messages[0]; + } else if (validations.length === 1) { + return first(validations).message; } else { + const severity = first(validations).severity; return ul({ - className, - items: messages.map((it, idx) => li({key: idx, item: it})) + className: `xh-form-field-${severity}-tooltip`, + items: validations.map((it, idx) => li({key: idx, item: it.message})) }); } } diff --git a/mobile/cmp/form/FormField.scss b/mobile/cmp/form/FormField.scss index 37966b79a1..a45ef85f81 100644 --- a/mobile/cmp/form/FormField.scss +++ b/mobile/cmp/form/FormField.scss @@ -50,6 +50,14 @@ color: var(--xh-red); } + &.xh-form-field-warning .xh-form-field-label { + color: var(--xh-orange); + } + + &.xh-form-field-info .xh-form-field-label { + color: var(--xh-blue); + } + &.xh-form-field-readonly { .xh-form-field-error-msg, .xh-form-field-warning-msg, diff --git a/mobile/cmp/form/FormField.ts b/mobile/cmp/form/FormField.ts index 9aaf096c33..923a39dbf7 100644 --- a/mobile/cmp/form/FormField.ts +++ b/mobile/cmp/form/FormField.ts @@ -8,7 +8,7 @@ import composeRefs from '@seznam/compose-react-refs/composeRefs'; import {FieldModel, FormContext, FormContextType, BaseFormFieldProps} from '@xh/hoist/cmp/form'; import {box, div, span} from '@xh/hoist/cmp/layout'; import {DefaultHoistProps, hoistCmp, HoistProps, TestSupportProps, uses, XH} from '@xh/hoist/core'; -import {Validation, ValidationSeverity} from '@xh/hoist/data'; +import {maxSeverity} from '@xh/hoist/data'; import {fmtDate, fmtDateTime, fmtNumber} from '@xh/hoist/format'; import {label as labelCmp} from '@xh/hoist/mobile/cmp/input'; import '@xh/hoist/mobile/register'; @@ -16,7 +16,7 @@ import {isLocalDate} from '@xh/hoist/utils/datetime'; import {errorIf, throwIf, withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps} from '@xh/hoist/utils/react'; import classNames from 'classnames'; -import {first, groupBy, isBoolean, isDate, isEmpty, isFinite, isUndefined} from 'lodash'; +import {first, isBoolean, isDate, isEmpty, isFinite, isUndefined} from 'lodash'; import {Children, cloneElement, ReactNode, useContext} from 'react'; import './FormField.scss'; @@ -66,20 +66,10 @@ export const [FormField, formField] = hoistCmp.withFactory({ const isRequired = model?.isRequired || false, readonly = model?.readonly || false, disabled = props.disabled || model?.disabled, - validationDisplayed = model?.validationDisplayed || false, - notValid = model?.isNotValid || false, - validationsBySeverity = groupBy(model?.validations, 'severity') as Record< - ValidationSeverity, - Validation[] - >, - validWithWarnings = !notValid && !isEmpty(validationsBySeverity.warning), - validWithInfo = !notValid && !validWithWarnings && !isEmpty(validationsBySeverity.info), - displayNotValid = validationDisplayed && notValid, - displayWithWarnings = validationDisplayed && validWithWarnings, - displayWithInfo = validationDisplayed && validWithInfo, - errors = model?.errors || [], - warnings = validationsBySeverity.warning?.map(v => v.message) ?? [], - infos = validationsBySeverity.info?.map(v => v.message) ?? [], + severityToDisplay = model?.validationDisplayed ? maxSeverity(model.validations) : null, + validationsToDisplay = severityToDisplay + ? model.validations.filter(v => v.severity === severityToDisplay) + : [], requiredStr = defaultProp('requiredIndicator', props, formContext, '*'), requiredIndicator = isRequired && !readonly && requiredStr @@ -112,9 +102,10 @@ export const [FormField, formField] = hoistCmp.withFactory({ if (minimal) classes.push('xh-form-field-minimal'); if (readonly) classes.push('xh-form-field-readonly'); if (disabled) classes.push('xh-form-field-disabled'); - if (displayNotValid) classes.push('xh-form-field-invalid'); - if (displayWithWarnings) classes.push('xh-form-field-warning'); - if (displayWithInfo) classes.push('xh-form-field-info'); + if (severityToDisplay) + classes.push( + `xh-form-field-${severityToDisplay === 'error' ? 'invalid' : severityToDisplay}` + ); let childEl = readonly || !child @@ -153,20 +144,14 @@ export const [FormField, formField] = hoistCmp.withFactory({ item: info }), div({ - omit: minimal || !isPending || !validationDisplayed, + omit: minimal || !isPending || !severityToDisplay, className: 'xh-form-field-pending-msg', item: 'Validating...' }), div({ - omit: - minimal || - !(displayNotValid || displayWithWarnings || displayWithInfo), - className: classNames( - displayNotValid && 'xh-form-field-error-msg', - displayWithWarnings && 'xh-form-field-warning-msg', - displayWithInfo && 'xh-form-field-info-msg' - ), - item: first(errors) ?? first(warnings) ?? first(infos) + omit: minimal || !severityToDisplay, + className: `xh-form-field-${severityToDisplay}-msg`, + item: first(validationsToDisplay)?.message }) ] }) From 874c9f6d8bd43894b1acbcab6e48c0cd193892a9 Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Fri, 9 Jan 2026 14:40:56 -0500 Subject: [PATCH 04/13] CR Anselm --- CHANGELOG.md | 5 +- cmp/form/field/BaseFieldModel.ts | 5 +- data/Field.ts | 3 +- data/Store.ts | 12 ++--- data/StoreRecord.ts | 2 +- data/impl/RecordValidator.ts | 16 ++++--- data/impl/StoreValidator.ts | 13 +---- data/index.ts | 1 + data/validation/Rule.ts | 64 +------------------------ data/validation/Types.ts | 81 ++++++++++++++++++++++++++++++++ data/validation/constraints.ts | 3 +- 11 files changed, 111 insertions(+), 94 deletions(-) create mode 100644 data/validation/Types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 315cf41e6d..7b1fbd4fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,8 @@ this release, but is not strictly required. `react-grid-layout` v2+ (not common). * Modified `DashCanvasModel.containerPadding` to apply to the `react-grid-layout` div created by the library, instead of the Hoist-created containing div. This may affect printing layouts. -* Enhanced `Field.rules` to support `warning` and `info` severity. +* Enhanced `Field.rules` to support `warning` and `info` severity. Useful for non-blocking + validation scenarios, such as providing guidance to users without preventing form submission. ### 🎁 New Features @@ -68,7 +69,7 @@ this release, but is not strictly required. ### ⚙️ Typescript API Adjustments -* Removed `RecordErrorMap` type (not expected to impact most applications). +* Removed `RecordErrorMap`/reorganized validation types (not expected to impact most applications). ### 📚 Libraries diff --git a/cmp/form/field/BaseFieldModel.ts b/cmp/form/field/BaseFieldModel.ts index 3f404e24fc..886c78103e 100644 --- a/cmp/form/field/BaseFieldModel.ts +++ b/cmp/form/field/BaseFieldModel.ts @@ -98,9 +98,8 @@ export abstract class BaseFieldModel extends HoistModel { boundInputRef = createObservableRef(); - // An array with the result of evaluating each rule. Each element will be array of strings - // containing any validation errors for the rule. If validation for the rule has not - // completed will contain null + // An array with the result of evaluating each rule. Each element will be an array of Validation + // failure objects for the rule. If validation for the rule has not completed will contain null. @observable private validationResults: Validation[][]; diff --git a/data/Field.ts b/data/Field.ts index 8d4e8f33d2..e9cfad2e7c 100644 --- a/data/Field.ts +++ b/data/Field.ts @@ -6,9 +6,10 @@ */ import {XH} from '@xh/hoist/core'; +import {RuleLike} from '@xh/hoist/data/validation/Types'; import {isLocalDate, LocalDate} from '@xh/hoist/utils/datetime'; import {withDefault} from '@xh/hoist/utils/js'; -import {Rule, RuleLike} from './validation/Rule'; +import {Rule} from './validation/Rule'; import equal from 'fast-deep-equal'; import {isDate, isString, toNumber, isFinite, startCase, isFunction, castArray} from 'lodash'; import DOMPurify from 'dompurify'; diff --git a/data/Store.ts b/data/Store.ts index 938deba1d4..e2ce9e41e2 100644 --- a/data/Store.ts +++ b/data/Store.ts @@ -6,7 +6,11 @@ */ import {HoistBase, managed, PlainObject, Some, XH} from '@xh/hoist/core'; -import {Validation} from '@xh/hoist/data/validation/Rule'; +import { + StoreValidationMessagesMap, + StoreValidationsMap, + Validation +} from '@xh/hoist/data/validation/Types'; import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {logWithDebug, throwIf, warnIf} from '@xh/hoist/utils/js'; import equal from 'fast-deep-equal'; @@ -31,11 +35,7 @@ import { import {Field, FieldSpec} from './Field'; import {parseFilter} from './filter/Utils'; import {RecordSet} from './impl/RecordSet'; -import { - StoreValidationMessagesMap, - StoreValidationsMap, - StoreValidator -} from './impl/StoreValidator'; +import {StoreValidator} from './impl/StoreValidator'; import {StoreRecord, StoreRecordId, StoreRecordOrId} from './StoreRecord'; import {instanceManager} from '../core/impl/InstanceManager'; import {Filter} from './filter/Filter'; diff --git a/data/StoreRecord.ts b/data/StoreRecord.ts index 1df5fd4c8c..1e9e3fd779 100644 --- a/data/StoreRecord.ts +++ b/data/StoreRecord.ts @@ -5,7 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import {PlainObject} from '@xh/hoist/core'; -import {Validation} from '@xh/hoist/data/validation/Rule'; +import {Validation} from '@xh/hoist/data/validation/Types'; import {throwIf} from '@xh/hoist/utils/js'; import {isNil, flatMap, isMatch, isEmpty, pickBy} from 'lodash'; import {Store} from './Store'; diff --git a/data/impl/RecordValidator.ts b/data/impl/RecordValidator.ts index bae1659224..7c5756f5a3 100644 --- a/data/impl/RecordValidator.ts +++ b/data/impl/RecordValidator.ts @@ -4,7 +4,16 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {Field, Rule, StoreRecord, StoreRecordId, Validation, ValidationState} from '@xh/hoist/data'; +import { + Field, + RecordValidationMessagesMap, + RecordValidationsMap, + Rule, + StoreRecord, + StoreRecordId, + Validation, + ValidationState +} from '@xh/hoist/data'; import {computed, observable, makeObservable, runInAction} from '@xh/hoist/mobx'; import {compact, flatten, isEmpty, isString, mapValues, values} from 'lodash'; import {TaskObserver} from '../../core'; @@ -131,8 +140,3 @@ export class RecordValidator { return !when || when(field, record.getValues()); } } - -/** Map of Field names to Field-level Validation lists. */ -export type RecordValidationsMap = Record; -/** Map of Field names to Field-level validation message lists. */ -export type RecordValidationMessagesMap = Record; diff --git a/data/impl/StoreValidator.ts b/data/impl/StoreValidator.ts index 04c3f0d193..7e1ba35237 100644 --- a/data/impl/StoreValidator.ts +++ b/data/impl/StoreValidator.ts @@ -6,15 +6,11 @@ */ import {HoistBase} from '@xh/hoist/core'; +import {StoreValidationMessagesMap, StoreValidationsMap, ValidationState} from '@xh/hoist/data'; import {computed, makeObservable, runInAction, observable} from '@xh/hoist/mobx'; import {sumBy, chunk} from 'lodash'; import {findIn} from '@xh/hoist/utils/js'; -import { - RecordValidationMessagesMap, - RecordValidationsMap, - RecordValidator -} from './RecordValidator'; -import {ValidationState} from '../validation/ValidationState'; +import {RecordValidator} from './RecordValidator'; import {Store} from '../Store'; import {StoreRecordId} from '../StoreRecord'; @@ -171,8 +167,3 @@ export class StoreValidator extends HoistBase { return Array.from(this._validators.values(), fn); } } - -/** Map of StoreRecord IDs to StoreRecord-level messages maps. */ -export type StoreValidationMessagesMap = Record; -/** Map of StoreRecord IDs to StoreRecord-level validations maps. */ -export type StoreValidationsMap = Record; diff --git a/data/index.ts b/data/index.ts index f2c4a8f550..c006d85d16 100644 --- a/data/index.ts +++ b/data/index.ts @@ -41,3 +41,4 @@ export * from './cube/ViewRowData'; export * from './validation/constraints'; export * from './validation/Rule'; export * from './validation/ValidationState'; +export * from './validation/Types'; diff --git a/data/validation/Rule.ts b/data/validation/Rule.ts index a06b900f7b..d508bfb0e3 100644 --- a/data/validation/Rule.ts +++ b/data/validation/Rule.ts @@ -4,10 +4,8 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {Awaitable, PlainObject, Some} from '../../core'; +import {Constraint, RuleSpec, Validation, ValidationSeverity, When} from '@xh/hoist/data'; import {castArray, groupBy, isEmpty} from 'lodash'; -import {StoreRecord} from '../StoreRecord'; -import {BaseFieldModel} from '../../cmp/form'; /** * Immutable object representing a validation rule. @@ -36,63 +34,3 @@ export function maxSeverity(validations: Validation[]): ValidationSeverity { if ('info' in bySeverity) return 'info'; return null; } - -/** - * Function to validate a value. - * - * @param fieldState - context w/value for the constraint's target Field. - * @param allValues - current values for all fields in form, keyed by field name. - * @returns Validation(s) or string(s) describing errors or null / undefined if rule passes. - * May return a Promise resolving to the same for async validation. - */ -export type Constraint = ( - fieldState: FieldState, - allValues: PlainObject -) => Awaitable>; - -/** - * Function to determine when to perform validation on a value. - * - * @param entity - the entity being evaluated. Typically a field for store validation or - * a BaseFieldModel for Form validation. - * @param allValues - current values for all fields in form or record, keyed by field name. - * @returns true if this rule is currently active. - */ -export type When = (entity: any, allValues: PlainObject) => boolean; - -export interface FieldState { - /** Current value of the field */ - value: T; - - /** Name of the field */ - name: string; - - /** Display name of the field */ - displayName: string; - - /** Record being validated, if validating Store data. */ - record?: StoreRecord; - - /** FieldModel being validated, if validating Form data. */ - fieldModel?: BaseFieldModel; -} - -export interface RuleSpec { - /** Function(s) to perform validation. */ - check: Some; - - /** - * Function to determine when this rule is active. - * If not specified rule is considered to be always active. - */ - when?: When; -} - -export type RuleLike = RuleSpec | Constraint | Rule; - -export interface Validation { - severity: ValidationSeverity; - message: string; -} - -export type ValidationSeverity = 'error' | 'warning' | 'info'; diff --git a/data/validation/Types.ts b/data/validation/Types.ts new file mode 100644 index 0000000000..24d2d0241a --- /dev/null +++ b/data/validation/Types.ts @@ -0,0 +1,81 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +import type {BaseFieldModel} from '@xh/hoist/cmp/form'; +import type {Awaitable, PlainObject, Some} from '@xh/hoist/core'; +import type {Rule, StoreRecord, StoreRecordId} from '@xh/hoist/data'; + +/** + * Function to validate a value. + * + * @param fieldState - context w/value for the constraint's target Field. + * @param allValues - current values for all fields in form, keyed by field name. + * @returns Validation(s) or string(s) describing errors or null / undefined if rule passes. + * May return a Promise resolving to the same for async validation. + */ +export type Constraint = ( + fieldState: FieldState, + allValues: PlainObject +) => Awaitable>; + +/** + * Function to determine when to perform validation on a value. + * + * @param entity - the entity being evaluated. Typically a field for store validation or + * a BaseFieldModel for Form validation. + * @param allValues - current values for all fields in form or record, keyed by field name. + * @returns true if this rule is currently active. + */ +export type When = (entity: any, allValues: PlainObject) => boolean; + +export interface FieldState { + /** Current value of the field */ + value: T; + + /** Name of the field */ + name: string; + + /** Display name of the field */ + displayName: string; + + /** Record being validated, if validating Store data. */ + record?: StoreRecord; + + /** FieldModel being validated, if validating Form data. */ + fieldModel?: BaseFieldModel; +} + +export interface RuleSpec { + /** Function(s) to perform validation. */ + check: Some; + + /** + * Function to determine when this rule is active. + * If not specified rule is considered to be always active. + */ + when?: When; +} + +export type RuleLike = RuleSpec | Constraint | Rule; + +export interface Validation { + severity: ValidationSeverity; + message: string; +} + +export type ValidationSeverity = 'error' | 'warning' | 'info'; + +/** Map of StoreRecord IDs to StoreRecord-level messages maps. */ +export type StoreValidationMessagesMap = Record; + +/** Map of StoreRecord IDs to StoreRecord-level validations maps. */ +export type StoreValidationsMap = Record; + +/** Map of Field names to Field-level Validation lists. */ +export type RecordValidationsMap = Record; + +/** Map of Field names to Field-level validation message lists. */ +export type RecordValidationMessagesMap = Record; diff --git a/data/validation/constraints.ts b/data/validation/constraints.ts index c962663fd1..f7ccd98e3a 100644 --- a/data/validation/constraints.ts +++ b/data/validation/constraints.ts @@ -4,11 +4,12 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ +import {Constraint} from '@xh/hoist/data'; import {LocalDate} from '@xh/hoist/utils/datetime'; import {pluralize} from '@xh/hoist/utils/js'; import {isArray, isEmpty, isFinite, isNil, isString, uniq} from 'lodash'; import moment from 'moment'; -import {Constraint} from './Rule'; + /** * A set of validation functions to assist in form field validation. */ From 6912bb8ec37b66ab6a40009f183b2f4e256737cd Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Fri, 9 Jan 2026 16:48:53 -0500 Subject: [PATCH 05/13] Rename fieldErrors -> fieldValidations --- data/impl/RecordValidator.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/data/impl/RecordValidator.ts b/data/impl/RecordValidator.ts index 7c5756f5a3..1d9326da12 100644 --- a/data/impl/RecordValidator.ts +++ b/data/impl/RecordValidator.ts @@ -89,23 +89,23 @@ export class RecordValidator { */ async validateAsync(): Promise { let runId = ++this.validationRunId, - fieldErrors = {}, + fieldValidations = {}, {record} = this, fieldsToValidate = record.store.fields.filter(it => !isEmpty(it.rules)); const promises = fieldsToValidate.flatMap(field => { - fieldErrors[field.name] = []; + fieldValidations[field.name] = []; return field.rules.map(async rule => { const result = await this.evaluateRuleAsync(record, field, rule); - fieldErrors[field.name].push(result); + fieldValidations[field.name].push(result); }); }); await Promise.all(promises).linkTo(this.validationTask); if (runId !== this.validationRunId) return; - fieldErrors = mapValues(fieldErrors, it => compact(flatten(it))); + fieldValidations = mapValues(fieldValidations, it => compact(flatten(it))); - runInAction(() => (this.fieldValidations = fieldErrors)); + runInAction(() => (this.fieldValidations = fieldValidations)); return this.isValid; } From 1b89fbd68c380ef77daa8741184556993bdcf69e Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Fri, 9 Jan 2026 17:14:40 -0500 Subject: [PATCH 06/13] Rename Validation -> ValidationResult --- cmp/form/FormModel.ts | 2 +- cmp/form/field/BaseFieldModel.ts | 35 ++++++++++++++-------------- cmp/form/field/SubformsFieldModel.ts | 8 +++---- cmp/grid/columns/Column.ts | 12 +++++----- cmp/input/HoistInputModel.ts | 2 +- data/Store.ts | 16 ++++++------- data/StoreRecord.ts | 14 +++++------ data/impl/RecordValidator.ts | 18 ++++++++------ data/impl/StoreValidator.ts | 20 +++++++++------- data/validation/Rule.ts | 12 +++++----- data/validation/Types.ts | 12 +++++----- desktop/cmp/form/FormField.ts | 30 +++++++++++++----------- mobile/cmp/form/FormField.ts | 10 ++++---- 13 files changed, 102 insertions(+), 89 deletions(-) diff --git a/cmp/form/FormModel.ts b/cmp/form/FormModel.ts index 3bdac656cd..f1756f66a3 100644 --- a/cmp/form/FormModel.ts +++ b/cmp/form/FormModel.ts @@ -264,7 +264,7 @@ export class FormModel extends HoistModel { return flatMap(this.fields, s => s.allErrors); } - /** Recompute all validations and return true if the form is valid. */ + /** Recompute all ValidationResults and return true if the form is valid. */ async validateAsync(opts?: FormValidateOptions): Promise { const {display = true} = opts ?? {}, promises = map(this.fields, m => m.validateAsync({display})); diff --git a/cmp/form/field/BaseFieldModel.ts b/cmp/form/field/BaseFieldModel.ts index 886c78103e..8cdbe590d5 100644 --- a/cmp/form/field/BaseFieldModel.ts +++ b/cmp/form/field/BaseFieldModel.ts @@ -10,7 +10,7 @@ import { required, Rule, RuleLike, - Validation, + ValidationResult, ValidationState } from '@xh/hoist/data'; import {action, bindable, computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; @@ -98,10 +98,11 @@ export abstract class BaseFieldModel extends HoistModel { boundInputRef = createObservableRef(); - // An array with the result of evaluating each rule. Each element will be an array of Validation - // failure objects for the rule. If validation for the rule has not completed will contain null. + // An array with the result of evaluating each rule. Each element will be an array of + // ValidationResults for the rule. If validation for the rule has not completed will contain + // null. @observable - private validationResults: Validation[][]; + private validationResultsInternal: ValidationResult[][]; @managed private validationTask = TaskObserver.trackLast(); @@ -124,7 +125,7 @@ export abstract class BaseFieldModel extends HoistModel { this._disabled = disabled; this._readonly = readonly; this.rules = this.processRuleSpecs(rules); - this.validationResults = this.rules.map(() => null); + this.validationResultsInternal = this.rules.map(() => null); } //----------------------------- @@ -181,13 +182,13 @@ export abstract class BaseFieldModel extends HoistModel { /** All validation errors for this field. */ @computed get errors(): string[] { - return this.validations.filter(it => it.severity === 'error').map(it => it.message); + return this.validationResults.filter(it => it.severity === 'error').map(it => it.message); } - /** All validations for this field. */ + /** All ValidationResults for this field. */ @computed - get validations(): Validation[] { - return compact(flatten(this.validationResults)); + get validationResults(): ValidationResult[] { + return compact(flatten(this.validationResultsInternal)); } /** All validation errors for this field and its sub-forms. */ @@ -195,9 +196,9 @@ export abstract class BaseFieldModel extends HoistModel { return this.errors; } - /** All validation warnings for this field and its sub-forms. */ - get allValidations(): Validation[] { - return this.validations; + /** All ValidationResults for this field and its sub-forms. */ + get allValidationResults(): ValidationResult[] { + return this.validationResults; } /** @@ -219,7 +220,7 @@ export abstract class BaseFieldModel extends HoistModel { // Force an immediate 'Unknown' state -- the async recompute leaves the old state in place until it completed. // (We want that for a value change, but not reset/init) Force the recompute only if needed. - this.validationResults.fill(null); + this.validationResultsInternal.fill(null); wait().then(() => { if (!this.isValidationPending && this.validationState === 'Unknown') { this.computeValidationAsync(); @@ -317,7 +318,7 @@ export abstract class BaseFieldModel extends HoistModel { } /** - * Recompute all validations and return true if the field is valid. + * Recompute all ValidationResults and return true if the field is valid. * * @param display - true to trigger the display of validation errors (if any) * by the bound FormField component after validation is complete. @@ -356,13 +357,13 @@ export abstract class BaseFieldModel extends HoistModel { const promises = this.rules.map(async (rule, idx) => { const result = await this.evaluateRuleAsync(rule); if (runId === this.validationRunId) { - runInAction(() => (this.validationResults[idx] = result)); + runInAction(() => (this.validationResultsInternal[idx] = result)); } }); await Promise.all(promises); } - private async evaluateRuleAsync(rule: Rule): Promise { + private async evaluateRuleAsync(rule: Rule): Promise { if (this.ruleIsActive(rule)) { const promises = rule.check.map(async constraint => { const {value, name, displayName} = this, @@ -387,7 +388,7 @@ export abstract class BaseFieldModel extends HoistModel { protected deriveValidationState(): ValidationState { if (!isEmpty(this.errors)) return 'NotValid'; - if (this.validationResults.some(e => isNil(e))) return 'Unknown'; + if (this.validationResultsInternal.some(e => isNil(e))) return 'Unknown'; return 'Valid'; } } diff --git a/cmp/form/field/SubformsFieldModel.ts b/cmp/form/field/SubformsFieldModel.ts index 69b56b8240..560de4fb4a 100644 --- a/cmp/form/field/SubformsFieldModel.ts +++ b/cmp/form/field/SubformsFieldModel.ts @@ -5,7 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import {managed, PlainObject, XH} from '@xh/hoist/core'; -import {Validation, ValidationState} from '@xh/hoist/data'; +import {ValidationResult, ValidationState} from '@xh/hoist/data'; import {action, computed, makeObservable, override} from '@xh/hoist/mobx'; import {throwIf} from '@xh/hoist/utils/js'; import {clone, defaults, isEqual, flatMap, isArray, partition, without} from 'lodash'; @@ -47,7 +47,7 @@ export interface SubformAddOptions { * all existing form contents to new values. Call {@link add} or {@link remove} on one of these * fields to adjust the contents of its collection while preserving existing state. * - * Validation rules for the entire collection may be specified as for any field, but validations on + * Validation rules for the entire collection may be specified as for any field, but ValidationResults on * the subforms will also bubble up to this field, affecting its overall validation state. */ export class SubformsFieldModel extends BaseFieldModel { @@ -120,9 +120,9 @@ export class SubformsFieldModel extends BaseFieldModel { } @computed - override get allValidations(): Validation[] { + override get allValidationResults(): ValidationResult[] { const subVals = flatMap(this.value, s => s.allValidations); - return [...this.validations, ...subVals]; + return [...this.validationResults, ...subVals]; } @override diff --git a/cmp/grid/columns/Column.ts b/cmp/grid/columns/Column.ts index 10cc62e939..75b397aa75 100644 --- a/cmp/grid/columns/Column.ts +++ b/cmp/grid/columns/Column.ts @@ -14,7 +14,7 @@ import { RecordAction, RecordActionSpec, StoreRecord, - Validation, + ValidationResult, ValidationSeverity } from '@xh/hoist/data'; import {logDebug, logWarn, throwIf, warnIf, withDefault} from '@xh/hoist/utils/js'; @@ -874,9 +874,9 @@ export class Column { // Override with validation errors, if present -- only show highest-severity level if (editor) { const validationsBySeverity = groupBy( - record.validations[field], + record.validationResults[field], 'severity' - ) as Record, + ) as Record, validationMessages = ( validationsBySeverity.error ?? validationsBySeverity.warning ?? @@ -1020,11 +1020,11 @@ export class Column { ret.cellEditorPopup = this.editorIsPopup; ret.cellClassRules = { 'xh-cell--invalid': agParams => - maxSeverity(agParams.data.validations[field]) === 'error', + maxSeverity(agParams.data.validationResults[field]) === 'error', 'xh-cell--warning': agParams => - maxSeverity(agParams.data.validations[field]) === 'warning', + maxSeverity(agParams.data.validationResults[field]) === 'warning', 'xh-cell--info': agParams => - maxSeverity(agParams.data.validations[field]) === 'info', + maxSeverity(agParams.data.validationResults[field]) === 'info', 'xh-cell--editable': agParams => { return this.isEditableForRecord(agParams.data); }, diff --git a/cmp/input/HoistInputModel.ts b/cmp/input/HoistInputModel.ts index ad0d91cb21..20915caaf9 100644 --- a/cmp/input/HoistInputModel.ts +++ b/cmp/input/HoistInputModel.ts @@ -337,7 +337,7 @@ export function useHoistInputModel( useImperativeHandle(ref, () => inputModel); const field = inputModel.getField(), - severityToDisplay = field?.validationDisplayed && maxSeverity(field?.validations), + severityToDisplay = field?.validationDisplayed && maxSeverity(field?.validationResults), disabledClass = props.disabled ? 'xh-input-disabled' : null; return component({ diff --git a/data/Store.ts b/data/Store.ts index 74ba3beef1..2f91fa9dee 100644 --- a/data/Store.ts +++ b/data/Store.ts @@ -18,8 +18,8 @@ import { StoreRecordId, StoreRecordOrId, StoreValidationMessagesMap, - StoreValidationsMap, - Validation + StoreValidationResultsMap, + ValidationResult } from '@xh/hoist/data'; import {StoreValidator} from '@xh/hoist/data/impl/StoreValidator'; import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; @@ -900,8 +900,8 @@ export class Store extends HoistBase implements FilterBindTarget, FilterValueSou return this.validator.errors; } - get validations(): StoreValidationsMap { - return this.validator.validations; + get validationResults(): StoreValidationResultsMap { + return this.validator.validationResults; } /** Count of all validation errors for the store. */ @@ -914,9 +914,9 @@ export class Store extends HoistBase implements FilterBindTarget, FilterValueSou return uniq(flatMapDeep(this.errors, values)); } - /** Array of all validations for this store. */ - get allValidations(): Validation[] { - return uniq(flatMapDeep(this.validations, values)); + /** Array of all ValidationResults for this store. */ + get allValidationResults(): ValidationResult[] { + return uniq(flatMapDeep(this.validationResults, values)); } /** @@ -991,7 +991,7 @@ export class Store extends HoistBase implements FilterBindTarget, FilterValueSou return this.validator.isNotValid; } - /** Recompute validations for all records and return true if the store is valid. */ + /** Recompute ValidationResults for all records and return true if the store is valid. */ async validateAsync(): Promise { return this.validator.validateAsync(); } diff --git a/data/StoreRecord.ts b/data/StoreRecord.ts index 1e9e3fd779..a8838a145c 100644 --- a/data/StoreRecord.ts +++ b/data/StoreRecord.ts @@ -5,7 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import {PlainObject} from '@xh/hoist/core'; -import {Validation} from '@xh/hoist/data/validation/Types'; +import {ValidationResult} from '@xh/hoist/data/validation/Types'; import {throwIf} from '@xh/hoist/utils/js'; import {isNil, flatMap, isMatch, isEmpty, pickBy} from 'lodash'; import {Store} from './Store'; @@ -154,9 +154,9 @@ export class StoreRecord { return this.validator?.errors ?? {}; } - /** Map of field names to list of validations. */ - get validations(): Record { - return this.validator?.validations ?? {}; + /** Map of field names to list of ValidationResults. */ + get validationResults(): Record { + return this.validator?.validationResults ?? {}; } /** Array of all errors for this record. */ @@ -164,9 +164,9 @@ export class StoreRecord { return flatMap(this.errors); } - /** Array of all validations for this record. */ - get allValidations(): Validation[] { - return flatMap(this.validations); + /** Array of all ValidationResults for this record. */ + get allValidationResults(): ValidationResult[] { + return flatMap(this.validationResults); } /** Count of all validation errors for the record. */ diff --git a/data/impl/RecordValidator.ts b/data/impl/RecordValidator.ts index 1d9326da12..e570938c7d 100644 --- a/data/impl/RecordValidator.ts +++ b/data/impl/RecordValidator.ts @@ -7,11 +7,11 @@ import { Field, RecordValidationMessagesMap, - RecordValidationsMap, + RecordValidationResultsMap, Rule, StoreRecord, StoreRecordId, - Validation, + ValidationResult, ValidationState } from '@xh/hoist/data'; import {computed, observable, makeObservable, runInAction} from '@xh/hoist/mobx'; @@ -25,7 +25,7 @@ import {TaskObserver} from '../../core'; export class RecordValidator { record: StoreRecord; - @observable.ref private fieldValidations: RecordValidationsMap = null; + @observable.ref private fieldValidations: RecordValidationResultsMap = null; private validationTask = TaskObserver.trackLast(); private validationRunId = 0; @@ -59,9 +59,9 @@ export class RecordValidator { ); } - /** Map of field names to field-level validations. */ + /** Map of field names to field-level ValidationResults. */ @computed.struct - get validations(): RecordValidationsMap { + get validationResults(): RecordValidationResultsMap { return this.fieldValidations ?? {}; } @@ -85,7 +85,7 @@ export class RecordValidator { } /** - * Recompute validations for the record and return true if valid. + * Recompute ValidationResults for the record and return true if valid. */ async validateAsync(): Promise { let runId = ++this.validationRunId, @@ -117,7 +117,11 @@ export class RecordValidator { return 'Valid'; } - async evaluateRuleAsync(record: StoreRecord, field: Field, rule: Rule): Promise { + async evaluateRuleAsync( + record: StoreRecord, + field: Field, + rule: Rule + ): Promise { const values = record.getValues(), {name, displayName} = field, value = record.get(name); diff --git a/data/impl/StoreValidator.ts b/data/impl/StoreValidator.ts index 7e1ba35237..c983eaa5f3 100644 --- a/data/impl/StoreValidator.ts +++ b/data/impl/StoreValidator.ts @@ -6,7 +6,11 @@ */ import {HoistBase} from '@xh/hoist/core'; -import {StoreValidationMessagesMap, StoreValidationsMap, ValidationState} from '@xh/hoist/data'; +import { + StoreValidationMessagesMap, + StoreValidationResultsMap, + ValidationState +} from '@xh/hoist/data'; import {computed, makeObservable, runInAction, observable} from '@xh/hoist/mobx'; import {sumBy, chunk} from 'lodash'; import {findIn} from '@xh/hoist/utils/js'; @@ -51,10 +55,10 @@ export class StoreValidator extends HoistBase { return sumBy(this.validators, 'errorCount'); } - /** Map of StoreRecord IDs to StoreRecord-level validations maps. */ + /** Map of StoreRecord IDs to StoreRecord-level ValidationResults maps. */ @computed.struct - get validations(): StoreValidationsMap { - return this.getValidationsMap(); + get validationResults(): StoreValidationResultsMap { + return this.getValidationResultsMap(); } /** True if any records are currently recomputing their validation state. */ @@ -82,7 +86,7 @@ export class StoreValidator extends HoistBase { } /** - * Recompute validations for the store and return true if valid. + * Recompute ValidationResults for the store and return true if valid. */ async validateAsync(): Promise { await this.validateInChunksAsync(this.validators); @@ -104,9 +108,9 @@ export class StoreValidator extends HoistBase { return ret; } - getValidationsMap(): StoreValidationsMap { - const ret: StoreValidationsMap = {}; - this._validators.forEach(v => (ret[v.id] = v.validations)); + getValidationResultsMap(): StoreValidationResultsMap { + const ret: StoreValidationResultsMap = {}; + this._validators.forEach(v => (ret[v.id] = v.validationResults)); return ret; } diff --git a/data/validation/Rule.ts b/data/validation/Rule.ts index d508bfb0e3..19c807637f 100644 --- a/data/validation/Rule.ts +++ b/data/validation/Rule.ts @@ -4,7 +4,7 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {Constraint, RuleSpec, Validation, ValidationSeverity, When} from '@xh/hoist/data'; +import {Constraint, RuleSpec, ValidationResult, ValidationSeverity, When} from '@xh/hoist/data'; import {castArray, groupBy, isEmpty} from 'lodash'; /** @@ -21,14 +21,14 @@ export class Rule { } /** - * Utility to determine the maximum severity from a list of validations. + * Utility to determine the maximum severity from a list of ValidationResults. * - * @param validations - list of Validation objects + * @param validationResults - list of ValidationResults to evaluate. * @returns The highest severity level found, or null if none. */ -export function maxSeverity(validations: Validation[]): ValidationSeverity { - if (isEmpty(validations)) return null; - const bySeverity = groupBy(validations, 'severity'); +export function maxSeverity(validationResults: ValidationResult[]): ValidationSeverity { + if (isEmpty(validationResults)) return null; + const bySeverity = groupBy(validationResults, 'severity'); if ('error' in bySeverity) return 'error'; if ('warning' in bySeverity) return 'warning'; if ('info' in bySeverity) return 'info'; diff --git a/data/validation/Types.ts b/data/validation/Types.ts index 24d2d0241a..542b3643e5 100644 --- a/data/validation/Types.ts +++ b/data/validation/Types.ts @@ -13,13 +13,13 @@ import type {Rule, StoreRecord, StoreRecordId} from '@xh/hoist/data'; * * @param fieldState - context w/value for the constraint's target Field. * @param allValues - current values for all fields in form, keyed by field name. - * @returns Validation(s) or string(s) describing errors or null / undefined if rule passes. + * @returns ValidationResult(s) or string(s) describing errors or null / undefined if rule passes. * May return a Promise resolving to the same for async validation. */ export type Constraint = ( fieldState: FieldState, allValues: PlainObject -) => Awaitable>; +) => Awaitable>; /** * Function to determine when to perform validation on a value. @@ -61,7 +61,7 @@ export interface RuleSpec { export type RuleLike = RuleSpec | Constraint | Rule; -export interface Validation { +export interface ValidationResult { severity: ValidationSeverity; message: string; } @@ -71,11 +71,11 @@ export type ValidationSeverity = 'error' | 'warning' | 'info'; /** Map of StoreRecord IDs to StoreRecord-level messages maps. */ export type StoreValidationMessagesMap = Record; -/** Map of StoreRecord IDs to StoreRecord-level validations maps. */ -export type StoreValidationsMap = Record; +/** Map of StoreRecord IDs to StoreRecord-level ValidationResults maps. */ +export type StoreValidationResultsMap = Record; /** Map of Field names to Field-level Validation lists. */ -export type RecordValidationsMap = Record; +export type RecordValidationResultsMap = Record; /** Map of Field names to Field-level validation message lists. */ export type RecordValidationMessagesMap = Record; diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index 9518d9000f..ba78e6a6e9 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -19,7 +19,7 @@ import { } from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {instanceManager} from '@xh/hoist/core/impl/InstanceManager'; -import {maxSeverity, Validation} from '@xh/hoist/data'; +import {maxSeverity, ValidationResult} from '@xh/hoist/data'; import {fmtDate, fmtDateTime, fmtJson, fmtNumber} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {tooltip} from '@xh/hoist/kit/blueprint'; @@ -123,9 +123,11 @@ export const [FormField, formField] = hoistCmp.withFactory({ const isRequired = model?.isRequired || false, readonly = model?.readonly || false, disabled = props.disabled || model?.disabled, - severityToDisplay = model?.validationDisplayed ? maxSeverity(model.validations) : null, - validationsToDisplay = severityToDisplay - ? model.validations.filter(v => v.severity === severityToDisplay) + severityToDisplay = model?.validationDisplayed + ? maxSeverity(model.validationResults) + : null, + validationResultsToDisplay = severityToDisplay + ? model.validationResults.filter(v => v.severity === severityToDisplay) : [], requiredStr = defaultProp('requiredIndicator', props, formContext, '*'), requiredIndicator = @@ -212,7 +214,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ position: tooltipPosition, boundary: tooltipBoundary, disabled: !severityToDisplay, - content: getValidationTooltipContent(validationsToDisplay) + content: getValidationTooltipContent(validationResultsToDisplay) }); } @@ -250,9 +252,9 @@ export const [FormField, formField] = hoistCmp.withFactory({ omit: minimal || !severityToDisplay, openOnTargetFocus: false, className: `xh-form-field-${severityToDisplay}-msg`, - item: first(validationsToDisplay)?.message, + item: first(validationResultsToDisplay)?.message, content: getValidationTooltipContent( - validationsToDisplay + validationResultsToDisplay ) as ReactElement }) ] @@ -369,23 +371,23 @@ function getValidChild(children) { return child; } -function getValidationTooltipContent(validations: Validation[]): ReactElement | string { - // If no validations, something other than null must be returned. +function getValidationTooltipContent(validationResults: ValidationResult[]): ReactElement | string { + // If no ValidationResults, something other than null must be returned. // If null is returned, as of Blueprint v5, the Blueprint Tooltip component causes deep re-renders of its target // when content changes from null <-> not null. // In `formField` `minimal:true` mode with `commitonchange:true`, this causes the // TextInput component to lose focus when its validation state changes, which is undesirable. // It is not clear if this is a bug or intended behavior in BP v5, but this workaround prevents the issue. // `Tooltip:content` has been a required prop since at least BP v4, but something about the way it is used in BP v5 changed. - if (isEmpty(validations)) { + if (isEmpty(validationResults)) { return 'Is Valid'; - } else if (validations.length === 1) { - return first(validations).message; + } else if (validationResults.length === 1) { + return first(validationResults).message; } else { - const severity = first(validations).severity; + const severity = first(validationResults).severity; return ul({ className: `xh-form-field-${severity}-tooltip`, - items: validations.map((it, idx) => li({key: idx, item: it.message})) + items: validationResults.map((it, idx) => li({key: idx, item: it.message})) }); } } diff --git a/mobile/cmp/form/FormField.ts b/mobile/cmp/form/FormField.ts index 923a39dbf7..f70ef3532b 100644 --- a/mobile/cmp/form/FormField.ts +++ b/mobile/cmp/form/FormField.ts @@ -66,9 +66,11 @@ export const [FormField, formField] = hoistCmp.withFactory({ const isRequired = model?.isRequired || false, readonly = model?.readonly || false, disabled = props.disabled || model?.disabled, - severityToDisplay = model?.validationDisplayed ? maxSeverity(model.validations) : null, - validationsToDisplay = severityToDisplay - ? model.validations.filter(v => v.severity === severityToDisplay) + severityToDisplay = model?.validationDisplayed + ? maxSeverity(model.validationResults) + : null, + validationResultsToDisplay = severityToDisplay + ? model.validationResults.filter(v => v.severity === severityToDisplay) : [], requiredStr = defaultProp('requiredIndicator', props, formContext, '*'), requiredIndicator = @@ -151,7 +153,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ div({ omit: minimal || !severityToDisplay, className: `xh-form-field-${severityToDisplay}-msg`, - item: first(validationsToDisplay)?.message + item: first(validationResultsToDisplay)?.message }) ] }) From 8ba63eb3252f42ae75df7c509faaa1d8c0c73733 Mon Sep 17 00:00:00 2001 From: Lee Wexler Date: Mon, 12 Jan 2026 12:33:19 -0500 Subject: [PATCH 07/13] Tweak (#4199) --- cmp/form/field/BaseFieldModel.ts | 4 +++- cmp/form/field/SubformsFieldModel.ts | 6 ------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/cmp/form/field/BaseFieldModel.ts b/cmp/form/field/BaseFieldModel.ts index 8cdbe590d5..67220a7fb4 100644 --- a/cmp/form/field/BaseFieldModel.ts +++ b/cmp/form/field/BaseFieldModel.ts @@ -193,7 +193,9 @@ export abstract class BaseFieldModel extends HoistModel { /** All validation errors for this field and its sub-forms. */ get allErrors(): string[] { - return this.errors; + return this.allValidationResults + .filter(it => it.severity === 'error') + .map(it => it.message); } /** All ValidationResults for this field and its sub-forms. */ diff --git a/cmp/form/field/SubformsFieldModel.ts b/cmp/form/field/SubformsFieldModel.ts index 560de4fb4a..53c23d7c17 100644 --- a/cmp/form/field/SubformsFieldModel.ts +++ b/cmp/form/field/SubformsFieldModel.ts @@ -113,12 +113,6 @@ export class SubformsFieldModel extends BaseFieldModel { }); } - @computed - override get allErrors(): string[] { - const subErrs = flatMap(this.value, s => s.allErrors); - return [...this.errors, ...subErrs]; - } - @computed override get allValidationResults(): ValidationResult[] { const subVals = flatMap(this.value, s => s.allValidations); From dafc2fa76264793b9f163a94f63ef90d6e64b172 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Mon, 12 Jan 2026 12:17:08 -0800 Subject: [PATCH 08/13] Use BEM more consistently for new validation modifiers (#4200) - Use `--` modifier when appending to validation-related classes. - Remove some remaining direct uses of the intents or colors in favor of the new per-level CSS vars. - Define additional base classes for validation messages. - Fixup changelog to note updates under correct version. --- CHANGELOG.md | 26 ++++++++++++------ cmp/input/HoistInput.scss | 6 ++-- cmp/input/HoistInputModel.ts | 5 ++-- desktop/cmp/form/FormField.scss | 44 ++++++++++++++---------------- desktop/cmp/form/FormField.ts | 19 +++++++------ desktop/cmp/input/CodeInput.scss | 6 ++-- desktop/cmp/input/RadioInput.scss | 12 ++++---- desktop/cmp/input/SwitchInput.scss | 36 ++++++++++++------------ desktop/cmp/input/TextArea.scss | 6 ++-- kit/onsen/styles.scss | 8 +++--- mobile/cmp/form/FormField.scss | 39 ++++++++++++-------------- mobile/cmp/form/FormField.ts | 13 +++++---- styles/vars.scss | 36 +++++++++++++----------- 13 files changed, 133 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5258b43fac..b4dccdf536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,14 @@ so there is no deprecated alias. Any app usages should swap to `XH.appLoadObserver`. * Removed additional references to deprecated `loadModel` within Hoist itself. * Removed the following instance getters - use new static typeguards instead: - * `Store.isStore` - * `View.isView` - * `Filter.isFilter` + * `Store.isStore` + * `View.isView` + * `Filter.isFilter` + +### 🎁 New Features + +* Enhanced `Field.rules` to support `warning` and `info` severity. Useful for non-blocking + validation scenarios, such as providing guidance to users without preventing form submission. ### ⚙️ Typescript API Adjustments @@ -22,6 +27,15 @@ `View` implement these interfaces, meaning no changes are required for apps, but it is now possible to use these models with other, alternate implementations if needed. * Added new static typeguard methods on `Store`, `View`, and `Filter` + subclasses. +* Removed `RecordErrorMap` + reorganized validation types (not expected to impact most apps). + +### ✨ Styles + +* Updated + added validation-related input and form-field CSS classes and variables to account for + new `info` and `warning` validation levels. + * ⚠️Pre-existing `xh-input-invalid` and `xh-form-field-invalid` CSS classes have been updated to + better follow BEM conventions with `--` modifier. They are now `xh-input--invalid` and + `xh-form-field--invalid`. ## 79.0.0 - 2026-01-05 @@ -54,8 +68,6 @@ this release, but is not strictly required. `react-grid-layout` v2+ (not common). * Modified `DashCanvasModel.containerPadding` to apply to the `react-grid-layout` div created by the library, instead of the Hoist-created containing div. This may affect printing layouts. -* Enhanced `Field.rules` to support `warning` and `info` severity. Useful for non-blocking - validation scenarios, such as providing guidance to users without preventing form submission. ### 🎁 New Features @@ -90,10 +102,6 @@ this release, but is not strictly required. * Introduced opt-in `Grid` performance optimizations on an experimental basis with `GridExperimentalFlags.deltaSort` and `GridExperimentalFlags.disableScrollOptimization` -### ⚙️ Typescript API Adjustments - -* Removed `RecordErrorMap`/reorganized validation types (not expected to impact most applications). - ### 📚 Libraries * @blueprintjs/core: `5.10 → 6.3` diff --git a/cmp/input/HoistInput.scss b/cmp/input/HoistInput.scss index 053cecd4cc..1a043efd67 100644 --- a/cmp/input/HoistInput.scss +++ b/cmp/input/HoistInput.scss @@ -6,19 +6,19 @@ */ .xh-input { - &.xh-input-invalid { + &.xh-input--invalid { input { border: var(--xh-form-field-invalid-border); } } - &.xh-input-warning { + &.xh-input--warning { input { border: var(--xh-form-field-warning-border); } } - &.xh-input-info { + &.xh-input--info { input { border: var(--xh-form-field-info-border); } diff --git a/cmp/input/HoistInputModel.ts b/cmp/input/HoistInputModel.ts index 20915caaf9..0733467a39 100644 --- a/cmp/input/HoistInputModel.ts +++ b/cmp/input/HoistInputModel.ts @@ -338,6 +338,7 @@ export function useHoistInputModel( const field = inputModel.getField(), severityToDisplay = field?.validationDisplayed && maxSeverity(field?.validationResults), + displayInvalid = severityToDisplay === 'error', disabledClass = props.disabled ? 'xh-input-disabled' : null; return component({ @@ -346,8 +347,8 @@ export function useHoistInputModel( ref: inputModel.domRef, className: classNames( 'xh-input', - severityToDisplay && - `xh-input-${severityToDisplay === 'error' ? 'invalid' : severityToDisplay}`, + severityToDisplay && `xh-input--${severityToDisplay}`, + displayInvalid && 'xh-input--invalid', disabledClass, props.className ) diff --git a/desktop/cmp/form/FormField.scss b/desktop/cmp/form/FormField.scss index 3327349d27..51e18fec60 100644 --- a/desktop/cmp/form/FormField.scss +++ b/desktop/cmp/form/FormField.scss @@ -48,9 +48,7 @@ } .xh-form-field-info, - .xh-form-field-error-msg, - .xh-form-field-warning-msg, - .xh-form-field-info-msg { + &__validation-msg { font-size: var(--xh-font-size-small-px); line-height: calc(var(--xh-font-size-small-px) + var(--xh-pad-px)); white-space: nowrap; @@ -58,16 +56,18 @@ overflow: hidden; } - .xh-form-field-error-msg { - color: var(--xh-red); - } + &__validation-msg { + &--error { + color: var(--xh-form-field-invalid-color); + } - .xh-form-field-warning-msg { - color: var(--xh-orange); - } + &--info { + color: var(--xh-form-field-info-color); + } - .xh-form-field-info-msg { - color: var(--xh-blue); + &--warning { + color: var(--xh-form-field-warning-color); + } } &.xh-form-field-inline { @@ -95,9 +95,7 @@ } } - .xh-form-field-error-msg, - .xh-form-field-warning-msg, - .xh-form-field-info-msg { + .xh-form-field__validation-msg { display: none; } @@ -113,7 +111,7 @@ } } - &.xh-form-field-invalid:not(.xh-form-field-readonly) { + &.xh-form-field--invalid:not(.xh-form-field-readonly) { .xh-check-box span { box-shadow: var(--xh-form-field-invalid-box-shadow) !important; } @@ -131,7 +129,7 @@ } .xh-text-input > svg { - color: var(--xh-intent-danger); + color: var(--xh-form-field-invalid-color); } .xh-text-area.textarea { @@ -139,7 +137,7 @@ } } - &.xh-form-field-warning:not(.xh-form-field-readonly) { + &.xh-form-field--warning:not(.xh-form-field-readonly) { .xh-check-box span { box-shadow: var(--xh-form-field-warning-box-shadow) !important; } @@ -157,7 +155,7 @@ } .xh-text-input > svg { - color: var(--xh-intent-warning); + color: var(--xh-form-field-warning-color); } .xh-text-area.textarea { @@ -165,7 +163,7 @@ } } - &.xh-form-field-info:not(.xh-form-field-readonly) { + &.xh-form-field--info:not(.xh-form-field-readonly) { .xh-check-box span { box-shadow: var(--xh-form-field-info-box-shadow) !important; } @@ -183,7 +181,7 @@ } .xh-text-input > svg { - color: var(--xh-intent-primary); + color: var(--xh-form-field-info-color); } .xh-text-area.textarea { @@ -192,7 +190,7 @@ } } -ul.xh-form-field-error-tooltip { +ul.xh-form-field__validation-tooltip { margin: 0; padding: 0 1em 0 2em; } @@ -210,9 +208,7 @@ ul.xh-form-field-error-tooltip { align-items: center; } - .xh-form-field-error-msg, - .xh-form-field-warning-msg, - .xh-form-field-info-msg { + .xh-form-field__validation-msg { margin: 0 var(--xh-pad-px); } } diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index ba78e6a6e9..a837b23d55 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -126,6 +126,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ severityToDisplay = model?.validationDisplayed ? maxSeverity(model.validationResults) : null, + displayInvalid = severityToDisplay === 'error', validationResultsToDisplay = severityToDisplay ? model.validationResults.filter(v => v.severity === severityToDisplay) : [], @@ -172,10 +173,11 @@ export const [FormField, formField] = hoistCmp.withFactory({ if (minimal) classes.push('xh-form-field-minimal'); if (readonly) classes.push('xh-form-field-readonly'); if (disabled) classes.push('xh-form-field-disabled'); - if (severityToDisplay) - classes.push( - `xh-form-field-${severityToDisplay === 'error' ? 'invalid' : severityToDisplay}` - ); + + if (severityToDisplay) { + classes.push(`xh-form-field--${severityToDisplay}`); + if (displayInvalid) classes.push('xh-form-field--invalid'); + } const testId = getFormFieldTestId(props, formContext, model?.name); useOnMount(() => instanceManager.registerModelWithTestId(testId, model)); @@ -206,8 +208,8 @@ export const [FormField, formField] = hoistCmp.withFactory({ item: childEl, className: classNames( 'xh-input', - severityToDisplay && - `xh-input-${severityToDisplay === 'error' ? 'invalid' : severityToDisplay}` + severityToDisplay && `xh-input--${severityToDisplay}`, + displayInvalid && 'xh-input--invalid' ), targetTagName: !blockChildren.includes(childElementName) || childWidth ? 'span' : 'div', @@ -251,7 +253,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ tooltip({ omit: minimal || !severityToDisplay, openOnTargetFocus: false, - className: `xh-form-field-${severityToDisplay}-msg`, + className: `xh-form-field__validation-msg xh-form-field__validation-msg--${severityToDisplay}`, item: first(validationResultsToDisplay)?.message, content: getValidationTooltipContent( validationResultsToDisplay @@ -335,7 +337,6 @@ const editableChild = hoistCmp.factory({ //-------------------------------- // Helper Functions //--------------------------------- - export function defaultReadonlyRenderer(value: any): ReactNode { if (isLocalDate(value)) return fmtDate(value); if (isDate(value)) return fmtDateTime(value); @@ -386,7 +387,7 @@ function getValidationTooltipContent(validationResults: ValidationResult[]): Rea } else { const severity = first(validationResults).severity; return ul({ - className: `xh-form-field-${severity}-tooltip`, + className: `xh-form-field__validation-tooltip xh-form-field__validation-tooltip--${severity}`, items: validationResults.map((it, idx) => li({key: idx, item: it.message})) }); } diff --git a/desktop/cmp/input/CodeInput.scss b/desktop/cmp/input/CodeInput.scss index e0f953bacc..60ba548413 100644 --- a/desktop/cmp/input/CodeInput.scss +++ b/desktop/cmp/input/CodeInput.scss @@ -29,19 +29,19 @@ .xh-code-input { height: 100px; - &.xh-input-invalid { + &.xh-input--invalid { div.CodeMirror { border: var(--xh-form-field-invalid-border); } } - &.xh-input-warning { + &.xh-input--warning { div.CodeMirror { border: var(--xh-form-field-warning-border); } } - &.xh-input-info { + &.xh-input--info { div.CodeMirror { border: var(--xh-form-field-info-border); } diff --git a/desktop/cmp/input/RadioInput.scss b/desktop/cmp/input/RadioInput.scss index 17c98f8275..3e16d9ea81 100644 --- a/desktop/cmp/input/RadioInput.scss +++ b/desktop/cmp/input/RadioInput.scss @@ -10,23 +10,23 @@ margin-right: var(--xh-pad-double-px); } - &.xh-input-invalid, - &.xh-input-warning, - &.xh-input-info { + &.xh-input--invalid, + &.xh-input--warning, + &.xh-input--info { .xh-radio-input-option .bp6-control-indicator::before { margin: -1px; } } - &.xh-input-invalid .xh-radio-input-option .bp6-control-indicator { + &.xh-input--invalid .xh-radio-input-option .bp6-control-indicator { border: var(--xh-form-field-invalid-border); } - &.xh-input-warning .xh-radio-input-option .bp6-control-indicator { + &.xh-input--warning .xh-radio-input-option .bp6-control-indicator { border: var(--xh-form-field-warning-border); } - &.xh-input-info .xh-radio-input-option .bp6-control-indicator { + &.xh-input--info .xh-radio-input-option .bp6-control-indicator { border: var(--xh-form-field-info-border); } } diff --git a/desktop/cmp/input/SwitchInput.scss b/desktop/cmp/input/SwitchInput.scss index 1a1f106a11..e59bc23707 100644 --- a/desktop/cmp/input/SwitchInput.scss +++ b/desktop/cmp/input/SwitchInput.scss @@ -5,28 +5,30 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ -.xh-switch-input.xh-input-invalid, -.xh-switch-input.xh-input-warning, -.xh-switch-input.xh-input-info { - .bp6-control-indicator::before { - margin: 1px; +.xh-switch-input { + &.xh-input--invalid, + &.xh-input--warning, + &.xh-input--info { + .bp6-control-indicator::before { + margin: 1px; + } } -} -.xh-switch-input.xh-input-invalid { - .bp6-control-indicator { - border: var(--xh-form-field-invalid-border); + &.xh-input--invalid { + .bp6-control-indicator { + border: var(--xh-form-field-invalid-border); + } } -} -.xh-switch-input.xh-input-warning { - .bp6-control-indicator { - border: var(--xh-form-field-warning-border); + &.xh-input--warning { + .bp6-control-indicator { + border: var(--xh-form-field-warning-border); + } } -} -.xh-switch-input.xh-input-info { - .bp6-control-indicator { - border: var(--xh-form-field-info-border); + &.xh-input--info { + .bp6-control-indicator { + border: var(--xh-form-field-info-border); + } } } diff --git a/desktop/cmp/input/TextArea.scss b/desktop/cmp/input/TextArea.scss index 8dd2de38bd..099328126c 100644 --- a/desktop/cmp/input/TextArea.scss +++ b/desktop/cmp/input/TextArea.scss @@ -12,15 +12,15 @@ // Suppress resize handles added by browser - we want to manage the size more closely. resize: none; - &.xh-input-invalid { + &.xh-input--invalid { border: var(--xh-form-field-invalid-border); } - &.xh-input-warning { + &.xh-input--warning { border: var(--xh-form-field-warning-border); } - &.xh-input-info { + &.xh-input--info { border: var(--xh-form-field-info-border); } } diff --git a/kit/onsen/styles.scss b/kit/onsen/styles.scss index a72dd39abf..b831208f54 100644 --- a/kit/onsen/styles.scss +++ b/kit/onsen/styles.scss @@ -24,19 +24,19 @@ border: var(--xh-border-solid); border-radius: var(--xh-border-radius-px); - &:not(.xh-input-invalid):not(.xh-input-warning):not(.xh-input-info):focus-within { + &:not(.xh-input--invalid):not(.xh-input--warning):not(.xh-input--info):focus-within { border-color: var(--xh-focus-outline-color); } - &.xh-input-invalid { + &.xh-input--invalid { border: var(--xh-form-field-invalid-border); } - &.xh-input-warning { + &.xh-input--warning { border: var(--xh-form-field-warning-border); } - &.xh-input-info { + &.xh-input--info { border: var(--xh-form-field-info-border); } } diff --git a/mobile/cmp/form/FormField.scss b/mobile/cmp/form/FormField.scss index a45ef85f81..852c2e060d 100644 --- a/mobile/cmp/form/FormField.scss +++ b/mobile/cmp/form/FormField.scss @@ -22,10 +22,7 @@ } .xh-form-field-info, - .xh-form-field-error-msg, - .xh-form-field-pending-msg, - .xh-form-field-warning-msg, - .xh-form-field-info-msg { + &__validation-msg { font-size: var(--xh-font-size-small-px); line-height: calc(var(--xh-font-size-small-px) + var(--xh-pad-px)); color: var(--xh-text-color-muted); @@ -34,34 +31,34 @@ overflow: hidden; } - .xh-form-field-error-msg { - color: var(--xh-red); - } + &__validation-msg { + &--error { + color: var(--xh-form-field-invalid-color); + } - .xh-form-field-warning-msg { - color: var(--xh-orange); - } + &--info { + color: var(--xh-form-field-info-color); + } - .xh-form-field-info-msg { - color: var(--xh-blue); + &--warning { + color: var(--xh-form-field-warning-color); + } } - &.xh-form-field-invalid .xh-form-field-label { - color: var(--xh-red); + &--invalid .xh-form-field-label { + color: var(--xh-form-field-invalid-color); } - &.xh-form-field-warning .xh-form-field-label { - color: var(--xh-orange); + &--warning .xh-form-field-label { + color: var(--xh-form-field-warning-color); } - &.xh-form-field-info .xh-form-field-label { - color: var(--xh-blue); + &--info .xh-form-field-label { + color: var(--xh-form-field-info-color); } &.xh-form-field-readonly { - .xh-form-field-error-msg, - .xh-form-field-warning-msg, - .xh-form-field-info-msg { + .xh-form-field__validation-msg { display: none; } diff --git a/mobile/cmp/form/FormField.ts b/mobile/cmp/form/FormField.ts index f70ef3532b..e0260fed08 100644 --- a/mobile/cmp/form/FormField.ts +++ b/mobile/cmp/form/FormField.ts @@ -69,6 +69,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ severityToDisplay = model?.validationDisplayed ? maxSeverity(model.validationResults) : null, + displayInvalid = severityToDisplay === 'error', validationResultsToDisplay = severityToDisplay ? model.validationResults.filter(v => v.severity === severityToDisplay) : [], @@ -104,10 +105,10 @@ export const [FormField, formField] = hoistCmp.withFactory({ if (minimal) classes.push('xh-form-field-minimal'); if (readonly) classes.push('xh-form-field-readonly'); if (disabled) classes.push('xh-form-field-disabled'); - if (severityToDisplay) - classes.push( - `xh-form-field-${severityToDisplay === 'error' ? 'invalid' : severityToDisplay}` - ); + if (severityToDisplay) { + classes.push(`xh-form-field--${severityToDisplay}`); + if (displayInvalid) classes.push('xh-form-field--invalid'); + } let childEl = readonly || !child @@ -147,12 +148,12 @@ export const [FormField, formField] = hoistCmp.withFactory({ }), div({ omit: minimal || !isPending || !severityToDisplay, - className: 'xh-form-field-pending-msg', + className: `xh-form-field__validation-msg xh-form-field__validation-msg--pending`, item: 'Validating...' }), div({ omit: minimal || !severityToDisplay, - className: `xh-form-field-${severityToDisplay}-msg`, + className: `xh-form-field__validation-msg xh-form-field__validation-msg--${severityToDisplay}`, item: first(validationResultsToDisplay)?.message }) ] diff --git a/styles/vars.scss b/styles/vars.scss index 615218b916..9098068199 100644 --- a/styles/vars.scss +++ b/styles/vars.scss @@ -404,31 +404,35 @@ body { //------------------------ // Form Fields //------------------------ - --xh-form-field-box-shadow-color-top: var(--form-field-box-shadow-color-top, #{mc-trans('blue-grey', '100', 0.15)}); - --xh-form-field-box-shadow-color-bottom: var(--form-field-box-shadow-color-bottom, #{mc-trans('blue-grey', '100', 0.2)}); + --xh-form-field-margin-bottom: var(--form-field-margin-bottom, 0); + --xh-form-field-margin-right: var(--form-field-margin-right, 0); + --xh-form-field-box-shadow: var(--form-field-box-shadow, #{inset 0 0 0 1px var(--xh-form-field-box-shadow-color-top), inset 0 1px 1px var(--xh-form-field-box-shadow-color-bottom)}); + --xh-form-field-box-shadow-color-bottom: var(--form-field-box-shadow-color-bottom, #{mc-trans('blue-grey', '100', 0.2)}); + --xh-form-field-box-shadow-color-top: var(--form-field-box-shadow-color-top, #{mc-trans('blue-grey', '100', 0.15)}); --xh-form-field-focused-border-color: var(--form-field-focused-border-color, var(--xh-focus-outline-color)); --xh-form-field-focused-box-shadow: var(--form-field-focused-box-shadow, #{inset 0 0 0 1px var(--xh-form-field-focused-border-color), inset 0 1px 1px var(--xh-form-field-focused-border-color)}); + + --xh-form-field-info-border: #{(var(--xh-form-field-info-border-width-px) solid var(--xh-form-field-info-border-color))}; + --xh-form-field-info-border-color: var(--form-field-info-border-color, var(--xh-intent-primary)); + --xh-form-field-info-border-width: var(--form-field-info-border-width, 1); + --xh-form-field-info-border-width-px: calc(var(--xh-form-field-info-border-width) * 1px); + --xh-form-field-info-box-shadow: var(--form-field-info-border-color, #{inset 0 0 0 1px var(--xh-form-field-info-border-color), inset 0 1px 1px var(--xh-form-field-info-border-color)}); + --xh-form-field-info-color: var(--form-field-info-color, var(--xh-intent-primary)); + + --xh-form-field-invalid-border: #{(var(--xh-form-field-invalid-border-width-px) solid var(--xh-form-field-invalid-border-color))}; --xh-form-field-invalid-border-color: var(--form-field-invalid-border-color, var(--xh-intent-danger)); - --xh-form-field-invalid-box-shadow: var(--form-field-invalid-border-color, #{inset 0 0 0 1px var(--xh-form-field-invalid-border-color), inset 0 1px 1px var(--xh-form-field-invalid-border-color)}); --xh-form-field-invalid-border-width: var(--form-field-invalid-border-width, 1); --xh-form-field-invalid-border-width-px: calc(var(--xh-form-field-invalid-border-width) * 1px); - --xh-form-field-invalid-border: #{(var(--xh-form-field-invalid-border-width-px) solid var(--xh-form-field-invalid-border-color))}; - --xh-form-field-invalid-message-text-color: var(--form-field-invalid-message-text-color, var(--xh-intent-danger)); - --xh-form-field-margin-bottom: var(--form-field-margin-bottom, 0); - --xh-form-field-margin-right: var(--form-field-margin-right, 0); + --xh-form-field-invalid-box-shadow: var(--form-field-invalid-border-color, #{inset 0 0 0 1px var(--xh-form-field-invalid-border-color), inset 0 1px 1px var(--xh-form-field-invalid-border-color)}); + --xh-form-field-invalid-color: var(--form-field-invalid-color, var(--xh-intent-danger)); + + --xh-form-field-warning-border: #{(var(--xh-form-field-warning-border-width-px) solid var(--xh-form-field-warning-border-color))}; --xh-form-field-warning-border-color: var(--form-field-warning-border-color, var(--xh-intent-warning)); - --xh-form-field-warning-box-shadow: var(--form-field-warning-border-color, #{inset 0 0 0 1px var(--xh-form-field-warning-border-color), inset 0 1px 1px var(--xh-form-field-warning-border-color)}); --xh-form-field-warning-border-width: var(--form-field-warning-border-width, 1); --xh-form-field-warning-border-width-px: calc(var(--xh-form-field-warning-border-width) * 1px); - --xh-form-field-warning-border: #{(var(--xh-form-field-warning-border-width-px) solid var(--xh-form-field-warning-border-color))}; - --xh-form-field-warning-message-text-color: var(--form-field-warning-message-text-color, var(--xh-intent-warning)); - --xh-form-field-info-border-color: var(--form-field-info-border-color, var(--xh-intent-primary)); - --xh-form-field-info-box-shadow: var(--form-field-info-border-color, #{inset 0 0 0 1px var(--xh-form-field-info-border-color), inset 0 1px 1px var(--xh-form-field-info-border-color)}); - --xh-form-field-info-border-width: var(--form-field-info-border-width, 1); - --xh-form-field-info-border-width-px: calc(var(--xh-form-field-info-border-width) * 1px); - --xh-form-field-info-border: #{(var(--xh-form-field-info-border-width-px) solid var(--xh-form-field-info-border-color))}; - --xh-form-field-info-message-text-color: var(--form-field-info-message-text-color, var(--xh-intent-primary)); + --xh-form-field-warning-box-shadow: var(--form-field-warning-border-color, #{inset 0 0 0 1px var(--xh-form-field-warning-border-color), inset 0 1px 1px var(--xh-form-field-warning-border-color)}); + --xh-form-field-warning-color: var(--form-field-warning-color, var(--xh-intent-warning)); &.xh-dark { --xh-form-field-box-shadow-color-top: var(--form-field-box-shadow-color-top, #{mc-trans('blue-grey', '800', 0.15)}); From e2afc24a08d55a94a8fd70a148412c10fb5db30d Mon Sep 17 00:00:00 2001 From: Greg Solomon <88904581+ghsolomon@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:11:40 -0500 Subject: [PATCH 09/13] Refactor form CSS classes to follow BEM conventions (#4201) * Refactor form CSS classes to follow BEM conventions * Codev with Greg - Stop forcing form field info messages to a single line w/clipping - allow to wrap, apply styles used in former ViewManager override example. - Apply validation color to form field label on desktop, as was already done on mobile. - Improve non-minimal validation msg display to only use a tooltip if there are multiple messages. - Simplify some style rules via new BEM conventions --------- Co-authored-by: Anselm McClain --- CHANGELOG.md | 1 + .../activity/tracking/ActivityTracking.scss | 14 +-- .../userData/roles/details/RoleDetails.scss | 12 +-- desktop/appcontainer/OptionsDialog.scss | 2 +- desktop/cmp/form/FormField.scss | 94 ++++++++++--------- desktop/cmp/form/FormField.ts | 61 +++++++----- desktop/cmp/rest/impl/RestForm.scss | 2 +- desktop/cmp/viewmanager/ViewManager.scss | 22 ++--- mobile/cmp/form/FormField.scss | 16 ++-- mobile/cmp/form/FormField.ts | 22 +++-- 10 files changed, 132 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f826dc07ef..1d487a9a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ * ⚠️Pre-existing `xh-input-invalid` and `xh-form-field-invalid` CSS classes have been updated to better follow BEM conventions with `--` modifier. They are now `xh-input--invalid` and `xh-form-field--invalid`. + * TODO - document other form CSS changes ## 79.0.0 - 2026-01-05 diff --git a/admin/tabs/activity/tracking/ActivityTracking.scss b/admin/tabs/activity/tracking/ActivityTracking.scss index efe6c6287d..3d84506114 100644 --- a/admin/tabs/activity/tracking/ActivityTracking.scss +++ b/admin/tabs/activity/tracking/ActivityTracking.scss @@ -9,7 +9,7 @@ .xh-toolbar .xh-form-field { margin: 0 0 0 var(--xh-tbar-item-pad-px); - .xh-form-field-label { + .xh-form-field__label { min-width: 0 !important; margin-right: 6px; } @@ -60,7 +60,12 @@ margin: 0; padding: 2px var(--xh-pad-px); - &.xh-form-field-readonly .xh-form-field-readonly-display { + &__label { + font-size: var(--xh-font-size-small-px); + padding: 0; + } + + &__readonly-display { padding: var(--xh-pad-half-px); } @@ -68,11 +73,6 @@ background-color: var(--xh-grid-bg-odd); } } - - .xh-form-field-label { - font-size: var(--xh-font-size-small-px); - padding: 0; - } } h3 { diff --git a/admin/tabs/userData/roles/details/RoleDetails.scss b/admin/tabs/userData/roles/details/RoleDetails.scss index ad30ae4cea..873b132a3c 100644 --- a/admin/tabs/userData/roles/details/RoleDetails.scss +++ b/admin/tabs/userData/roles/details/RoleDetails.scss @@ -6,7 +6,12 @@ margin: 0; padding: 2px var(--xh-pad-px); - &.xh-form-field-readonly .xh-form-field-readonly-display { + &__label { + font-size: var(--xh-font-size-small-px); + padding: 0; + } + + &__readonly-display { padding: var(--xh-pad-half-px); } @@ -14,10 +19,5 @@ background-color: var(--xh-grid-bg-odd); } } - - .xh-form-field-label { - font-size: var(--xh-font-size-small-px); - padding: 0; - } } } diff --git a/desktop/appcontainer/OptionsDialog.scss b/desktop/appcontainer/OptionsDialog.scss index 09c06536cc..b248a6ce28 100644 --- a/desktop/appcontainer/OptionsDialog.scss +++ b/desktop/appcontainer/OptionsDialog.scss @@ -8,7 +8,7 @@ .xh-options-dialog { width: 500px; - .xh-form-field-label { + .xh-form-field__label { min-width: 120px !important; } } diff --git a/desktop/cmp/form/FormField.scss b/desktop/cmp/form/FormField.scss index 51e18fec60..d571e7f942 100644 --- a/desktop/cmp/form/FormField.scss +++ b/desktop/cmp/form/FormField.scss @@ -11,11 +11,11 @@ padding: 3px; margin: 0 0 var(--xh-pad-px); - .xh-form-field-label { + &__label { padding: 0 0 3px; } - .xh-form-field-inner { + &__inner { // Used for unsizeable children &--block { display: block; @@ -45,73 +45,71 @@ flex: 1; } } - } - - .xh-form-field-info, - &__validation-msg { - font-size: var(--xh-font-size-small-px); - line-height: calc(var(--xh-font-size-small-px) + var(--xh-pad-px)); - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - &__validation-msg { - &--error { - color: var(--xh-form-field-invalid-color); + &__info-msg, + &__validation-msg { + font-size: var(--xh-font-size-small-px); + line-height: 1.5em; + margin-top: var(--xh-pad-half-px); } - &--info { - color: var(--xh-form-field-info-color); - } + &__validation-msg { + &--error { + color: var(--xh-form-field-invalid-color); + } - &--warning { - color: var(--xh-form-field-warning-color); + &--info { + color: var(--xh-form-field-info-color); + } + + &--warning { + color: var(--xh-form-field-warning-color); + } } } - &.xh-form-field-inline { + &--inline { flex-direction: row; align-items: baseline; - &.xh-form-field-json-input { + &.xh-form-field--json-input { align-items: start; } - .xh-form-field-label { + .xh-form-field__label { padding: 0 var(--xh-pad-half-px) 0 0; } } - &.xh-form-field-readonly { - &.xh-form-field-json-input { - .xh-form-field-inner { - &--flex { - border: var(--xh-border-solid); - padding: var(--xh-pad-half-px); - background-color: var(--xh-input-disabled-bg); - font-family: var(--xh-font-family-mono); - } - } - } - + &--readonly { .xh-form-field__validation-msg { display: none; } - .xh-form-field-inner { - &--flex { - overflow-y: auto; - } + .xh-form-field__inner--flex { + overflow-y: auto; } - .xh-form-field-readonly-display { + .xh-form-field__readonly-display { padding: 6px 0; white-space: pre-wrap; } + + &.xh-form-field--json-input { + .xh-form-field__inner--flex { + background-color: var(--xh-input-disabled-bg); + border: var(--xh-border-solid); + font-family: var(--xh-font-family-mono); + padding: var(--xh-pad-half-px); + } + } } - &.xh-form-field--invalid:not(.xh-form-field-readonly) { + &.xh-form-field--invalid:not(.xh-form-field--readonly) { + .xh-form-field__label { + color: var(--xh-form-field-invalid-color); + } + .xh-check-box span { box-shadow: var(--xh-form-field-invalid-box-shadow) !important; } @@ -137,7 +135,11 @@ } } - &.xh-form-field--warning:not(.xh-form-field-readonly) { + &.xh-form-field--warning:not(.xh-form-field--readonly) { + .xh-form-field__label { + color: var(--xh-form-field-warning-color); + } + .xh-check-box span { box-shadow: var(--xh-form-field-warning-box-shadow) !important; } @@ -163,7 +165,11 @@ } } - &.xh-form-field--info:not(.xh-form-field-readonly) { + &.xh-form-field--info:not(.xh-form-field--readonly) { + .xh-form-field__label { + color: var(--xh-form-field-info-color); + } + .xh-check-box span { box-shadow: var(--xh-form-field-info-box-shadow) !important; } @@ -203,7 +209,7 @@ ul.xh-form-field__validation-tooltip { align-items: baseline; overflow: visible !important; - .xh-form-field-inner--flex { + .xh-form-field__inner--flex { flex-direction: row; align-items: center; } diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index a837b23d55..0bfd97327a 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -135,7 +135,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ isRequired && !readonly && requiredStr ? span({ item: ' ' + requiredStr, - className: 'xh-form-field-required-indicator' + className: 'xh-form-field__required-indicator' }) : null; @@ -167,23 +167,24 @@ export const [FormField, formField] = hoistCmp.withFactory({ // Styles const classes = []; - if (childElementName) classes.push(`xh-form-field-${kebabCase(childElementName)}`); - if (isRequired) classes.push('xh-form-field-required'); - if (inline) classes.push('xh-form-field-inline'); - if (minimal) classes.push('xh-form-field-minimal'); - if (readonly) classes.push('xh-form-field-readonly'); - if (disabled) classes.push('xh-form-field-disabled'); + if (childElementName) classes.push(`xh-form-field--${kebabCase(childElementName)}`); + if (isRequired) classes.push('xh-form-field--required'); + if (inline) classes.push('xh-form-field--inline'); + if (minimal) classes.push('xh-form-field--minimal'); + if (readonly) classes.push('xh-form-field--readonly'); + if (disabled) classes.push('xh-form-field--disabled'); if (severityToDisplay) { classes.push(`xh-form-field--${severityToDisplay}`); if (displayInvalid) classes.push('xh-form-field--invalid'); } + // Test ID handling const testId = getFormFieldTestId(props, formContext, model?.name); useOnMount(() => instanceManager.registerModelWithTestId(testId, model)); useOnUnmount(() => instanceManager.unregisterModelWithTestId(testId)); - // generate actual element child to render + // Generate actual element child to render let childEl: ReactElement = !child || readonly ? readonlyChild({ @@ -220,6 +221,28 @@ export const [FormField, formField] = hoistCmp.withFactory({ }); } + // Generate inlined validation messages, if any to show and not rendering in minimal mode. + let validationMsgEl: ReactElement = null; + if (severityToDisplay && !minimal) { + const validationMsgCls = `xh-form-field__inner__validation-msg xh-form-field__inner__validation-msg--${severityToDisplay}`, + firstMsg = first(validationResultsToDisplay)?.message; + + validationMsgEl = + validationResultsToDisplay.length > 1 + ? tooltip({ + openOnTargetFocus: false, + className: validationMsgCls, + item: firstMsg + ' (...)', + content: getValidationTooltipContent( + validationResultsToDisplay + ) as ReactElement + }) + : div({ + className: validationMsgCls, + item: firstMsg + }); + } + return box({ ref, key: model?.xhId, @@ -229,7 +252,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ items: [ labelEl({ omit: !label, - className: 'xh-form-field-label', + className: 'xh-form-field__label', items: [label, requiredIndicator], htmlFor: clickableLabel ? childId : null, style: { @@ -240,25 +263,19 @@ export const [FormField, formField] = hoistCmp.withFactory({ }), div({ className: classNames( - 'xh-form-field-inner', - childIsSizeable ? 'xh-form-field-inner--flex' : 'xh-form-field-inner--block' + 'xh-form-field__inner', + childIsSizeable + ? 'xh-form-field__inner--flex' + : 'xh-form-field__inner--block' ), items: [ childEl, div({ - className: 'xh-form-field-info', + className: 'xh-form-field__inner__info-msg', omit: !info, item: info }), - tooltip({ - omit: minimal || !severityToDisplay, - openOnTargetFocus: false, - className: `xh-form-field__validation-msg xh-form-field__validation-msg--${severityToDisplay}`, - item: first(validationResultsToDisplay)?.message, - content: getValidationTooltipContent( - validationResultsToDisplay - ) as ReactElement - }) + validationMsgEl ] }) ] @@ -276,7 +293,7 @@ const readonlyChild = hoistCmp.factory({ render({model, readonlyRenderer, testId}) { const value = model ? model['value'] : null; return div({ - className: 'xh-form-field-readonly-display', + className: 'xh-form-field__readonly-display', [TEST_ID]: testId, item: readonlyRenderer(value, model) }); diff --git a/desktop/cmp/rest/impl/RestForm.scss b/desktop/cmp/rest/impl/RestForm.scss index cca9b68dc4..cacd2ce13d 100644 --- a/desktop/cmp/rest/impl/RestForm.scss +++ b/desktop/cmp/rest/impl/RestForm.scss @@ -18,7 +18,7 @@ border: 1px solid var(--xh-form-field-box-shadow-color-top); } - .xh-form-field-label { + .xh-form-field__label { margin-right: var(--xh-pad-px); font-weight: bold; } diff --git a/desktop/cmp/viewmanager/ViewManager.scss b/desktop/cmp/viewmanager/ViewManager.scss index 2c5d652956..957c59f98b 100644 --- a/desktop/cmp/viewmanager/ViewManager.scss +++ b/desktop/cmp/viewmanager/ViewManager.scss @@ -9,27 +9,19 @@ &__form { padding: var(--xh-pad-px); - .xh-form-field.xh-form-field-readonly { - &:not(.xh-form-field-inline) { - .xh-form-field-label { - border-bottom: var(--xh-border-solid); + .xh-form-field { + &--readonly { + &:not(.xh-form-field--inline) { + .xh-form-field__label { + border-bottom: var(--xh-border-solid); + } } } - .xh-form-field-readonly-display { + &__readonly-display { padding: 0; } } - - .xh-form-field .xh-form-field-info { - line-height: 1.5em; - margin-top: var(--xh-pad-half-px); - white-space: unset; - - .xh-icon { - margin-right: 2px; - } - } } &__metadata { diff --git a/mobile/cmp/form/FormField.scss b/mobile/cmp/form/FormField.scss index 852c2e060d..0b5037fdb5 100644 --- a/mobile/cmp/form/FormField.scss +++ b/mobile/cmp/form/FormField.scss @@ -2,11 +2,11 @@ display: flex; flex-direction: column; - .xh-form-field-label { + &__label { padding: 0 0 3px; } - .xh-form-field-inner { + &__inner { // Used for unsizeable children &--block { display: block; @@ -21,7 +21,7 @@ } } - .xh-form-field-info, + &__info-msg, &__validation-msg { font-size: var(--xh-font-size-small-px); line-height: calc(var(--xh-font-size-small-px) + var(--xh-pad-px)); @@ -45,24 +45,24 @@ } } - &--invalid .xh-form-field-label { + &--invalid .xh-form-field__label { color: var(--xh-form-field-invalid-color); } - &--warning .xh-form-field-label { + &--warning .xh-form-field__label { color: var(--xh-form-field-warning-color); } - &--info .xh-form-field-label { + &--info .xh-form-field__label { color: var(--xh-form-field-info-color); } - &.xh-form-field-readonly { + &.xh-form-field--readonly { .xh-form-field__validation-msg { display: none; } - .xh-form-field-readonly-display { + .xh-form-field__readonly-display { padding: var(--xh-pad-px) 0; } } diff --git a/mobile/cmp/form/FormField.ts b/mobile/cmp/form/FormField.ts index e0260fed08..2fba41c3ab 100644 --- a/mobile/cmp/form/FormField.ts +++ b/mobile/cmp/form/FormField.ts @@ -78,7 +78,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ isRequired && !readonly && requiredStr ? span({ item: ' ' + requiredStr, - className: 'xh-form-field-required-indicator' + className: 'xh-form-field__required-indicator' }) : null, isPending = model && model.isValidationPending; @@ -101,10 +101,10 @@ export const [FormField, formField] = hoistCmp.withFactory({ // Styles const classes = []; - if (isRequired) classes.push('xh-form-field-required'); - if (minimal) classes.push('xh-form-field-minimal'); - if (readonly) classes.push('xh-form-field-readonly'); - if (disabled) classes.push('xh-form-field-disabled'); + if (isRequired) classes.push('xh-form-field--required'); + if (minimal) classes.push('xh-form-field--minimal'); + if (readonly) classes.push('xh-form-field--readonly'); + if (disabled) classes.push('xh-form-field--disabled'); if (severityToDisplay) { classes.push(`xh-form-field--${severityToDisplay}`); if (displayInvalid) classes.push('xh-form-field--invalid'); @@ -131,19 +131,21 @@ export const [FormField, formField] = hoistCmp.withFactory({ items: [ labelCmp({ omit: !label, - className: 'xh-form-field-label', + className: 'xh-form-field__label', items: [label, requiredIndicator] }), div({ className: classNames( - 'xh-form-field-inner', - childIsSizeable ? 'xh-form-field-inner--flex' : 'xh-form-field-inner--block' + 'xh-form-field__inner', + childIsSizeable + ? 'xh-form-field__inner--flex' + : 'xh-form-field__inner--block' ), items: [ childEl, div({ omit: !info, - className: 'xh-form-field-info', + className: 'xh-form-field__info-msg', item: info }), div({ @@ -173,7 +175,7 @@ const readonlyChild = hoistCmp.factory({ render({model, readonlyRenderer}) { const value = model ? model['value'] : null; return div({ - className: 'xh-form-field-readonly-display', + className: 'xh-form-field__readonly-display', item: readonlyRenderer(value, model) }); } From f35e14a66ac804ebe5ae8d2d32c3eb63e8ef402f Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Mon, 12 Jan 2026 15:34:42 -0800 Subject: [PATCH 10/13] Add `--xh-font-feature-settings` + ``--xh-input-font-family` --- CHANGELOG.md | 5 +++++ cmp/input/HoistInput.scss | 6 ++++++ styles/XH.scss | 1 + styles/vars.scss | 6 ++++++ 4 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d487a9a75..af0d806f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ ### ✨ Styles +* Applied the app-wide `--xh-font-family` to `input` elements. Previously these had continued to + take a default font defined by the browser stylesheet. + * Customize for inputs if needed via `--xh-input-font-family`. + * Note that the switch to Hoist's default Inter font w/tabular numbers might require some + inputs w/tight sizing to be made wider to avoid clipping. * Updated + added validation-related input and form-field CSS classes and variables to account for new `info` and `warning` validation levels. * ⚠️Pre-existing `xh-input-invalid` and `xh-form-field-invalid` CSS classes have been updated to diff --git a/cmp/input/HoistInput.scss b/cmp/input/HoistInput.scss index 1a043efd67..a0ee630f0a 100644 --- a/cmp/input/HoistInput.scss +++ b/cmp/input/HoistInput.scss @@ -6,6 +6,12 @@ */ .xh-input { + // Override browser user agent styles on input elements. + input { + font-family: var(--xh-input-font-family); + font-feature-settings: var(--xh-input-font-feature-settings); + } + &.xh-input--invalid { input { border: var(--xh-form-field-invalid-border); diff --git a/styles/XH.scss b/styles/XH.scss index 542da3bfe6..fcdcb6aaca 100644 --- a/styles/XH.scss +++ b/styles/XH.scss @@ -19,6 +19,7 @@ body.xh-app { border-color: var(--xh-border-color); color: var(--xh-text-color); font-family: var(--xh-font-family); + font-feature-settings: var(--xh-font-feature-settings); // Important for default Inter font to ensure numbers are constant-width and line up properly. font-variant-numeric: tabular-nums; font-size: var(--xh-font-size-px); diff --git a/styles/vars.scss b/styles/vars.scss index b3a780de2f..498a8415c2 100644 --- a/styles/vars.scss +++ b/styles/vars.scss @@ -380,6 +380,10 @@ body { --xh-font-family-headings: var(--font-family-headings, var(--xh-font-family)); --xh-font-family-mono: var(--font-family-mono, #{(Monaco, Consolas, monospace)}); + // Control font features such as tabular numbers. + // For Hoist's default Inter font, see https://rsms.me/inter/#features + --xh-font-feature-settings: var(--font-feature-settings, 'tnum'); + --xh-font-size: var(--font-size, 13); --xh-font-size-large-mult: var(--font-size-large-mult, 1.2); --xh-font-size-large-em: var(--font-size-large-em, 1.2em); @@ -575,6 +579,8 @@ body { --xh-input-bg: var(--input-bg, var(--xh-bg)); --xh-input-disabled-bg: var(--form-field-disabled-bg, rgba(206, 217, 224, 0.5)); // This and below currently matched from Blueprint defaults --xh-input-disabled-text-color: var(--form-field-disabled-text-color, rgba(92, 112, 128, 0.5)); + --xh-input-font-family: var(--input-font-family, var(--xh-font-family)); + --xh-input-font-feature-settings: var(--input-font-feature-settings, var(--xh-font-feature-settings)); --xh-input-text-color: var(--input-text-color, var(--xh-text-color)); --xh-input-placeholder-text-color: var(--input-placeholder-color, rgb(175, 183, 191)); --xh-input-disabled-checkmark-svg: var(--input-disabled-checkmark-svg, url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill-rule='evenodd' clip-rule='evenodd' d='M12 5c-.28 0-.53.11-.71.29L7 9.59l-2.29-2.3a1.003 1.003 0 00-1.42 1.42l3 3c.18.18.43.29.71.29s.53-.11.71-.29l5-5A1.003 1.003 0 0012 5z' fill='rgba(92, 112, 128, 0.5)'/%3e%3c/svg%3e")); From b298df1cd36aae0c5f2bb4da58634e411aecbdbc Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Tue, 13 Jan 2026 12:10:53 -0800 Subject: [PATCH 11/13] Additional CSS vars - New `--xh-intent-xxx-text-color` variants, customized to lighter color for dark theme to enhance legibility + contrast, used for form field validation colors - New set of `--xh-form-field-label` vars to support var-driven customization of FF labels w/o need for direct CSS selectors (on the just-changed classes) - Unwind weird/old setting of BP selectors based on (otherwise unused) form-field margin vars - Fix weird/old defaulting of `--xh-input` vars, which were looking to unqualified `--form-field` versions for their defaults. --- desktop/cmp/form/FormField.scss | 22 ++++++++++++-- kit/blueprint/styles.scss | 8 ++--- mobile/cmp/form/FormField.scss | 20 ++++++++++++- styles/vars.scss | 52 +++++++++++++++++++++++++-------- 4 files changed, 82 insertions(+), 20 deletions(-) diff --git a/desktop/cmp/form/FormField.scss b/desktop/cmp/form/FormField.scss index d571e7f942..f6007d61ce 100644 --- a/desktop/cmp/form/FormField.scss +++ b/desktop/cmp/form/FormField.scss @@ -8,11 +8,27 @@ .xh-form-field { display: flex; flex-direction: column; - padding: 3px; - margin: 0 0 var(--xh-pad-px); + padding: var(--xh-form-field-padding); + margin: var(--xh-form-field-margin); &__label { - padding: 0 0 3px; + color: var(--xh-form-field-label-color); + font-size: var(--xh-form-field-label-font-size); + font-style: var(--xh-form-field-label-font-style); + font-weight: var(--xh-form-field-label-font-weight); + text-transform: var(--xh-form-field-label-text-transform); + + // Borders + padding (not inline) + border-bottom: var(--xh-form-field-label-border-bottom); + margin: var(--xh-form-field-label-margin); + padding: var(--xh-form-field-label-padding); + + // Borders + padding (inline) + .xh-form-field--inline & { + border-bottom: var(--xh-form-field-label-inline-border-bottom); + margin: var(--xh-form-field-label-inline-margin); + padding: var(--xh-form-field-label-inline-padding); + } } &__inner { diff --git a/kit/blueprint/styles.scss b/kit/blueprint/styles.scss index 09cded0b8f..98f229c97b 100644 --- a/kit/blueprint/styles.scss +++ b/kit/blueprint/styles.scss @@ -350,15 +350,15 @@ textarea.bp6-input, z-index: 16; } -// Controls ship with default bottom and (inline) right margins. We expose variables -// to customize and default those to 0 to avoid adding margins by default. +// Controls ship with default bottom and (inline) right margins. +// We zero those out here so padding can be re-applied at HoistInput or FormField layer. // Hoist theme text-color applied to elements not styled with a more specific selector. .bp6-control { color: var(--xh-text-color); - margin-bottom: var(--xh-form-field-margin-bottom); + margin-bottom: 0; &.bp6-inline { - margin-right: var(--xh-form-field-margin-right); + margin-inline-end: 0; } } diff --git a/mobile/cmp/form/FormField.scss b/mobile/cmp/form/FormField.scss index 0b5037fdb5..1c8b3992ed 100644 --- a/mobile/cmp/form/FormField.scss +++ b/mobile/cmp/form/FormField.scss @@ -1,9 +1,27 @@ .xh-form-field { display: flex; flex-direction: column; + padding: var(--xh-form-field-padding); + margin: var(--xh-form-field-margin); &__label { - padding: 0 0 3px; + color: var(--xh-form-field-label-color); + font-size: var(--xh-form-field-label-font-size); + font-style: var(--xh-form-field-label-font-style); + font-weight: var(--xh-form-field-label-font-weight); + text-transform: var(--xh-form-field-label-text-transform); + + // Borders + padding (not inline) + border-bottom: var(--xh-form-field-label-border-bottom); + margin: var(--xh-form-field-label-margin); + padding: var(--xh-form-field-label-padding); + + // Borders + padding (inline) + .xh-form-field--inline & { + border-bottom: var(--xh-form-field-label-inline-border-bottom); + margin: var(--xh-form-field-label-inline-margin); + padding: var(--xh-form-field-label-inline-padding); + } } &__inner { diff --git a/styles/vars.scss b/styles/vars.scss index 498a8415c2..9f2fcde02a 100644 --- a/styles/vars.scss +++ b/styles/vars.scss @@ -199,6 +199,19 @@ body { --xh-intent-danger-trans1: hsla(var(--xh-intent-danger-h), var(--xh-intent-danger-s), var(--xh-intent-danger-l3), var(--xh-intent-a1)); --xh-intent-danger-trans2: hsla(var(--xh-intent-danger-h), var(--xh-intent-danger-s), var(--xh-intent-danger-l3), var(--xh-intent-a2)); + // Add -text variants - customized to lighter values for dark theme to optimize for legibility and contrast. + --xh-intent-primary-text-color: var(--intent-danger-text-color, var(--xh-intent-primary)); + --xh-intent-success-text-color: var(--intent-danger-text-color, var(--xh-intent-success)); + --xh-intent-warning-text-color: var(--intent-danger-text-color, var(--xh-intent-warning)); + --xh-intent-danger-text-color: var(--intent-danger-text-color, var(--xh-intent-danger)); + + &.xh-dark { + --xh-intent-primary-text-color: var(--intent-danger-text-color, var(--xh-intent-primary-lighter)); + --xh-intent-success-text-color: var(--intent-danger-text-color, var(--xh-intent-success-lighter)); + --xh-intent-warning-text-color: var(--intent-danger-text-color, var(--xh-intent-warning-lighter)); + --xh-intent-danger-text-color: var(--intent-danger-text-color, var(--xh-intent-danger-lighter)); + } + //--------- // AppBar @@ -409,8 +422,23 @@ body { //------------------------ // Form Fields //------------------------ - --xh-form-field-margin-bottom: var(--form-field-margin-bottom, 0); - --xh-form-field-margin-right: var(--form-field-margin-right, 0); + --xh-form-field-margin: var(--form-field-margin, 0 0 var(--xh-pad-px) 0); + --xh-form-field-padding: var(--form-field-padding, 3px); + + // Label styling (shared) + --xh-form-field-label-color: var(--form-field-label-color, var(--xh-text-color)); + --xh-form-field-label-font-size: var(--form-field-label-font-size, var(--xh-font-size-px)); + --xh-form-field-label-font-style: var(--form-field-label-font-style, normal); + --xh-form-field-label-font-weight: var(--form-field-label-font-weight, normal); + --xh-form-field-label-text-transform: var(--form-field-label-text-transform, none); + + // Label borders+padding (with inline variants) + --xh-form-field-label-border-bottom: var(--form-field-label-border-bottom, none); + --xh-form-field-label-margin: var(--form-field-label-margin, 0 0 3px 0); + --xh-form-field-label-padding: var(--form-field-label-padding, 0); + --xh-form-field-label-inline-border-bottom: var(--form-field-label-inline-border-bottom, none); + --xh-form-field-label-inline-margin: var(--form-field-label-inline-margin, 0); + --xh-form-field-label-inline-padding: var(--form-field-label-inline-padding, 0); --xh-form-field-box-shadow: var(--form-field-box-shadow, #{inset 0 0 0 1px var(--xh-form-field-box-shadow-color-top), inset 0 1px 1px var(--xh-form-field-box-shadow-color-bottom)}); --xh-form-field-box-shadow-color-bottom: var(--form-field-box-shadow-color-bottom, #{mc-trans('blue-grey', '100', 0.2)}); @@ -419,25 +447,25 @@ body { --xh-form-field-focused-box-shadow: var(--form-field-focused-box-shadow, #{inset 0 0 0 1px var(--xh-form-field-focused-border-color), inset 0 1px 1px var(--xh-form-field-focused-border-color)}); --xh-form-field-info-border: #{(var(--xh-form-field-info-border-width-px) solid var(--xh-form-field-info-border-color))}; - --xh-form-field-info-border-color: var(--form-field-info-border-color, var(--xh-intent-primary)); + --xh-form-field-info-border-color: var(--form-field-info-border-color, var(--xh-intent-primary-text-color)); --xh-form-field-info-border-width: var(--form-field-info-border-width, 1); --xh-form-field-info-border-width-px: calc(var(--xh-form-field-info-border-width) * 1px); --xh-form-field-info-box-shadow: var(--form-field-info-border-color, #{inset 0 0 0 1px var(--xh-form-field-info-border-color), inset 0 1px 1px var(--xh-form-field-info-border-color)}); - --xh-form-field-info-color: var(--form-field-info-color, var(--xh-intent-primary)); + --xh-form-field-info-color: var(--form-field-info-color, var(--xh-intent-primary-text-color)); --xh-form-field-invalid-border: #{(var(--xh-form-field-invalid-border-width-px) solid var(--xh-form-field-invalid-border-color))}; - --xh-form-field-invalid-border-color: var(--form-field-invalid-border-color, var(--xh-intent-danger)); + --xh-form-field-invalid-border-color: var(--form-field-invalid-border-color, var(--xh-intent-danger-text-color)); --xh-form-field-invalid-border-width: var(--form-field-invalid-border-width, 1); --xh-form-field-invalid-border-width-px: calc(var(--xh-form-field-invalid-border-width) * 1px); --xh-form-field-invalid-box-shadow: var(--form-field-invalid-border-color, #{inset 0 0 0 1px var(--xh-form-field-invalid-border-color), inset 0 1px 1px var(--xh-form-field-invalid-border-color)}); - --xh-form-field-invalid-color: var(--form-field-invalid-color, var(--xh-intent-danger)); + --xh-form-field-invalid-color: var(--form-field-invalid-color, var(--xh-intent-danger-text-color)); --xh-form-field-warning-border: #{(var(--xh-form-field-warning-border-width-px) solid var(--xh-form-field-warning-border-color))}; - --xh-form-field-warning-border-color: var(--form-field-warning-border-color, var(--xh-intent-warning)); + --xh-form-field-warning-border-color: var(--form-field-warning-border-color, var(--xh-intent-warning-text-color)); --xh-form-field-warning-border-width: var(--form-field-warning-border-width, 1); --xh-form-field-warning-border-width-px: calc(var(--xh-form-field-warning-border-width) * 1px); --xh-form-field-warning-box-shadow: var(--form-field-warning-border-color, #{inset 0 0 0 1px var(--xh-form-field-warning-border-color), inset 0 1px 1px var(--xh-form-field-warning-border-color)}); - --xh-form-field-warning-color: var(--form-field-warning-color, var(--xh-intent-warning)); + --xh-form-field-warning-color: var(--form-field-warning-color, var(--xh-intent-warning-text-color)); &.xh-dark { --xh-form-field-box-shadow-color-top: var(--form-field-box-shadow-color-top, #{mc-trans('blue-grey', '800', 0.15)}); @@ -577,8 +605,8 @@ body { // Inputs //------------------------ --xh-input-bg: var(--input-bg, var(--xh-bg)); - --xh-input-disabled-bg: var(--form-field-disabled-bg, rgba(206, 217, 224, 0.5)); // This and below currently matched from Blueprint defaults - --xh-input-disabled-text-color: var(--form-field-disabled-text-color, rgba(92, 112, 128, 0.5)); + --xh-input-disabled-bg: var(--input-disabled-bg, rgba(206, 217, 224, 0.5)); // This and below currently matched from Blueprint defaults + --xh-input-disabled-text-color: var(--input-disabled-text-color, rgba(92, 112, 128, 0.5)); --xh-input-font-family: var(--input-font-family, var(--xh-font-family)); --xh-input-font-feature-settings: var(--input-font-feature-settings, var(--xh-font-feature-settings)); --xh-input-text-color: var(--input-text-color, var(--xh-text-color)); @@ -587,8 +615,8 @@ body { &.xh-dark { --xh-input-bg: var(--input-bg, rgba(0, 0, 0, 0.3)); // Allow some blending w/backgrounds - e.g. in dialog. - --xh-input-disabled-bg: var(--form-field-disabled-bg, rgba(57, 75, 89, 0.5)); // This and below currently matched from Blueprint defaults - --xh-input-disabled-text-color: var(--form-field-disabled-text-color, rgba(191, 204, 214, 0.5)); + --xh-input-disabled-bg: var(--input-disabled-bg, rgba(57, 75, 89, 0.5)); // This and below currently matched from Blueprint defaults + --xh-input-disabled-text-color: var(--input-disabled-text-color, rgba(191, 204, 214, 0.5)); --xh-input-disabled-checkmark-svg: var(--input-disabled-checkmark-svg, url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill-rule='evenodd' clip-rule='evenodd' d='M12 5c-.28 0-.53.11-.71.29L7 9.59l-2.29-2.3a1.003 1.003 0 00-1.42 1.42l3 3c.18.18.43.29.71.29s.53-.11.71-.29l5-5A1.003 1.003 0 0012 5z' fill='rgba(191, 204, 214, 0.5)'/%3e%3c/svg%3e")); } From dacc8d504cbfa496a3fa2fdad2d609f03c8c057b Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Tue, 13 Jan 2026 19:11:46 -0800 Subject: [PATCH 12/13] Additional style updates to messages - CSS vars for customization - Don't clip to single line on mobile (now consistent with desktop) --- desktop/cmp/form/FormField.scss | 7 ++++--- mobile/cmp/form/FormField.scss | 10 ++++------ styles/vars.scss | 18 +++++++++++++----- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/desktop/cmp/form/FormField.scss b/desktop/cmp/form/FormField.scss index f6007d61ce..16dd7122d7 100644 --- a/desktop/cmp/form/FormField.scss +++ b/desktop/cmp/form/FormField.scss @@ -64,9 +64,10 @@ &__info-msg, &__validation-msg { - font-size: var(--xh-font-size-small-px); - line-height: 1.5em; - margin-top: var(--xh-pad-half-px); + font-size: var(--xh-form-field-msg-font-size); + line-height: var(--xh-form-field-msg-line-height); + margin: var(--xh-form-field-msg-margin); + text-transform: var(--xh-form-field-msg-text-transform); } &__validation-msg { diff --git a/mobile/cmp/form/FormField.scss b/mobile/cmp/form/FormField.scss index 1c8b3992ed..3b8f700ff3 100644 --- a/mobile/cmp/form/FormField.scss +++ b/mobile/cmp/form/FormField.scss @@ -41,12 +41,10 @@ &__info-msg, &__validation-msg { - font-size: var(--xh-font-size-small-px); - line-height: calc(var(--xh-font-size-small-px) + var(--xh-pad-px)); - color: var(--xh-text-color-muted); - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; + font-size: var(--xh-form-field-msg-font-size); + line-height: var(--xh-form-field-msg-line-height); + margin: var(--xh-form-field-msg-margin); + text-transform: var(--xh-form-field-msg-text-transform); } &__validation-msg { diff --git a/styles/vars.scss b/styles/vars.scss index 9f2fcde02a..1bc8c55eaa 100644 --- a/styles/vars.scss +++ b/styles/vars.scss @@ -425,6 +425,13 @@ body { --xh-form-field-margin: var(--form-field-margin, 0 0 var(--xh-pad-px) 0); --xh-form-field-padding: var(--form-field-padding, 3px); + // Box shadow + focus border + --xh-form-field-box-shadow: var(--form-field-box-shadow, #{inset 0 0 0 1px var(--xh-form-field-box-shadow-color-top), inset 0 1px 1px var(--xh-form-field-box-shadow-color-bottom)}); + --xh-form-field-box-shadow-color-bottom: var(--form-field-box-shadow-color-bottom, #{mc-trans('blue-grey', '100', 0.2)}); + --xh-form-field-box-shadow-color-top: var(--form-field-box-shadow-color-top, #{mc-trans('blue-grey', '100', 0.15)}); + --xh-form-field-focused-border-color: var(--form-field-focused-border-color, var(--xh-focus-outline-color)); + --xh-form-field-focused-box-shadow: var(--form-field-focused-box-shadow, #{inset 0 0 0 1px var(--xh-form-field-focused-border-color), inset 0 1px 1px var(--xh-form-field-focused-border-color)}); + // Label styling (shared) --xh-form-field-label-color: var(--form-field-label-color, var(--xh-text-color)); --xh-form-field-label-font-size: var(--form-field-label-font-size, var(--xh-font-size-px)); @@ -440,12 +447,13 @@ body { --xh-form-field-label-inline-margin: var(--form-field-label-inline-margin, 0); --xh-form-field-label-inline-padding: var(--form-field-label-inline-padding, 0); - --xh-form-field-box-shadow: var(--form-field-box-shadow, #{inset 0 0 0 1px var(--xh-form-field-box-shadow-color-top), inset 0 1px 1px var(--xh-form-field-box-shadow-color-bottom)}); - --xh-form-field-box-shadow-color-bottom: var(--form-field-box-shadow-color-bottom, #{mc-trans('blue-grey', '100', 0.2)}); - --xh-form-field-box-shadow-color-top: var(--form-field-box-shadow-color-top, #{mc-trans('blue-grey', '100', 0.15)}); - --xh-form-field-focused-border-color: var(--form-field-focused-border-color, var(--xh-focus-outline-color)); - --xh-form-field-focused-box-shadow: var(--form-field-focused-box-shadow, #{inset 0 0 0 1px var(--xh-form-field-focused-border-color), inset 0 1px 1px var(--xh-form-field-focused-border-color)}); + // Messages (general info text + validation results) + --xh-form-field-msg-font-size: var(--form-field-msg-font-size, var(--xh-font-size-small-px)); + --xh-form-field-msg-line-height: var(--form-field-msg-line-height, 1.5em); + --xh-form-field-msg-margin: var(--form-field-msg-margin, var(--xh-pad-half-px) 0 0 0); + --xh-form-field-msg-text-transform: var(--form-field-msg-text-transform, none); + // Validation colors + borders --xh-form-field-info-border: #{(var(--xh-form-field-info-border-width-px) solid var(--xh-form-field-info-border-color))}; --xh-form-field-info-border-color: var(--form-field-info-border-color, var(--xh-intent-primary-text-color)); --xh-form-field-info-border-width: var(--form-field-info-border-width, 1); From c8aece33b70e8a37e9cbf36c1dc63cb522249e15 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Tue, 13 Jan 2026 19:12:16 -0800 Subject: [PATCH 13/13] Changelog entry --- CHANGELOG.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a636c299..151300f3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,12 @@ ### 💥 Breaking Changes -* Completed the refactoring away from `loadModel` to `loadObserver` started in v79: +* Modified several CSS classes related to `FormField` to better follow BEM conventions. + * ⚠️The commonly targeted `xh-form-field-label` class is now `xh-form-field__label`, although + please review new CSS vars (below) and consider using those instead of class-based selectors. + * Modifier classes now follow BEM conventions (e.g. `xh-form-field-invalid` is now + `xh-form-field--invalid`). +* Completed the refactoring from `loadModel` to `loadObserver` started in v79: * Renamed `XH.appLoadModel` to `XH.appLoadObserver`. The prior getter remains as an alias but is deprecated and scheduled for removal in v82. * Renamed `AppContainerModel.loadModel` to `loadObserver`. This is primarily an internal model, @@ -29,8 +34,8 @@ * Introduced new `FilterBindTarget` and `FilterValueSource` interfaces to generalize the data sources that could be used with `FilterChooserModel` and `GridFilterModel`. Both `Store` and `View` implement these interfaces, meaning no changes are required for apps, but it is now - possible to use these models with other, alternate implementations if needed. -* Added new static typeguard methods on `Store`, `View`, and `Filter` + subclasses. + possible to use these models with alternate implementations. +* Added new static typeguard methods on `Store`, `View`, and `Filter` + its subclasses. * Removed `RecordErrorMap` + reorganized validation types (not expected to impact most apps). ### ✨ Styles @@ -39,13 +44,15 @@ take a default font defined by the browser stylesheet. * Customize for inputs if needed via `--xh-input-font-family`. * Note that the switch to Hoist's default Inter font w/tabular numbers might require some - inputs w/tight sizing to be made wider to avoid clipping. -* Updated + added validation-related input and form-field CSS classes and variables to account for - new `info` and `warning` validation levels. - * ⚠️Pre-existing `xh-input-invalid` and `xh-form-field-invalid` CSS classes have been updated to - better follow BEM conventions with `--` modifier. They are now `xh-input--invalid` and - `xh-form-field--invalid`. - * TODO - document other form CSS changes + inputs w/tight sizing to be made wider to avoid clipping (e.g. `DateInputs` sized to fit). +* Updated + added validation-related `FormField` CSS classes and variables to account for new `info` + and `warning` validation levels. Additionally validation messages and the `info` text element no + longer clip at a single line - they will wrap as needed. +* Added new CSS variables for `FormField` to allow easier customization of commonly adjusted styles, + with a focus on labels. See `vars.scss` for the full list. Consider replacing existing class-based + CSS overrides with overrides to variables where possible. +* Added new CSS variables `--xh-intent-danger-text-color` (and others). Consider using these when + styling text with Hoist intent colors to enhance legibility in dark mode. ## 79.0.0 - 2026-01-05