diff --git a/package-lock.json b/package-lock.json index e8ad921b9a..a79cef5f44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16740,19 +16740,15 @@ "@types/chai-as-promised": "^7.1.8", "@types/chai-dom": "^1.11.1", "@types/chai-like": "^1.1.3", - "@types/mocha": "^10.0.2", "@types/node": "^20.14.2", "@types/react": "^18.2.23", "@types/sinon": "^10.0.17", "@types/sinon-chai": "^3.2.10", "@types/validator": "^13.11.2", - "c8": "^10.1.2", "chai-as-promised": "^7.1.1", "chai-dom": "^1.11.0", "glob": "^10.4.1", "karma": "^6.4.3", - "mocha": "^10.4.0", - "monocart-coverage-reports": "^2.8.4", "sinon": "^16.0.0", "sinon-chai": "^3.7.0", "typescript": "5.5.2" diff --git a/packages/ts/models/package.json b/packages/ts/models/package.json index 941db92d57..c46d07449d 100644 --- a/packages/ts/models/package.json +++ b/packages/ts/models/package.json @@ -24,7 +24,7 @@ "lint": "eslint src test", "lint:fix": "eslint src test --fix", "test": "karma start ../../../karma.config.cjs", - "test:coverage": "c8 --experimental-monocart -c ../../../.c8rc.json npm test", + "test:coverage": "npm run test -- --coverage", "test:watch": "npm run test -- --watch", "typecheck": "tsc --noEmit" }, @@ -64,19 +64,15 @@ "@types/chai-as-promised": "^7.1.8", "@types/chai-dom": "^1.11.1", "@types/chai-like": "^1.1.3", - "@types/mocha": "^10.0.2", "@types/node": "^20.14.2", "@types/react": "^18.2.23", "@types/sinon": "^10.0.17", "@types/sinon-chai": "^3.2.10", "@types/validator": "^13.11.2", - "c8": "^10.1.2", "chai-as-promised": "^7.1.1", "chai-dom": "^1.11.0", "glob": "^10.4.1", "karma": "^6.4.3", - "mocha": "^10.4.0", - "monocart-coverage-reports": "^2.8.4", "sinon": "^16.0.0", "sinon-chai": "^3.7.0", "typescript": "5.5.2" diff --git a/packages/ts/models/src/builders.ts b/packages/ts/models/src/builders.ts index d4357ef588..66613f7631 100644 --- a/packages/ts/models/src/builders.ts +++ b/packages/ts/models/src/builders.ts @@ -112,7 +112,8 @@ export class CoreModelBuilder< key: DK, value: TypedPropertyDescriptor, ): CoreModelBuilder>, F> { - return defineProperty(this[$model], key, value) as any; + defineProperty(this[$model], key, value); + return this as any; } /** @@ -287,7 +288,7 @@ export class ObjectModelBuilder< selfRefKeys: F['selfRefKeys'] | PK; } > { - return defineProperty(this[$model], key, { + defineProperty(this[$model], key, { enumerable: true, get(this: Model>>>) { if (!propertyRegistry.has(this)) { @@ -310,7 +311,8 @@ export class ObjectModelBuilder< return props[key]; }, - }) as any; + }); + return this as any; } /** diff --git a/packages/ts/models/src/core.ts b/packages/ts/models/src/core.ts index 02b15bbc66..4f1c98d43f 100644 --- a/packages/ts/models/src/core.ts +++ b/packages/ts/models/src/core.ts @@ -1,6 +1,16 @@ import type { EmptyObject } from 'type-fest'; import { CoreModelBuilder } from './builders.js'; -import { $enum, $itemModel, type $members, type AnyObject, type Enum, Model, type Value } from './model.js'; +import { + type $defaultValue, + $enum, + $itemModel, + type $members, + type $optional, + type AnyObject, + type Enum, + Model, + type Value, +} from './model.js'; /* eslint-disable tsdoc/syntax */ @@ -14,11 +24,11 @@ export const $parse = Symbol('parse'); /** * The model of a primitive value, like `string`, `number` or `boolean`. */ -export type PrimitiveModel = Model>; +export type PrimitiveModel = Model>; export const PrimitiveModel = CoreModelBuilder.create(Model, (): unknown => undefined) .name('primitive') .define($parse, { - value: (value: string) => value, + value: (value: unknown) => String(value), }) .build(); @@ -37,7 +47,7 @@ export type NumberModel = PrimitiveModel; export const NumberModel = CoreModelBuilder.create(PrimitiveModel, () => 0) .name('number') .define($parse, { - value: (value: string) => Number(value), + value: (value: unknown) => Number(String(value)), }) .build(); @@ -48,7 +58,7 @@ export type BooleanModel = PrimitiveModel; export const BooleanModel = CoreModelBuilder.create(PrimitiveModel, () => false) .name('boolean') .define($parse, { - value: (value: string) => value !== '', + value: (value: unknown) => String(value) !== '', }) .build(); @@ -99,6 +109,11 @@ export const EnumModel = CoreModelBuilder.create(Model) .defaultValueProvider((self) => Object.values(self[$enum])[0]) .build(); +export type OptionalModel = + Extract extends never + ? Model + : Model, R>; + /** * The model of a union data. */ diff --git a/packages/ts/models/src/m.ts b/packages/ts/models/src/m.ts index 77acd6781f..855f5b7a2b 100644 --- a/packages/ts/models/src/m.ts +++ b/packages/ts/models/src/m.ts @@ -1,6 +1,14 @@ -import type { EmptyObject } from 'type-fest'; +import type { EmptyObject, SetOptional } from 'type-fest'; import { CoreModelBuilder, ObjectModelBuilder } from './builders.js'; -import { $parse, ArrayModel, EnumModel, ObjectModel, type PrimitiveModel, type UnionModel } from './core.js'; +import { + $parse, + ArrayModel, + EnumModel, + ObjectModel, + type OptionalModel, + type PrimitiveModel, + type UnionModel, +} from './core.js'; import { $defaultValue, $enum, @@ -79,10 +87,24 @@ export function object( * * @param base - The base model to extend. */ -export function optional(base: M): M { - return (CoreModelBuilder.create(base) as CoreModelBuilder) +export function optional( + base: Model, EX, R>, +): OptionalModel | undefined, EX, R> { + return (CoreModelBuilder.create(base) as CoreModelBuilder) .define($optional, { value: true }) - .build() as M; + .defaultValueProvider(() => undefined) + .build() as any; +} + +/** + * Checks if the given model is an optional model. + * + * @param model - The model to check. + */ +export function isOptional( + model: Model, +): model is OptionalModel { + return model[$optional]; } /** @@ -122,6 +144,10 @@ export function validators(model: Model): readonly Validator[] { return model[$validators]; } +export function defaultValue(model: Model): T { + return model[$defaultValue]; +} + /** * Provides the value the given model represents. For attached models it will * be the owner value or its part, for detached models or in case the value @@ -144,7 +170,8 @@ export function value(model: Model): T; export function value(model: Model, newValue: T): void; // eslint-disable-next-line consistent-return export function value(...args: [Model, unknown?]): unknown { - const [model] = args; + const isSetter = args.length > 1; + const [model, newValue] = args; const keys: Array = []; let target: Target; @@ -175,69 +202,53 @@ export function value(...args: [Model, unknown?]): unknown { // If the method is called with a single argument, it means we need to get // the value of the target object the given model represents - if (args.length === 1) { - if (target.value === $nothing) { + if (target.value === $nothing) { + if (!isSetter) { // If the model is detached, we return the default value of the model. return model[$defaultValue]; } + } - let current = target.value; - - // We execute the collected keys in reverse order to get the value of the - // nested property the model represents. - for (let i = keys.length - 1; i >= 0; i--) { - // If we are not at the model's level, and the current value is not an - // object we can take a key of, we throw an error. - if (!hasCorrectShape(current)) { - throw new Error('The value shape does not fit the model.'); - } - - // Otherwise, we take a key of the current value and continue the loop. - current = current[keys[i]]; + if (!keys.length) { + if (isSetter) { + // If we are at the root level, we just set the whole value. + target.value = newValue; + } else { + return target.value; } - - return current; } - // If the method is called with two arguments, it means we need to set the - // value of the nested property the model represents. - const [, newValue] = args; - - // Here we check if we are at the root (top) level of the model structure, and - // the model represents the whole `value` of the target. - if (keys.length) { - // In case we collected some keys, it means that we are working with the - // nested properties of the target object. - let current = target.value; + let current = target.value; - // We execute the collected keys in reverse order to get the nested property - // the model represents. Since we have to stop one step before the last key, - // the condition is `i > 0`, not `i >= 0` like it was in previous case. - for (let i = keys.length - 1; i >= 0; i--) { - // If we are not at the model's level, and the current value is not an - // object we can take a key of, we throw an error. - if (!hasCorrectShape(current)) { - // eslint-disable-next-line consistent-return - throw new Error('The value shape does not fit the model.'); - } + // We execute the collected keys in reverse order to get the value of the + // nested property the model represents. + for (let i = keys.length - 1; i >= 0; i--) { + // If we are not at the model's level, and the current value is not an + // object we can take a key of, we throw an error. + if (!hasCorrectShape(current)) { + throw new Error('The value shape does not fit the model.'); + } - if (i === 0) { - // We are at the end of the loop, so we have to assign the new value to - // a property the model describes. + if (i === 0) { + // We are at the end of the loop. + if (isSetter) { + // If it is a setter, we assign the new value to a property the model + // describes. current[keys[i]] = newValue; + + // Then we have to trigger a setter of the target value. E.g., it's + // needed for the Signal to update. + // eslint-disable-next-line no-self-assign + target.value = target.value; } else { - // Otherwise, we take a key of the current value and continue the loop. - current = current[keys[i]]; + // If it is a getter, we return the value of the property the model + // describes. + return current[keys[i]]; } + } else { + // Otherwise, we take a key of the current value and continue the loop. + current = current[keys[i]]; } - - // Then we have to trigger a setter of the target value. E.g., it's needed - // for the Signal to update. - // eslint-disable-next-line no-self-assign - target.value = target.value; - } else { - // If we are at the root level, we just set the whole value. - target.value = newValue; } } diff --git a/packages/ts/models/src/validators.ts b/packages/ts/models/src/validators.ts index 55e50601d6..6cfa652642 100644 --- a/packages/ts/models/src/validators.ts +++ b/packages/ts/models/src/validators.ts @@ -1,6 +1,4 @@ -const { create, defineProperty, getPrototypeOf } = Object; - -const $validator = Symbol('validator'); +const { create, entries, getPrototypeOf, getOwnPropertyDescriptors } = Object; export class ValidationError extends Error { readonly value: unknown; @@ -11,29 +9,50 @@ export class ValidationError extends Error { } } -export type ConstrainedHTMLElement = HTMLElement & - Pick; +export type ValidatableHTMLElement = HTMLElement & + Pick< + HTMLInputElement, + | 'checkValidity' + | 'max' + | 'maxLength' + | 'min' + | 'minLength' + | 'pattern' + | 'required' + | 'setCustomValidity' + | 'type' + | 'validity' + >; export interface Validator { - brand: typeof $validator; name: string; + super: Validator; error(value: unknown): ValidationError; - validate(value: unknown): boolean; - applyToElement(element: ConstrainedHTMLElement): void; + validate(value: unknown, state?: ValidityState): boolean; + bind(element: ValidatableHTMLElement): void; } -export function isValidator(obj: unknown): obj is Validator { - return typeof obj === 'object' && !!obj && 'brand' in obj && obj.brand === $validator; +export type ValidatorProps = Partial & { error: string }>; + +export function createValidator(name: string, base: Validator, props: ValidatorProps): Validator { + return create( + base, + entries(getOwnPropertyDescriptors(props)).reduce>( + (acc, [key, { value, get, set }]) => { + acc[key] = + key === 'error' ? { value: (v: unknown) => new ValidationError(name, v, value) } : { value, get, set }; + return acc; + }, + { name: { value: name } }, + ), + ); } export const Validator = create(null, { - brand: { - value: $validator, - }, name: { value: 'Validator', }, - applyToElement: { + bind: { value: () => {}, }, error: { @@ -44,116 +63,88 @@ export const Validator = create(null, { validate: { value: () => true, }, + super: { + get(this: Validator) { + return getPrototypeOf(this); + }, + }, + [Symbol.hasInstance]: { + value(this: Validator, o: unknown) { + return typeof o === 'object' && o != null && (this === o || Object.prototype.isPrototypeOf.call(this, o)); + }, + }, }); -export class ValidatorBuilder { - readonly #validator: Validator; - - constructor(name: string, base: Validator) { - this.#validator = create(base, { - name: { - value: name, - }, - }); - } - - define(property: K, descriptor: PropertyDescriptor): this { - defineProperty(this.#validator, property, descriptor); - return this; - } - - error(error: string): this { - return this.define('error', { - value(this: Validator, value: unknown) { - return new ValidationError(this.name, value, error); - }, - }); - } - - validate(validate: (value: unknown, super_: Validator) => boolean): this { - return this.define('validate', { - value(this: Validator, value: unknown) { - return validate(value, getPrototypeOf(this)); - }, - }); - } - - applyToElement(applyToElement: (element: ConstrainedHTMLElement, base: Validator) => void): this { - return this.define('applyToElement', { - value(this: Validator, element: ConstrainedHTMLElement) { - applyToElement(element, getPrototypeOf(this)); - }, - }); - } - - build(): Validator { - return this.#validator; - } -} - -export const Required = new ValidatorBuilder('Required', Validator) - .applyToElement((element) => { +export const Required = createValidator('Required', Validator, { + bind(element) { element.required = true; - }) - .validate((value) => value != null) - .build(); + }, + validate: (value, state) => !state?.valueMissing && value != null, +}); export function Pattern(pattern: RegExp | string): Validator { const _pattern = pattern instanceof RegExp ? pattern : new RegExp(pattern, 'u'); - return new ValidatorBuilder('Pattern', Validator) - .error(`Must match the following regular expression: ${String(pattern)}`) - .validate((value) => _pattern.test(String(value))) - .applyToElement((element) => { + + return createValidator('Pattern', Validator, { + bind(element) { element.pattern = _pattern.source; - }) - .build(); + }, + validate: (value, state) => !state?.patternMismatch && _pattern.test(String(value)), + }); } -export const IsNumber = new ValidatorBuilder('IsNumber', Pattern('^[0-9]*$')) - .validate((value, super_) => super_.validate(value) && isFinite(Number(value))) - .error('Must be a number') - .build(); +export const IsNumber = createValidator('IsNumber', Pattern(/^[0-9]*$/u), { + bind(element) { + element.type = 'number'; + }, + validate(this: Validator, value, state) { + return !state?.typeMismatch && this.super.validate(value) && isFinite(Number(value)); + }, +}); -export const Email = new ValidatorBuilder('Email', Pattern('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$')) - .error('Must be a well-formed email address') - .build(); +export const Email = createValidator('Email', Pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/u), { + error: 'Must be a well-formed email address', +}); -export const Null = new ValidatorBuilder('Null', Validator) - .validate((value) => value == null) - .error('Must be null') - .build(); +export const Null = createValidator('Null', Validator, { + validate: (value) => value == null, + error: 'Must be null', +}); -export const NotNull = new ValidatorBuilder('NotNull', Required).error('Must not be null').build(); +export const NotNull = createValidator('NotNull', Required, { + error: 'Must not be null', +}); -export const NotEmpty = new ValidatorBuilder('NotEmpty', Required) - .validate( - (value, super_) => - super_.validate(value) && (typeof value === 'string' || Array.isArray(value)) && value.length > 0, - ) - .error('Must not be empty') - .build(); +export const NotEmpty = createValidator('NotEmpty', Required, { + validate(this: Validator, value) { + return this.super.validate(value) && (typeof value === 'string' || Array.isArray(value)) && value.length > 0; + }, + error: 'Must not be empty', +}); -export const NotBlank = new ValidatorBuilder('NotBlank', Required) - .validate((value, super_) => super_.validate(value) && typeof value === 'string' && /\S/u.test(value)) - .error('Must not be blank') - .build(); +export const NotBlank = createValidator('NotBlank', Required, { + validate(this: Validator, value) { + return this.super.validate(value) && typeof value === 'string' && /\S/u.test(value); + }, + error: 'Must not be blank', +}); export function Min(min: number): Validator { - return new ValidatorBuilder('Min', IsNumber) - .validate((value, super_) => super_.validate(value) && Number(value) >= min) - .applyToElement((element) => { - element.minLength = min; - }) - .error(`Must be greater than or equal to ${min}`) - .build(); + return createValidator('Min', IsNumber, { + validate: (value, state) => !state?.rangeUnderflow && IsNumber.validate(value) && Number(value) >= min, + bind(element) { + element.min = String(min); + }, + error: `Must be greater than or equal to ${min}`, + }); } export function Max(max: number): Validator { - return new ValidatorBuilder('Max', IsNumber) - .validate((value, super_) => super_.validate(value) && Number(value) <= max) - .applyToElement((element) => { - element.maxLength = max; - }) - .error(`Must be less than or equal to ${max}`) - .build(); + return createValidator('Max', IsNumber, { + validate: (value, state) => !state?.rangeOverflow && IsNumber.validate(value) && Number(value) <= max, + bind(element) { + element.max = String(max); + }, + error: `Must be less than or equal to ${max}`, + }); } diff --git a/packages/ts/models/test/models.spec.ts b/packages/ts/models/test/models.spec.ts index c4b10bee6b..c49ea8d46f 100644 --- a/packages/ts/models/test/models.spec.ts +++ b/packages/ts/models/test/models.spec.ts @@ -26,7 +26,7 @@ import m, { use(chaiLike); -describe('@vaadin/hilla-form-models', () => { +describe('@vaadin/hilla-models', () => { enum Role { Guest = 'guest', User = 'user', @@ -212,6 +212,16 @@ describe('@vaadin/hilla-form-models', () => { expect(OptionalModel.name.toString()).to.be.equal('[[:detached: / Symbol(nothing)] Optional / name?] string'); }); + it('should check if a model is optional', () => { + interface Optional { + name?: string; + } + + const OptionalModel = m.object('Optional').property('name', m.optional(StringModel)).build(); + + expect(m.isOptional(OptionalModel.name)).to.be.true; + }); + describe('m.value', () => { let target: Target;