Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +22,8 @@

### 🎁 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.
* Added new `AppMenuButton.renderWithUserProfile` prop as a built-in alternative to the default
hamburger menu. Set to `true` to render the current user's initials instead or provide a function
to render a custom element for the user.
Expand All @@ -27,8 +34,25 @@
* 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

* 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 (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

Expand Down
14 changes: 7 additions & 7 deletions admin/tabs/activity/tracking/ActivityTracking.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -60,19 +60,19 @@
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);
}

&:nth-child(odd) {
background-color: var(--xh-grid-bg-odd);
}
}

.xh-form-field-label {
font-size: var(--xh-font-size-small-px);
padding: 0;
}
}

h3 {
Expand Down
12 changes: 6 additions & 6 deletions admin/tabs/userData/roles/details/RoleDetails.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@
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);
}

&:nth-child(odd) {
background-color: var(--xh-grid-bg-odd);
}
}

.xh-form-field-label {
font-size: var(--xh-font-size-small-px);
padding: 0;
}
}
}
4 changes: 2 additions & 2 deletions cmp/form/FormModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,15 +256,15 @@ export class FormModel extends HoistModel {

/** True if all fields are valid. */
get isValid(): boolean {
return this.validationState == 'Valid';
return this.validationState === 'Valid';
}

/** List of all validation errors for this form. */
get allErrors(): string[] {
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<boolean> {
const {display = true} = opts ?? {},
promises = map(this.fields, m => m.validateAsync({display}));
Expand Down
56 changes: 38 additions & 18 deletions cmp/form/field/BaseFieldModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
ValidationResult,
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 {
Expand Down Expand Up @@ -91,11 +98,11 @@ 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
// ValidationResults for the rule. If validation for the rule has not completed will contain
// null.
@observable
private _errors: string[][];
private validationResultsInternal: ValidationResult[][];

@managed
private validationTask = TaskObserver.trackLast();
Expand All @@ -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.validationResultsInternal = this.rules.map(() => null);
}

//-----------------------------
Expand Down Expand Up @@ -175,12 +182,25 @@ export abstract class BaseFieldModel extends HoistModel {
/** All validation errors for this field. */
@computed
get errors(): string[] {
return compact(flatten(this._errors));
return this.validationResults.filter(it => it.severity === 'error').map(it => it.message);
}

/** All ValidationResults for this field. */
@computed
get validationResults(): ValidationResult[] {
return compact(flatten(this.validationResultsInternal));
}

/** 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. */
get allValidationResults(): ValidationResult[] {
return this.validationResults;
}

/**
Expand All @@ -202,7 +222,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.validationResultsInternal.fill(null);
wait().then(() => {
if (!this.isValidationPending && this.validationState === 'Unknown') {
this.computeValidationAsync();
Expand Down Expand Up @@ -300,7 +320,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.
Expand Down Expand Up @@ -339,13 +359,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.validationResultsInternal[idx] = result));
}
});
await Promise.all(promises);
}

private async evaluateRuleAsync(rule): Promise<string[]> {
private async evaluateRuleAsync(rule: Rule): Promise<ValidationResult[]> {
if (this.ruleIsActive(rule)) {
const promises = rule.check.map(async constraint => {
const {value, name, displayName} = this,
Expand All @@ -355,7 +375,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 [];
}
Expand All @@ -367,10 +389,8 @@ export abstract class BaseFieldModel extends HoistModel {
}

protected deriveValidationState(): ValidationState {
const {_errors} = this;

if (_errors.some(e => !isEmpty(e))) return 'NotValid';
if (_errors.some(e => isNil(e))) return 'Unknown';
if (!isEmpty(this.errors)) return 'NotValid';
if (this.validationResultsInternal.some(e => isNil(e))) return 'Unknown';
return 'Valid';
}
}
10 changes: 5 additions & 5 deletions cmp/form/field/SubformsFieldModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -114,9 +114,9 @@ export class SubformsFieldModel extends BaseFieldModel {
}

@computed
override get allErrors(): string[] {
const subErrs = flatMap(this.value, s => s.allErrors);
return [...this.errors, ...subErrs];
override get allValidationResults(): ValidationResult[] {
const subVals = flatMap(this.value, s => s.allValidations);
return [...this.validationResults, ...subVals];
}

@override
Expand Down
39 changes: 31 additions & 8 deletions cmp/grid/Grid.scss
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,16 @@
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,
.ag-cell.xh-cell--warning,
.ag-cell.xh-cell--info {
&::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;
}

Expand All @@ -125,6 +125,21 @@
}
}

.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);
}

.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);
Expand All @@ -136,25 +151,33 @@

.xh-ag-grid {
&--tiny {
.ag-cell.xh-cell--invalid::before {
.ag-cell.xh-cell--invalid::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--invalid::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--invalid::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--invalid::before,
.ag-cell.xh-cell--warning::before,
.ag-cell.xh-cell--info::before {
border-width: 6px;
}
}
Expand Down
Loading
Loading