diff --git a/src/Framework/Framework/Resources/Scripts/validation/error.ts b/src/Framework/Framework/Resources/Scripts/validation/error.ts index 8fd1aae885..5d984b3b48 100644 --- a/src/Framework/Framework/Resources/Scripts/validation/error.ts +++ b/src/Framework/Framework/Resources/Scripts/validation/error.ts @@ -21,12 +21,29 @@ export function getErrors(o: KnockoutObservable | null): ValidationError[] export class ValidationError { private constructor(public errorMessage: string, public propertyPath: string, public validatedObservable: KnockoutObservable) { + if (!errorMessage) { + throw new Error(`String "${errorMessage}" is not a valid ValidationError message.`); + } } public static attach(errorMessage: string, propertyPath: string, observable: KnockoutObservable): ValidationError { - if (!errorMessage) { - throw new Error(`String "${errorMessage}" is not a valid ValidationError message.`); + let unwrapped = ValidationError.getOrCreateErrorsCollection(observable); + const error = new ValidationError(errorMessage, propertyPath, unwrapped); + unwrapped[errorsSymbol].push(error); + allErrors.push(error); + return error; + } + + static attachIfNoErrors(errorMessage: string, propertyPath: string, observable: KnockoutObservable) { + let unwrapped = ValidationError.getOrCreateErrorsCollection(observable); + if (unwrapped[errorsSymbol].length === 0) { + const error = new ValidationError(errorMessage, propertyPath, unwrapped); + unwrapped[errorsSymbol].push(error); + allErrors.push(error); } + } + + private static getOrCreateErrorsCollection(observable: KnockoutObservable): KnockoutObservable & { [errorsSymbol]: ValidationError[] } { if (!observable) { throw new Error(`ValidationError cannot be attached to "${observable}".`); } @@ -35,10 +52,7 @@ export class ValidationError { if (!unwrapped.hasOwnProperty(errorsSymbol)) { unwrapped[errorsSymbol] = []; } - const error = new ValidationError(errorMessage, propertyPath, unwrapped); - unwrapped[errorsSymbol].push(error); - allErrors.push(error); - return error; + return unwrapped; } public detach(): void { diff --git a/src/Framework/Framework/Resources/Scripts/validation/validation.ts b/src/Framework/Framework/Resources/Scripts/validation/validation.ts index f486630e0d..e52bc4bd8a 100644 --- a/src/Framework/Framework/Resources/Scripts/validation/validation.ts +++ b/src/Framework/Framework/Resources/Scripts/validation/validation.ts @@ -171,7 +171,7 @@ function validateViewModel(viewModel: any, path: string): void { function validateRecursive(observable: KnockoutObservable, propertyValue: any, type: TypeDefinition, propertyPath: string) { const lastSetError: CoerceResult = (observable as any)[lastSetErrorSymbol]; if (lastSetError && lastSetError.isError) { - ValidationError.attach(lastSetError.message, propertyPath, observable); + ValidationError.attachIfNoErrors(lastSetError.message, propertyPath, observable); } if (Array.isArray(type)) { diff --git a/src/Framework/Framework/Resources/Scripts/validation/validators.ts b/src/Framework/Framework/Resources/Scripts/validation/validators.ts index 5272ee43da..08d262ac4d 100644 --- a/src/Framework/Framework/Resources/Scripts/validation/validators.ts +++ b/src/Framework/Framework/Resources/Scripts/validation/validators.ts @@ -5,8 +5,22 @@ export type DotvvmValidator = { } export const required: DotvvmValidator = { - isValid(value: any): boolean { - return !isEmpty(value); + isValid(value: any, context, property): boolean { + if (isEmpty(value)) { + return false; + } + + // for value types, the observable may still hold the previous value - we need to look at element states if there is any element with invalid state and empty value + const metadata = getValidationMetadata(property); + if (metadata) { + for (const metaElement of metadata) { + if (!metaElement.elementValidationState && "value" in metaElement.element && isEmpty((metaElement.element as any)["value"])) { + return false; + } + } + } + + return true; } } export const regex: DotvvmValidator = { @@ -40,8 +54,12 @@ export const enforceClientFormat: DotvvmValidator = { const metadata = getValidationMetadata(property); if (metadata) { for (const metaElement of metadata) { - if (!metaElement.elementValidationState) { - valid = false; + if (!metaElement.elementValidationState && "value" in metaElement.element) { + // do not emit the error if the element value is empty and it is allowed to be empty + const elementValue = (metaElement.element as any).value as string; + if ((!allowEmptyStringOrWhitespaces && isEmpty(elementValue)) || (!allowEmptyString && elementValue === "") || !isEmpty(elementValue)) { + valid = false; + } } } } diff --git a/src/Samples/Common/Views/FeatureSamples/Validation/DateTimeValidation.dothtml b/src/Samples/Common/Views/FeatureSamples/Validation/DateTimeValidation.dothtml index 6b3df5c7bd..6c64d72fa8 100644 --- a/src/Samples/Common/Views/FeatureSamples/Validation/DateTimeValidation.dothtml +++ b/src/Samples/Common/Views/FeatureSamples/Validation/DateTimeValidation.dothtml @@ -16,22 +16,27 @@

Nullable DateTime with disabled DotvvmClientFormat: +

Nullable DateTime: +

Nullable DateTime with Required: +

Non-nullable: +

Non-nullable with Required: +

diff --git a/src/Samples/Tests/Tests/Feature/ValidationTests.cs b/src/Samples/Tests/Tests/Feature/ValidationTests.cs index 3f11fb1882..010985947b 100644 --- a/src/Samples/Tests/Tests/Feature/ValidationTests.cs +++ b/src/Samples/Tests/Tests/Feature/ValidationTests.cs @@ -94,6 +94,7 @@ public void Feature_Validation_DateTimeValidation() var button = browser.First("input[type=button]"); var textBoxes = browser.FindElements("input[type=text]").ThrowIfDifferentCountThan(5); + var validators = browser.FindElements("span[data-control=validator]").ThrowIfDifferentCountThan(5); void testValue(string value) { @@ -103,37 +104,38 @@ void testValue(string value) } button.Click(); } - void assertValidators(params bool[] states) + void assertValidators(params string[] errors) { - if (states.Length != textBoxes.Count) + if (errors.Length != textBoxes.Count) { - throw new ArgumentException("states"); + throw new ArgumentException("errors"); } for (int i = 0; i < textBoxes.Count; i++) { - if (states[i]) + if (!string.IsNullOrEmpty(errors[i])) { AssertUI.HasClass(textBoxes[i], "has-error"); } else { AssertUI.HasNotClass(textBoxes[i], "has-error"); + AssertUI.TextEquals(validators[i], errors[i]); } } } // empty field - Required validators should be triggered testValue(""); - assertValidators(false, false, true, true, true); + assertValidators("", "", "The Value3 field is required.", "Cannot coerce 'null' to type 'DateTime'.", "The Value5 field is required."); // correct value - no error testValue("06/14/2017 8:10:35 AM"); - assertValidators(false, false, false, false, false); + assertValidators("", "", "", "", ""); // incorrect format - all fields should trigger errors except the one where DotvvmClientFormat is disabled testValue("06-14-2017"); - assertValidators(false, true, true, true, true); + assertValidators("", "The field Value2 is invalid.", "The Value3 field is required. The field Value3 is invalid.", "The field Value4 is invalid.", "The field Value5 is invalid."); }); }