From cea273cfe5b9a6be1f6634d0cc637324513c41af Mon Sep 17 00:00:00 2001 From: TSenter Date: Mon, 19 Aug 2024 21:16:16 -0400 Subject: [PATCH 01/13] refactor(validations): Update types to support computed properties --- packages/ember/src/components/form/index.gts | 10 ++-- packages/ember/src/validation/index.ts | 5 +- .../src/validation/{types.d.ts => types.ts} | 47 ++++++++-------- packages/ember/src/validation/utils.ts | 2 +- .../ember/src/validation/validators/base.ts | 53 ++++++++++++++----- .../src/validation/validators/confirmation.ts | 22 +++++--- .../ember/src/validation/validators/custom.ts | 30 +++++++---- .../src/validation/validators/exclusion.ts | 10 ++-- .../src/validation/validators/inclusion.ts | 8 +-- .../ember/src/validation/validators/length.ts | 8 +-- .../ember/src/validation/validators/number.ts | 6 +-- .../src/validation/validators/presence.ts | 8 +-- .../ember/src/validation/validators/range.ts | 8 +-- .../ember/src/validation/validators/regex.ts | 18 +++---- 14 files changed, 136 insertions(+), 99 deletions(-) rename packages/ember/src/validation/{types.d.ts => types.ts} (52%) diff --git a/packages/ember/src/components/form/index.gts b/packages/ember/src/components/form/index.gts index 10f319c87..18b537a06 100644 --- a/packages/ember/src/components/form/index.gts +++ b/packages/ember/src/components/form/index.gts @@ -22,12 +22,12 @@ import type { ComponentLike } from '@glint/template'; type Wrapper = { id: string; - v: Validator; + v: Validator; }; -type ValidatorArray = ValidatorBuilder[]; +type ValidatorArray = ValidatorBuilder[]; type ValidatorsObject = Record< string, - ValidatorBuilder | ValidatorArray + ValidatorBuilder | ValidatorArray >; export interface FormType { @@ -38,7 +38,7 @@ export interface FormType { warningFor(name: string): string | undefined; registerBinding(binding: Binding, name?: string): void; registerValidator( - validator: Validator, + validator: Validator, name?: string, ): string; unregisterValidator(name: string, id: string): void; @@ -162,7 +162,7 @@ export default class Form extends Component implements FormType { @action registerValidator( - validator: Validator, + validator: Validator, name?: string, ): string { const id = uid(); diff --git a/packages/ember/src/validation/index.ts b/packages/ember/src/validation/index.ts index 1bd864a8a..579414f31 100644 --- a/packages/ember/src/validation/index.ts +++ b/packages/ember/src/validation/index.ts @@ -12,11 +12,8 @@ export { default as RegexValidator } from './validators/regex.ts'; export { validator } from './utils.ts'; export type { + BaseOptions, ComputedProperty, - DerivedOptions, - DerivedOptionValue, - Options, - OptionValue, TranslatableMessage, TranslatableOption, ValidateFnResponse, diff --git a/packages/ember/src/validation/types.d.ts b/packages/ember/src/validation/types.ts similarity index 52% rename from packages/ember/src/validation/types.d.ts rename to packages/ember/src/validation/types.ts index a154b150b..6963af31b 100644 --- a/packages/ember/src/validation/types.d.ts +++ b/packages/ember/src/validation/types.ts @@ -1,27 +1,18 @@ -import { Binding } from '../index.ts'; +import type BaseValidator from './validators/base.ts'; +import type { Binding } from '../index.ts'; -export type ComputedProperty = (this: Context) => DerivedOptionValue; - -export interface DerivedOptions { - [key: string]: DerivedOptionValue; +export type BaseOptions = { + disabled?: boolean; + key?: string; message?: string; isWarning?: boolean; -} -export type DerivedOptionValue = - | boolean - | number - | string - | Date - | undefined - | RegExp - | unknown[]; +}; -export interface Options { - [key: string]: OptionValue; -} -export type OptionValue = - | ComputedProperty - | DerivedOptionValue; +export type Computable = { + [key in keyof Options]: ComputedProperty; +}; + +export type ComputedProperty = T | ((this: Context) => T); export interface TranslatableMessage { key: string; @@ -48,13 +39,23 @@ export interface ValidationResult { message?: string; } -export type ValidatorBuilder = ( +export type ValidatorBuilder< + T, + Model extends object, + Context extends object, + OptionsShape extends object, +> = ( binding: Binding, context: Context, ) => Validator; -export interface Validator { +export interface Validator< + T, + Model extends object, + Context extends object, + OptionsShape extends BaseOptions, +> { validate( - this: BaseValidator, + this: BaseValidator, value: T, options: OptionsShape, context: Context | Model, diff --git a/packages/ember/src/validation/utils.ts b/packages/ember/src/validation/utils.ts index 16da16c30..cd402c1de 100644 --- a/packages/ember/src/validation/utils.ts +++ b/packages/ember/src/validation/utils.ts @@ -37,7 +37,7 @@ type ValidatorFnArgs = T extends T export function validator( ...[type, options]: ValidatorFnArgs -): ValidatorBuilder, OptionsOf, ContextOf> { +): ValidatorBuilder, ContextOf, OptionsOf> { if (type === 'confirmation') { return (binding: Binding>, context: ContextOf) => new ConfirmationValidator( diff --git a/packages/ember/src/validation/validators/base.ts b/packages/ember/src/validation/validators/base.ts index 1d86cc73d..64a48a5b9 100644 --- a/packages/ember/src/validation/validators/base.ts +++ b/packages/ember/src/validation/validators/base.ts @@ -9,8 +9,8 @@ import { cached } from '@glimmer/tracking'; import type { Binding } from '../../'; import type { - DerivedOptions, - Options, + BaseOptions, + Computable, TranslatableMessage, ValidateFnResponse, ValidationResult, @@ -34,26 +34,27 @@ export function unwrapProxy(value: T): T { export default abstract class BaseValidator< T, Model extends object = Record, - OptionsShape extends DerivedOptions = DerivedOptions, Context extends object = Record, -> implements Validator + OptionsShape extends BaseOptions = BaseOptions, +> implements Validator { abstract validate( - this: BaseValidator, + this: BaseValidator, value: T, options: OptionsShape, context: Context | Model, ): ValidateFnResponse; - defaultOptions: OptionsShape = {} as OptionsShape; + defaultOptions: Computable = + {} as OptionsShape; readonly owner; readonly binding: Binding; - readonly options: OptionsShape; + readonly options: Computable; readonly context: Context | Model; constructor( binding: Binding, - options: OptionsShape, + options: Computable, context: Context, ) { this.binding = binding; @@ -120,6 +121,14 @@ export default abstract class BaseValidator< message, }; } + if (options.key) { + const message = this.translateMessage({ key: options.key, ...options }); + return { + isValid: false, + isWarning: options.isWarning ?? false, + message, + }; + } return response; } @@ -130,18 +139,34 @@ export default abstract class BaseValidator< return this.intl.t(key, options); } - computeOptions(options: Options): OptionsShape { + computeOptions(options: Computable) { const { context, defaultOptions } = this; - const computed: Record = {}; + const computed = {} as OptionsShape; + + for (const key of Object.keys(options)) { + const value = options[key as keyof OptionsShape]; + if (typeof value === 'function') { + computed[key as keyof OptionsShape] = value.apply(context); + } else { + computed[key as keyof OptionsShape] = + value as OptionsShape[keyof OptionsShape]; + } + } + + for (const key of Object.keys(defaultOptions)) { + if (key in computed) { + continue; + } - for (const [key, value] of Object.entries(options)) { + const value = defaultOptions[key as keyof OptionsShape]; if (typeof value === 'function') { - computed[key] = value.apply(context); + computed[key as keyof OptionsShape] = value.apply(context); } else { - computed[key] = value; + computed[key as keyof OptionsShape] = + value as OptionsShape[keyof OptionsShape]; } } - return { ...defaultOptions, ...computed } as OptionsShape; + return { ...computed }; } } diff --git a/packages/ember/src/validation/validators/confirmation.ts b/packages/ember/src/validation/validators/confirmation.ts index 7daeecc09..4a56dae3c 100644 --- a/packages/ember/src/validation/validators/confirmation.ts +++ b/packages/ember/src/validation/validators/confirmation.ts @@ -4,7 +4,12 @@ import { isEqual, isPresent } from '@ember/utils'; import BaseValidator from './base.ts'; import type { Binding } from '../../'; -import type { TranslatableOption, ValidateFnResponse } from '../types'; +import type { + BaseOptions, + Computable, + TranslatableOption, + ValidateFnResponse, +} from '../types'; export type ConfirmationOptions = { /** @@ -16,20 +21,21 @@ export type ConfirmationOptions = { * The property name to compare the value against. */ on: string; -}; +} & BaseOptions; export default class ConfirmationValidator< Model extends object, + Context extends object = Record, > extends BaseValidator< TranslatableOption, Model, - ConfirmationOptions, - Record + Context, + ConfirmationOptions > { constructor( binding: Binding, - options: ConfirmationOptions, - context: Record, + options: Computable, + context: Context, ) { super(binding, options, context); @@ -44,11 +50,11 @@ export default class ConfirmationValidator< validate( value: TranslatableOption, options: ConfirmationOptions, - context: Record, + context: Context, ): ValidateFnResponse { const { label, on } = options; - const expectedValue = context[on]; + const expectedValue = (context as Record)[on]; if (isEqual(value, expectedValue)) { return true; diff --git a/packages/ember/src/validation/validators/custom.ts b/packages/ember/src/validation/validators/custom.ts index 687e01d2a..66fa2854d 100644 --- a/packages/ember/src/validation/validators/custom.ts +++ b/packages/ember/src/validation/validators/custom.ts @@ -7,32 +7,36 @@ import BaseValidator from './base.ts'; import type { Binding } from '../../'; import type { - DerivedOptions, + BaseOptions, + Computable, ValidateFnResponse, ValidationResult, } from '../types'; -declare type ValidateFn = ( +declare type ValidateFn = ( value: T, - options: CustomOptions, + options: CustomOptions, context: Record, ) => ValidateFnResponse; -export type CustomOptions = { +export type CustomOptions = { /** * The function to be called to validate. It should return a boolean or a * response object. */ - validate: ValidateFn; -} & DerivedOptions; + validate: ValidateFn; +} & BaseOptions; export default class CustomValidator< T, Model extends object, - Options extends CustomOptions = CustomOptions, Context extends object = Record, -> extends BaseValidator { - constructor(binding: Binding, options: Options, context: Context) { +> extends BaseValidator> { + constructor( + binding: Binding, + options: Computable>, + context: Context, + ) { super(binding, options, context); const { validate } = options; @@ -47,9 +51,11 @@ export default class CustomValidator< get result(): ValidationResult { const { context, value } = this; const { validate, ...options } = this.options; - const computedOptions = this.computeOptions(options); + const computedOptions = this.computeOptions( + options as CustomOptions, + ); - let response = validate.apply(context, [ + let response = (validate as ValidateFn).apply(context, [ value, computedOptions, context as Record, @@ -60,6 +66,8 @@ export default class CustomValidator< response.message ??= this.intl.t('nrg.validation.custom.invalid', { value: String(value), }); + + delete computedOptions.key; } return this.coalesceResponse(response, computedOptions); diff --git a/packages/ember/src/validation/validators/exclusion.ts b/packages/ember/src/validation/validators/exclusion.ts index 430216884..38bd95955 100644 --- a/packages/ember/src/validation/validators/exclusion.ts +++ b/packages/ember/src/validation/validators/exclusion.ts @@ -3,23 +3,23 @@ import { assert } from '@ember/debug'; import BaseValidator from './base.ts'; import type { Binding, Primitive } from '../../'; -import type { ValidateFnResponse } from '../types'; +import type { BaseOptions, Computable, ValidateFnResponse } from '../types'; export type ExclusionOptions = { /** * An array of invalid values. */ in: T[]; -}; +} & BaseOptions; export default class ExclusionValidator< T extends Primitive, - Model extends object, Context extends object = Record, -> extends BaseValidator, Context> { + Model extends object = Record, +> extends BaseValidator> { constructor( bind: Binding, - options: ExclusionOptions, + options: Computable>, context: Context, ) { super(bind, options, context); diff --git a/packages/ember/src/validation/validators/inclusion.ts b/packages/ember/src/validation/validators/inclusion.ts index 6bb2eb267..506054f31 100644 --- a/packages/ember/src/validation/validators/inclusion.ts +++ b/packages/ember/src/validation/validators/inclusion.ts @@ -3,23 +3,23 @@ import { assert } from '@ember/debug'; import BaseValidator from './base.ts'; import type { Binding, Primitive } from '../../'; -import type { ValidateFnResponse } from '../types'; +import type { BaseOptions, Computable, ValidateFnResponse } from '../types'; export type InclusionOptions = { /** * An array of valid values. */ in: T[]; -}; +} & BaseOptions; export default class InclusionValidator< T extends Primitive, Model extends object, Context extends object = Record, -> extends BaseValidator, Context> { +> extends BaseValidator> { constructor( binding: Binding, - options: InclusionOptions, + options: Computable>, context: Context, ) { super(binding, options, context); diff --git a/packages/ember/src/validation/validators/length.ts b/packages/ember/src/validation/validators/length.ts index 148ef2c02..90746e40b 100644 --- a/packages/ember/src/validation/validators/length.ts +++ b/packages/ember/src/validation/validators/length.ts @@ -4,7 +4,7 @@ import { isEmpty, isNone } from '@ember/utils'; import BaseValidator from './base.ts'; import type { Binding } from '../../'; -import type { ValidateFnResponse } from '../types'; +import type { BaseOptions, Computable, ValidateFnResponse } from '../types'; export type LengthOptions = { /** @@ -22,13 +22,13 @@ export type LengthOptions = { is?: number; max?: number; min?: number; -}; +} & BaseOptions; export default class LengthValidator< T extends ArrayLike, Model extends object, Context extends object = Record, -> extends BaseValidator { +> extends BaseValidator { defaultOptions = { allowNone: true, presence: true, @@ -36,7 +36,7 @@ export default class LengthValidator< constructor( binding: Binding, - options: LengthOptions, + options: Computable, context: Context, ) { super(binding, options, context); diff --git a/packages/ember/src/validation/validators/number.ts b/packages/ember/src/validation/validators/number.ts index d0ea5632a..0859f4a02 100644 --- a/packages/ember/src/validation/validators/number.ts +++ b/packages/ember/src/validation/validators/number.ts @@ -2,7 +2,7 @@ import { isEmpty, isNone } from '@ember/utils'; import BaseValidator from './base.ts'; -import type { ValidateFnResponse } from '../types'; +import type { BaseOptions, ValidateFnResponse } from '../types'; export type NumberOptions = { /** @@ -53,14 +53,14 @@ export type NumberOptions = { * When set, the number must be exactly this value */ is?: number; -}; +} & BaseOptions; declare type NumberLike = number | string | null | undefined; export default class NumberValidator< Model extends object, Context extends object = Record, -> extends BaseValidator { +> extends BaseValidator { defaultOptions = { allowNone: true, }; diff --git a/packages/ember/src/validation/validators/presence.ts b/packages/ember/src/validation/validators/presence.ts index 7a4e09d38..4bf8a0d0e 100644 --- a/packages/ember/src/validation/validators/presence.ts +++ b/packages/ember/src/validation/validators/presence.ts @@ -4,7 +4,7 @@ import { isEmpty, isPresent } from '@ember/utils'; import BaseValidator from './base.ts'; import type { Binding } from '../../'; -import type { ValidateFnResponse } from '../types'; +import type { BaseOptions, Computable, ValidateFnResponse } from '../types'; export type PresenceOptions = { /** @@ -17,13 +17,13 @@ export type PresenceOptions = { * @default false */ ignoreBlank?: boolean; -}; +} & BaseOptions; export default class PresenceValidator< T, Model extends object, Context extends object = Record, -> extends BaseValidator { +> extends BaseValidator { defaultOptions = { presence: true, ignoreBlank: false, @@ -31,7 +31,7 @@ export default class PresenceValidator< constructor( binding: Binding, - options: PresenceOptions, + options: Computable, context: Context, ) { super(binding, options, context); diff --git a/packages/ember/src/validation/validators/range.ts b/packages/ember/src/validation/validators/range.ts index e8bc1dbbc..a0e0aa42c 100644 --- a/packages/ember/src/validation/validators/range.ts +++ b/packages/ember/src/validation/validators/range.ts @@ -4,7 +4,7 @@ import { isPresent } from '@ember/utils'; import BaseValidator from './base.ts'; import type { Binding } from '../../'; -import type { ValidateFnResponse } from '../types'; +import type { BaseOptions, Computable, ValidateFnResponse } from '../types'; export type RangeOptions = { /** @@ -25,12 +25,12 @@ export type RangeOptions = { * @default true */ maxInclusive?: boolean; -}; +} & BaseOptions; export default class RangeValidator< Model extends object, Context extends object = Record, -> extends BaseValidator { +> extends BaseValidator { defaultOptions = { minInclusive: true, maxInclusive: true, @@ -38,7 +38,7 @@ export default class RangeValidator< constructor( binding: Binding, - options: RangeOptions, + options: Computable, context: Context, ) { super(binding, options, context); diff --git a/packages/ember/src/validation/validators/regex.ts b/packages/ember/src/validation/validators/regex.ts index 452c5dcc9..cf0b7456f 100644 --- a/packages/ember/src/validation/validators/regex.ts +++ b/packages/ember/src/validation/validators/regex.ts @@ -4,7 +4,7 @@ import { isNone } from '@ember/utils'; import BaseValidator from './base.ts'; import type { Binding } from '../..'; -import type { ValidateFnResponse } from '../types'; +import type { BaseOptions, Computable, ValidateFnResponse } from '../types'; export type RegexOptions = { /** @@ -16,15 +16,15 @@ export type RegexOptions = { * A regular expression pattern that the value must match. */ pattern: RegExp | string; -}; +} & BaseOptions; export default class RegexValidator< Model extends object = Record, Context extends object = Record, -> extends BaseValidator { +> extends BaseValidator { constructor( binding: Binding, - options: RegexOptions, + options: Computable, context: Context, ) { super(binding, options, context); @@ -35,11 +35,6 @@ export default class RegexValidator< 'RegexValidator requires `pattern` to be provided', !isNone(pattern), ); - - assert( - 'RegexValidator requires the pattern to be of type string or RegExp', - typeof pattern === 'string' || pattern instanceof RegExp, - ); } validate(value: string, options: RegexOptions): ValidateFnResponse { @@ -50,6 +45,11 @@ export default class RegexValidator< !isNone(pattern), ); + assert( + 'RegexValidator requires the pattern to be of type string or RegExp', + typeof pattern === 'string' || pattern instanceof RegExp, + ); + const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; const isValid = regex.test(value) === !inverse; From 191d6b594c3bf9bcd03db7a9f4ee68e2b799496f Mon Sep 17 00:00:00 2001 From: TSenter Date: Mon, 19 Aug 2024 21:32:03 -0400 Subject: [PATCH 02/13] feat(validations): Skip validations when `disabled` --- .../tests/unit/validators/custom-test.ts | 36 +++++++++++++++++++ .../ember/src/validation/validators/base.ts | 5 +++ .../ember/src/validation/validators/custom.ts | 4 +++ 3 files changed, 45 insertions(+) diff --git a/apps/ember-test-app/tests/unit/validators/custom-test.ts b/apps/ember-test-app/tests/unit/validators/custom-test.ts index 43c2a2ad5..a00abb8ce 100644 --- a/apps/ember-test-app/tests/unit/validators/custom-test.ts +++ b/apps/ember-test-app/tests/unit/validators/custom-test.ts @@ -15,6 +15,9 @@ import type { Binding } from '@nrg-ui/ember'; class Model { @tracked field?: string | string[]; + + @tracked + disabled: boolean = false; } declare type TestContext = { @@ -138,6 +141,39 @@ module('Unit | Validator | custom', function (hooks) { }); }); + test('disabled option works', async function (this: TestContext, assert) { + const validator = new CustomValidator( + this.binding, + { + validate() { + return false; + }, + disabled() { + return this.disabled; + }, + }, + this.model, + ); + + this.model.field = ['foo', 'bar']; + + let result = validator.result; + + assert.deepEqual(result, { + isValid: false, + isWarning: false, + message: 'This field is invalid', + }); + + this.model.disabled = true; + + result = validator.result; + + assert.deepEqual(result, { + isValid: true, + }); + }); + test('works with `validator` function', function (this: TestContext, assert) { const builder = buildValidator('custom', { validate: () => { diff --git a/packages/ember/src/validation/validators/base.ts b/packages/ember/src/validation/validators/base.ts index 64a48a5b9..2a946e070 100644 --- a/packages/ember/src/validation/validators/base.ts +++ b/packages/ember/src/validation/validators/base.ts @@ -90,6 +90,11 @@ export default abstract class BaseValidator< get result(): ValidationResult { const { context, options, validate, value } = this; const computedOptions = this.computeOptions(options); + + if (computedOptions.disabled) { + return { isValid: true }; + } + const response = validate.apply(this, [value, computedOptions, context]); return this.coalesceResponse(response, computedOptions); diff --git a/packages/ember/src/validation/validators/custom.ts b/packages/ember/src/validation/validators/custom.ts index 66fa2854d..7d8aec304 100644 --- a/packages/ember/src/validation/validators/custom.ts +++ b/packages/ember/src/validation/validators/custom.ts @@ -55,6 +55,10 @@ export default class CustomValidator< options as CustomOptions, ); + if (computedOptions.disabled) { + return { isValid: true }; + } + let response = (validate as ValidateFn).apply(context, [ value, computedOptions, From cdf1e94475e8210ee24611134e5a4584ba61b98f Mon Sep 17 00:00:00 2001 From: TSenter Date: Mon, 19 Aug 2024 21:33:01 -0400 Subject: [PATCH 03/13] chore: Update test name and add TS directive --- apps/ember-test-app/tests/unit/validators/custom-test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ember-test-app/tests/unit/validators/custom-test.ts b/apps/ember-test-app/tests/unit/validators/custom-test.ts index a00abb8ce..56fad8752 100644 --- a/apps/ember-test-app/tests/unit/validators/custom-test.ts +++ b/apps/ember-test-app/tests/unit/validators/custom-test.ts @@ -36,10 +36,11 @@ module('Unit | Validator | custom', function (hooks) { setOwner(this.model, this.owner); }); - test('`on` option is required', function (this: TestContext, assert) { + test('`validate` option is required', function (this: TestContext, assert) { assert.expect(1); assert.throws(() => { + // @ts-expect-error Testing that the `validate` option is required const validator = new CustomValidator(this.binding, {}, this.model); const result = validator.result; From 00515b3e21ee6c6e34ff8d7b9000472de42bdb9f Mon Sep 17 00:00:00 2001 From: TSenter Date: Tue, 20 Aug 2024 08:40:56 -0400 Subject: [PATCH 04/13] feat(validations): Run and display warnings without submitting form --- packages/design-system/src/_forms.scss | 75 +++++++++++++++++++ packages/ember/src/components/form/field.gts | 26 ++++++- packages/ember/src/components/form/index.gts | 4 +- .../ember/src/components/form/radio-group.gts | 7 ++ packages/ember/src/components/form/select.gts | 3 + .../ember/src/components/form/text-area.gts | 3 + .../ember/src/components/form/text-field.gts | 3 + 7 files changed, 118 insertions(+), 3 deletions(-) diff --git a/packages/design-system/src/_forms.scss b/packages/design-system/src/_forms.scss index 541e85e2b..1bc5780aa 100644 --- a/packages/design-system/src/_forms.scss +++ b/packages/design-system/src/_forms.scss @@ -10,3 +10,78 @@ overflow-y: auto; } } + +/* I'm open to changing the color (it's the "orange" color from + * Fomantic) but I think it's a good idea to have a color that's + * different from the default "warning" color from Bootstrap. + * The default Bootstrap warning color is hard to read, and the + * "emphasis" variant is gross (see `text-warning-emphasis`): + * + */ +$warning-custom: #f2711c; +:root { + --bs-form-warning-rgb: 242, 113, 28; + --bs-form-warning-color: #f2711c; + --bs-form-warning-border-color: #f2711c; +} + +input, +textarea, +select, +.form-control { + &[disabled] { + & ~ .warning-feedback { + display: none; + } + } + &:not([disabled]) { + &:not(input[type="checkbox"]):not(input[type="radio"]) { + background: none !important; + padding-right: revert !important; + } + + & ~ .warning-feedback { + @extend .invalid-feedback; + color: var(--bs-form-warning-color); + display: block; + } + } +} + +input.form-control, +textarea.form-control, +select.form-select, +.form-control, +.form-check, +.form-check-input { + &.is-warning:not([disabled]) { + @extend .is-invalid; + + border-color: var(--bs-form-warning-border-color); + } + + &.is-warning:focus:not([disabled]) { + @extend .is-invalid, :focus; + + border-color: var(--bs-form-warning-border-color); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-form-warning-rgb), 0.25); + } +} + +.form-check-input.is-warning:not([disabled]) { + @extend .is-invalid; + + &:checked { + &:focus, + & { + background-color: var(--bs-form-warning-color); + } + } + + &:focus, + & { + & ~ .form-check-label { + color: var(--bs-form-warning-color); + } + } +} diff --git a/packages/ember/src/components/form/field.gts b/packages/ember/src/components/form/field.gts index b367e6bff..c22f5c57d 100644 --- a/packages/ember/src/components/form/field.gts +++ b/packages/ember/src/components/form/field.gts @@ -109,6 +109,10 @@ export default class Field extends Component { return typeof this.errorMessage === 'string'; } + get hasWarning() { + return typeof this.warningMessage === 'string'; + } + get errorMessage() { const { form } = this.args; @@ -119,6 +123,16 @@ export default class Field extends Component { return form.errorFor(this.validatorKey); } + get warningMessage() { + const { form } = this.args; + + if (!form) { + return undefined; + } + + return form.warningFor(this.validatorKey); + } + get validatorKey() { return this.args.validatorKey ?? this.binding.valuePath; } @@ -126,7 +140,7 @@ export default class Field extends Component { get describedBy() { const describedBy = []; - if (this.hasError) { + if (this.hasError || this.hasWarning) { describedBy.push(this.messageId); } @@ -202,6 +216,7 @@ export default class Field extends Component { id=this.fieldId initBinding=this.initBinding isInvalid=this.hasError + isWarning=this.hasWarning ) Select=(component this.TypedSelect @@ -210,6 +225,7 @@ export default class Field extends Component { id=this.fieldId initBinding=this.initBinding isInvalid=this.hasError + isWarning=this.hasWarning ) Text=(component Text field=this id=this.textId) TextArea=(component @@ -219,6 +235,7 @@ export default class Field extends Component { id=this.fieldId initBinding=this.initBinding isInvalid=this.hasError + isWarning=this.hasWarning ) TextField=(component TextField @@ -227,13 +244,18 @@ export default class Field extends Component { id=this.fieldId initBinding=this.initBinding isInvalid=this.hasError + isWarning=this.hasWarning ) ) }} {{#if this.hasError}} -
+
{{this.errorMessage}}
+ {{else if this.hasWarning}} +
+ {{this.warningMessage}} +
{{/if}} } diff --git a/packages/ember/src/components/form/index.gts b/packages/ember/src/components/form/index.gts index 18b537a06..f760bad2d 100644 --- a/packages/ember/src/components/form/index.gts +++ b/packages/ember/src/components/form/index.gts @@ -131,7 +131,9 @@ export default class Form extends Component implements FormType { }); const validations = Array.from(this.validations.values()).flat(); - return validations.every((validator) => validator.v.result.isValid); + return validations + .filter((validator) => !validator.v.result.isWarning) + .every((validator) => validator.v.result.isValid); } submit = dropTask(async (event: SubmitEvent) => { diff --git a/packages/ember/src/components/form/radio-group.gts b/packages/ember/src/components/form/radio-group.gts index 71246f2a5..15f0b560c 100644 --- a/packages/ember/src/components/form/radio-group.gts +++ b/packages/ember/src/components/form/radio-group.gts @@ -16,6 +16,7 @@ export interface RadioGroupFieldSignature { disabled?: boolean; id?: string; isInvalid?: boolean; + isWarning?: boolean; name: string; }; Blocks: { @@ -44,6 +45,8 @@ export default class RadioGroupField extends BoundValue< if (this.args.isInvalid) { classes.push('is-invalid'); + } else if (this.args.isWarning) { + classes.push('is-warning'); } return classes.join(' '); @@ -63,6 +66,7 @@ export default class RadioGroupField extends BoundValue< currentValue=this.value disabled=@disabled isInvalid=@isInvalid + isWarning=@isWarning name=@name onChange=this.change ) @@ -78,6 +82,7 @@ export interface RadioFieldSignature { currentValue?: string | null; disabled?: boolean; isInvalid?: boolean; + isWarning?: boolean; label?: string; name: string; option?: string; @@ -97,6 +102,8 @@ class RadioField extends Component { if (this.args.isInvalid) { classes.push('is-invalid'); + } else if (this.args.isWarning) { + classes.push('is-warning'); } return classes.join(' '); diff --git a/packages/ember/src/components/form/select.gts b/packages/ember/src/components/form/select.gts index 5fce809b7..fd331b6ff 100644 --- a/packages/ember/src/components/form/select.gts +++ b/packages/ember/src/components/form/select.gts @@ -83,6 +83,7 @@ export interface SelectSignature { displayPath?: string; id?: string; isInvalid?: boolean; + isWarning?: boolean; loading?: boolean; options: T[]; scrollable?: boolean; @@ -127,6 +128,8 @@ export default class Select extends BoundValue< if (this.args.isInvalid) { classes.push('is-invalid'); + } else if (this.args.isWarning) { + classes.push('is-warning'); } return classes.join(' '); diff --git a/packages/ember/src/components/form/text-area.gts b/packages/ember/src/components/form/text-area.gts index a52d291cd..ae85c83ef 100644 --- a/packages/ember/src/components/form/text-area.gts +++ b/packages/ember/src/components/form/text-area.gts @@ -11,6 +11,7 @@ export interface TextAreaSignature { disabled?: boolean; id?: string; isInvalid?: boolean; + isWarning?: boolean; readonly?: boolean; }; } @@ -25,6 +26,8 @@ export default class TextArea extends BoundValue { if (this.args.isInvalid) { classes.push('is-invalid'); + } else if (this.args.isWarning) { + classes.push('is-warning'); } return classes.join(' '); diff --git a/packages/ember/src/components/form/text-field.gts b/packages/ember/src/components/form/text-field.gts index fc3859dba..5931f67df 100644 --- a/packages/ember/src/components/form/text-field.gts +++ b/packages/ember/src/components/form/text-field.gts @@ -11,6 +11,7 @@ export interface TextFieldSignature { disabled?: boolean; id?: string; isInvalid?: boolean; + isWarning?: boolean; readonly?: boolean; }; } @@ -25,6 +26,8 @@ export default class TextField extends BoundValue { if (this.args.isInvalid) { classes.push('is-invalid'); + } else if (this.args.isWarning) { + classes.push('is-warning'); } return classes.join(' '); From 69b10cef29b8bbc188ee484ffb97a28522a1234f Mon Sep 17 00:00:00 2001 From: TSenter Date: Tue, 20 Aug 2024 08:41:55 -0400 Subject: [PATCH 05/13] docs: Add `isWarning` examples --- apps/ember-test-app/app/components/f/form.gts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ember-test-app/app/components/f/form.gts b/apps/ember-test-app/app/components/f/form.gts index 57808b634..baa8d3518 100644 --- a/apps/ember-test-app/app/components/f/form.gts +++ b/apps/ember-test-app/app/components/f/form.gts @@ -26,8 +26,10 @@ const Validators = { console.log('Validating: ' + value); return value !== 'foo'; }, + isWarning: true, }), ], + radio: [validator('exclusion', { in: ['A', 'B'], isWarning: true })], }; class Model { From 99d41f91404ca48e8f03712308ecf8d60cb33307 Mon Sep 17 00:00:00 2001 From: TSenter Date: Tue, 20 Aug 2024 08:42:06 -0400 Subject: [PATCH 06/13] chore: Add test for warning validations --- .../components/form/index-test.gts | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/ember-test-app/tests/integration/components/form/index-test.gts b/apps/ember-test-app/tests/integration/components/form/index-test.gts index 877011a4a..010b84ff6 100644 --- a/apps/ember-test-app/tests/integration/components/form/index-test.gts +++ b/apps/ember-test-app/tests/integration/components/form/index-test.gts @@ -1,8 +1,10 @@ import { setOwner } from '@ember/application'; +import { array } from '@ember/helper'; import { click, fillIn, render } from '@ember/test-helpers'; import { tracked } from '@glimmer/tracking'; import Form from '@nrg-ui/ember/components/form'; import bind from '@nrg-ui/ember/helpers/bind'; +import { validator } from '@nrg-ui/ember/validation'; import { setupIntl } from 'ember-intl/test-support'; import { setupRenderingTest } from 'ember-qunit'; import { module, test } from 'qunit'; @@ -15,6 +17,13 @@ class Model { textArea: string = ''; } +const Validators = { + selectByAnotherProperty: validator('presence', { + presence: false, + isWarning: true, + }), +}; + module('Integration | Component | form', function (hooks) { setupRenderingTest(hooks); setupIntl(hooks, 'en-us'); @@ -101,7 +110,7 @@ module('Integration | Component | form', function (hooks) { }); test('validations work', async function (assert) { - assert.expect(12); + assert.expect(20); const model = this.model; @@ -111,7 +120,7 @@ module('Integration | Component | form', function (hooks) { }; await render(); - await click('button'); + // Select + const select = this.element.querySelector('label + button.dropdown'); + + assert + .dom(select) + .exists() + .hasAttribute('role', 'combobox') + .hasClass('form-control'); + + await click(select); + await click('button > .dropdown-menu > li:first-child'); + + const ariaId = select.getAttribute('aria-describedby'); + + assert.dom('button.dropdown').doesNotHaveClass('is-invalid'); + assert + .dom('button.dropdown + div') + .exists() + .hasAttribute('id', ariaId) + .hasClass('warning-feedback') + .containsText('This field must be blank'); + + await click('button[type="submit"]'); assert.false(didSubmit, 'Form should not submit when validations fail'); @@ -159,7 +200,7 @@ module('Integration | Component | form', function (hooks) { assert.ok(true, 'Form should submit'); }; - await click('button'); + await click('button[type="submit"]'); assert.true(didSubmit, 'Form should submit when validations pass'); }); From a3b3d9228dfd4cef81c1c25b84717a46b1e88fb4 Mon Sep 17 00:00:00 2001 From: TSenter Date: Tue, 20 Aug 2024 09:40:17 -0400 Subject: [PATCH 07/13] fix(radio): Add default name for group --- .../ember/src/components/form/radio-group.gts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/ember/src/components/form/radio-group.gts b/packages/ember/src/components/form/radio-group.gts index 15f0b560c..a4655c6cb 100644 --- a/packages/ember/src/components/form/radio-group.gts +++ b/packages/ember/src/components/form/radio-group.gts @@ -28,14 +28,6 @@ export default class RadioGroupField extends BoundValue< RadioGroupFieldSignature, string > { - @action - change(updatedValue: Optional) { - if (updatedValue) { - this.value = updatedValue; - this.onChange(updatedValue); - } - } - get classList() { const classes = ['form-control']; @@ -52,6 +44,18 @@ export default class RadioGroupField extends BoundValue< return classes.join(' '); } + get name() { + return this.args.name ?? crypto.randomUUID(); + } + + @action + change(updatedValue: Optional) { + if (updatedValue) { + this.value = updatedValue; + this.onChange(updatedValue); + } + } +