diff --git a/src/Framework/Framework/ViewModel/Validation/ViewModelValidationRuleTranslator.cs b/src/Framework/Framework/ViewModel/Validation/ViewModelValidationRuleTranslator.cs index aa1a937d33..56ddfbffe9 100644 --- a/src/Framework/Framework/ViewModel/Validation/ViewModelValidationRuleTranslator.cs +++ b/src/Framework/Framework/ViewModel/Validation/ViewModelValidationRuleTranslator.cs @@ -25,8 +25,8 @@ public virtual IEnumerable TranslateValidationR switch (attribute) { - case RequiredAttribute _: - validationRule.ClientRuleName = "required"; + case RequiredAttribute required: + validationRule.ClientRuleName = required.AllowEmptyStrings ? "notnull" : "required"; break; case RegularExpressionAttribute regularExpressionAttr: validationRule.ClientRuleName = "regularExpression"; diff --git a/src/Samples/Common/ViewModels/FeatureSamples/Validation/ClientSideRulesViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/Validation/ClientSideRulesViewModel.cs new file mode 100644 index 0000000000..1ac449d1e6 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/Validation/ClientSideRulesViewModel.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using DotVVM.Framework.ViewModel; + +namespace DotVVM.Samples.BasicSamples.ViewModels.FeatureSamples.Validation +{ + public class ClientSideRulesViewModel : DotvvmViewModelBase + { + [Range(10, 20)] + public int? RangeInt32 { get; set; } = null; + + [Range(12.345678901, double.PositiveInfinity)] + public double? RangeFloat64 { get; set; } = null; + + [Range(typeof(DateOnly), "2015-01-01", "2015-12-31")] + public DateOnly? RangeDate { get; set; } = null; + + [Required(AllowEmptyStrings = false)] + public string RequiredString { get; set; } = "abc"; + + [Required(AllowEmptyStrings = true)] + public string NotNullString { get; set; } = ""; + + [EmailAddress] + public string EmailString { get; set; } = "test@something.somewhere"; + + public string Result { get; set; } + [Bind(Direction.ServerToClientFirstRequest)] + public int ServerRequestCount { get; set; } + [Bind(Direction.ServerToClientFirstRequest)] + public int ClientPostbackCount { get; set; } + + public void Command() + { + Result = "Valid"; + } + + } +} diff --git a/src/Samples/Common/Views/FeatureSamples/Validation/ClientSideRules.dothtml b/src/Samples/Common/Views/FeatureSamples/Validation/ClientSideRules.dothtml new file mode 100644 index 0000000000..aad1b11f0f --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/Validation/ClientSideRules.dothtml @@ -0,0 +1,87 @@ +@viewModel DotVVM.Samples.BasicSamples.ViewModels.FeatureSamples.Validation.ClientSideRulesViewModel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Int32 Range(10, 20) + + Set null +
Float64 Range(12.345678901, Inf) + + Set null +
Date Range in 2015 + + + Set null +
String Required + + Set null +
String NotNull + + Set null +
String Email + + Set null +
+
+ +

+ Server requests: , + Logical postbacks: +

+

+ Result: +

+

+ + + +

+ + + dotvvm.events.postbackHandlersStarted.subscribe(() => { + dotvvm.patchState({ Result: "" }) + }) + dotvvm.events.postbackCommitInvoked.subscribe(() => { + dotvvm.patchState({ ServerRequestCount: dotvvm.state.ServerRequestCount + 1 }) + }) + dotvvm.events.afterPostback.subscribe(() => { + dotvvm.patchState({ ClientPostbackCount: dotvvm.state.ClientPostbackCount + 1 }) + }) + const validationSupressor = () => { + for (const e of [...dotvvm.validation.errors]) { + e.detach() + } + } + + + + diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index 1801de683b..e8de8526c4 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -363,6 +363,7 @@ public partial class SamplesRouteUrls public const string FeatureSamples_StringInterpolation_StringInterpolation = "FeatureSamples/StringInterpolation/StringInterpolation"; public const string FeatureSamples_UsageValidation_OverrideValidation = "FeatureSamples/UsageValidation/OverrideValidation"; public const string FeatureSamples_Validation_ClientSideObservableUpdate = "FeatureSamples/Validation/ClientSideObservableUpdate"; + public const string FeatureSamples_Validation_ClientSideRules = "FeatureSamples/Validation/ClientSideRules"; public const string FeatureSamples_Validation_CustomValidation = "FeatureSamples/Validation/CustomValidation"; public const string FeatureSamples_Validation_DateTimeValidation = "FeatureSamples/Validation/DateTimeValidation"; public const string FeatureSamples_Validation_DateTimeValidation_NullableDateTime = "FeatureSamples/Validation/DateTimeValidation_NullableDateTime"; diff --git a/src/Samples/Tests/Tests/Feature/ValidationClientSideRulesTests.cs b/src/Samples/Tests/Tests/Feature/ValidationClientSideRulesTests.cs new file mode 100644 index 0000000000..520b779a2d --- /dev/null +++ b/src/Samples/Tests/Tests/Feature/ValidationClientSideRulesTests.cs @@ -0,0 +1,203 @@ +using System; +using System.Globalization; +using System.Linq; +using DotVVM.Samples.Tests.Base; +using DotVVM.Testing.Abstractions; +using OpenQA.Selenium; +using Riganti.Selenium.Core; +using Riganti.Selenium.Core.Abstractions; +using Riganti.Selenium.DotVVM; +using Xunit; +using Xunit.Abstractions; + +namespace DotVVM.Samples.Tests.Feature +{ + public class ValidationClientSideRulesTests : AppSeleniumTest + { + public ValidationClientSideRulesTests(ITestOutputHelper output) : base(output) + { + } + + (int requests, int postbacks) PostbacksCounts(IBrowserWrapper browser) + { + var requestCount = browser.Single("request-count", SelectByDataUi).GetInnerText(); + var postbackCount = browser.Single("postback-count", SelectByDataUi).GetInnerText(); + + return (int.Parse(requestCount), int.Parse(postbackCount)); + } + void ExpectNoErrors(IBrowserWrapper browser) + { + var count = PostbacksCounts(browser); + browser.Single("submit-button", SelectByDataUi).Click(); + try + { + AssertUI.InnerTextEquals(browser.Single("postback-count", SelectByDataUi), (count.postbacks + 1).ToString()); + AssertUI.InnerTextEquals(browser.Single("request-count", SelectByDataUi), (count.requests + 1).ToString()); + AssertUI.InnerTextEquals(browser.Single("result", SelectByDataUi), "Valid"); + Assert.Empty(browser.FindElements("ul[data-ui=errors] > li")); + } + catch (Exception e) + { + var errors = browser.FindElements("ul[data-ui=errors] > li").Select(t => t.GetInnerText()).ToArray(); + if (errors.Length > 0) + { + throw new Exception($"Validation failed with errors: {string.Join(", ", errors)}", e); + } + throw; + } + } + + void ExpectErrors(IBrowserWrapper browser, string[] expectedErrors) + { + var count = PostbacksCounts(browser); + // client-side validation + browser.Single("submit-button", SelectByDataUi).Click(); + AssertUI.InnerTextEquals(browser.Single("postback-count", SelectByDataUi), (count.postbacks + 1).ToString()); + var errors = browser.WaitFor(_ => { + var c = browser.FindElements("ul[data-ui=errors] > li"); + Assert.NotEmpty(c); + return c; + }).Select(t => t.GetInnerText()).ToArray(); + AssertUI.InnerTextEquals(browser.Single("request-count", SelectByDataUi), count.requests.ToString(), failureMessage: $"Validation didn't run client-side (got errors: {string.Join(", ", errors)}."); + AssertUI.InnerTextEquals(browser.Single("result", SelectByDataUi), ""); + Assert.Equal(expectedErrors.OrderBy(t => t).ToArray(), errors.OrderBy(t => t).ToArray()); + + // server-side validation + browser.Single("submit-button-serverside", SelectByDataUi).Click(); + AssertUI.InnerTextEquals(browser.Single("postback-count", SelectByDataUi), (count.postbacks + 2).ToString()); + AssertUI.InnerTextEquals(browser.Single("request-count", SelectByDataUi), (count.requests + 1).ToString()); + AssertUI.InnerTextEquals(browser.Single("result", SelectByDataUi), ""); + errors = browser.WaitFor(_ => { + var c = browser.FindElements("ul[data-ui=errors] > li"); + Assert.NotEmpty(c); + return c; + }).Select(t => t.GetInnerText()).ToArray(); + Assert.Equal(expectedErrors.OrderBy(t => t).ToArray(), errors.OrderBy(t => t).ToArray()); + } + + void SetValue(IBrowserWrapper browser, string property, string value) + { + if (value == null) + browser.Single($"setnull-{property}", SelectByDataUi).Click(); + else + browser.Single($"textbox-{property}", SelectByDataUi).Clear().SendKeys(value); + } + + [Theory] + [InlineData("10", null)] + [InlineData("20", null)] + [InlineData("", null)] + [InlineData(null, null)] + [InlineData("-1", "The field RangeInt32 must be between 10 and 20.")] + [InlineData("0", "The field RangeInt32 must be between 10 and 20.")] + public void Feature_Validation_ClienSideRules_RangeInt32(string value, string error) + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Validation_ClientSideRules); + + SetValue(browser, "RangeInt32", value); + if (error == null) + ExpectNoErrors(browser); + else + ExpectErrors(browser, new[] { error }); + }); + } + + [Theory] + [InlineData("12.345678901", null)] + [InlineData("Infinity", null)] + [InlineData("3e300", null)] + [InlineData(null, null)] + [InlineData("12.345678900", "The field RangeFloat64 must be between 12.345678901 and ∞.")] + // [InlineData("-Infinity", "The field RangeFloat64 must be between 12.345678901 and ∞.")] + public void Feature_Validation_ClienSideRules_RangeFloat64(string value, string error) + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Validation_ClientSideRules); + + SetValue(browser, "RangeFloat64", value); + if (error == null) + ExpectNoErrors(browser); + else + ExpectErrors(browser, new[] { error }); + }); + } + + [Theory] + [InlineData("2015-01-01", null)] + [InlineData("2015-12-31", null)] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("2024-01-01", "The field RangeDate must be between 1/1/2015 and 12/31/2015.")] + public void Feature_Validation_ClienSideRules_RangeDate(string value, string error) + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Validation_ClientSideRules); + + SetValue(browser, "RangeDate", value); + if (error == null) + ExpectNoErrors(browser); + else + ExpectErrors(browser, new[] { error }); + }); + } + + [Theory] + [InlineData("12", null)] + [InlineData(".", null)] + [InlineData("", "The RequiredString field is required.")] + [InlineData(" ", "The RequiredString field is required.")] + [InlineData(null, "The RequiredString field is required.")] + public void Feature_Validation_ClienSideRules_RequiredString(string value, string error) + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Validation_ClientSideRules); + + SetValue(browser, "RequiredString", value); + if (error == null) + ExpectNoErrors(browser); + else + ExpectErrors(browser, new[] { error }); + }); + } + + [Theory] + [InlineData("12", null)] + [InlineData(".", null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData(null, "The NotNullString field is required.")] + public void Feature_Validation_ClienSideRules_NotNullString(string value, string error) + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Validation_ClientSideRules); + + SetValue(browser, "NotNullString", value); + if (error == null) + ExpectNoErrors(browser); + else + ExpectErrors(browser, new[] { error }); + }); + } + + [Theory] + [InlineData("a@b.c", null)] + [InlineData(null, null)] + [InlineData("@handle", "The EmailString field is not a valid e-mail address.")] + [InlineData("incomplete@", "The EmailString field is not a valid e-mail address.")] + [InlineData("", "The EmailString field is not a valid e-mail address.")] + public void Feature_Validation_ClienSideRules_EmailString(string value, string error) + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Validation_ClientSideRules); + + SetValue(browser, "EmailString", value); + if (error == null) + ExpectNoErrors(browser); + else + ExpectErrors(browser, new[] { error }); + }); + } + + } +}