From ce876fff192075377544c7aeaf9dc86bfcb44c29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:57:17 +0000 Subject: [PATCH 1/4] Initial plan From 8ad0a6c61629eacc5ff16da73d9e9c504682ae7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:07:30 +0000 Subject: [PATCH 2/4] Add ValidationErrorsCount control with tests - Create ValidationErrorsCount.cs control that displays validation error count - Support for IncludeErrorsFromChildren and IncludeErrorsFromTarget properties - Support for InvalidCssClass property to apply CSS class when errors exist - Support for WrapperTagName property (default: span) - Add JavaScript binding handler in validation.ts - Add unit tests for the new control Co-authored-by: tomasherceg <5599524+tomasherceg@users.noreply.github.com> --- .../Controls/ValidationErrorsCount.cs | 104 ++++++++++++++++++ .../Scripts/validation/validation.ts | 31 ++++++ .../ValidationErrorsCountTests.cs | 70 ++++++++++++ ....ValidationErrorsCount_BasicRendering.html | 23 ++++ ...ionErrorsCount_WithValidationDisabled.html | 8 ++ 5 files changed, 236 insertions(+) create mode 100644 src/Framework/Framework/Controls/ValidationErrorsCount.cs create mode 100644 src/Tests/ControlTests/ValidationErrorsCountTests.cs create mode 100644 src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_BasicRendering.html create mode 100644 src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_WithValidationDisabled.html diff --git a/src/Framework/Framework/Controls/ValidationErrorsCount.cs b/src/Framework/Framework/Controls/ValidationErrorsCount.cs new file mode 100644 index 0000000000..202e9d59ee --- /dev/null +++ b/src/Framework/Framework/Controls/ValidationErrorsCount.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DotVVM.Framework.Binding; +using DotVVM.Framework.Runtime; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Framework.Controls +{ + /// + /// Displays the count of validation errors from the current Validation.Target. + /// + [ControlMarkupOptions(AllowContent = false)] + public class ValidationErrorsCount : HtmlGenericControl + { + /// + /// Initializes a new instance of the class. + /// + public ValidationErrorsCount() + : base("span", false) + { + } + + /// + /// Gets or sets whether the errors from child objects in the viewmodel will be counted too. + /// + [MarkupOptions(AllowBinding = false)] + public bool IncludeErrorsFromChildren + { + get { return (bool)GetValue(IncludeErrorsFromChildrenProperty)!; } + set { SetValue(IncludeErrorsFromChildrenProperty, value); } + } + public static readonly DotvvmProperty IncludeErrorsFromChildrenProperty + = DotvvmProperty.Register(c => c.IncludeErrorsFromChildren, true); + + /// + /// Gets or sets whether the errors from the object will be counted too. + /// + [MarkupOptions(AllowBinding = false)] + public bool IncludeErrorsFromTarget + { + get { return (bool)GetValue(IncludeErrorsFromTargetProperty)!; } + set { SetValue(IncludeErrorsFromTargetProperty, value); } + } + public static readonly DotvvmProperty IncludeErrorsFromTargetProperty + = DotvvmProperty.Register(c => c.IncludeErrorsFromTarget, true); + + /// + /// Gets or sets the CSS class that will be applied to the element when there is at least one validation error. + /// + public string? InvalidCssClass + { + get { return (string?)GetValue(InvalidCssClassProperty); } + set { SetValue(InvalidCssClassProperty, value); } + } + public static readonly DotvvmProperty InvalidCssClassProperty + = DotvvmProperty.Register(c => c.InvalidCssClass, null); + + /// + /// Gets or sets the name of the tag that wraps the control. + /// + [MarkupOptions(AllowBinding = false)] + public string WrapperTagName + { + get { return (string)GetValue(WrapperTagNameProperty)!; } + set { SetValue(WrapperTagNameProperty, value); } + } + public static readonly DotvvmProperty WrapperTagNameProperty + = DotvvmProperty.Register(c => c.WrapperTagName, "span"); + + protected internal override void OnPreRender(IDotvvmRequestContext context) + { + TagName = WrapperTagName; + base.OnPreRender(context); + } + + /// + /// Adds all attributes that should be added to the control begin tag. + /// + protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequestContext context) + { + base.AddAttributesToRender(writer, context); + + if (false.Equals(this.GetValue(Validation.EnabledProperty))) + { + return; + } + + var expression = this.GetValueBinding(Validation.TargetProperty)?.GetKnockoutBindingExpression(this) ?? "dotvvm.viewModelObservables.root"; + + var group = new KnockoutBindingGroup(); + { + group.Add("target", expression); + group.Add("includeErrorsFromChildren", IncludeErrorsFromChildren.ToString().ToLowerInvariant()); + group.Add("includeErrorsFromTarget", IncludeErrorsFromTarget.ToString().ToLowerInvariant()); + if (!string.IsNullOrEmpty(InvalidCssClass)) + { + group.Add("invalidCssClass", KnockoutHelper.MakeStringLiteral(InvalidCssClass)); + } + } + writer.AddKnockoutDataBind("dotvvm-validationErrorsCount", group); + } + } +} diff --git a/src/Framework/Framework/Resources/Scripts/validation/validation.ts b/src/Framework/Framework/Resources/Scripts/validation/validation.ts index 64d707d909..49450f5913 100644 --- a/src/Framework/Framework/Resources/Scripts/validation/validation.ts +++ b/src/Framework/Framework/Resources/Scripts/validation/validation.ts @@ -22,6 +22,13 @@ type ValidationSummaryBinding = { hideWhenValid: boolean } +type ValidationErrorsCountBinding = { + target: KnockoutObservable, + includeErrorsFromChildren: boolean, + includeErrorsFromTarget: boolean, + invalidCssClass?: string +} + type DotvvmValidationErrorsChangedEventArgs = Partial & { readonly allErrors: ValidationError[] } @@ -126,6 +133,30 @@ export function init() { }); } } + + // ValidationErrorsCount + ko.bindingHandlers["dotvvm-validationErrorsCount"] = { + init: (element: HTMLElement, valueAccessor: () => ValidationErrorsCountBinding) => { + const binding = valueAccessor(); + + validationErrorsChanged.subscribe(_ => { + const errors = getValidationErrors( + binding.target, + binding.includeErrorsFromChildren, + binding.includeErrorsFromTarget + ); + element.innerText = errors.length.toString(); + + if (binding.invalidCssClass) { + if (errors.length > 0) { + element.classList.add(binding.invalidCssClass); + } else { + element.classList.remove(binding.invalidCssClass); + } + } + }); + } + } } function validateViewModel(viewModel: any, path: string): void { diff --git a/src/Tests/ControlTests/ValidationErrorsCountTests.cs b/src/Tests/ControlTests/ValidationErrorsCountTests.cs new file mode 100644 index 0000000000..2651c2c4c0 --- /dev/null +++ b/src/Tests/ControlTests/ValidationErrorsCountTests.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using CheckTestOutput; +using DotVVM.Framework.Compilation; +using DotVVM.Framework.Controls; +using DotVVM.Framework.Tests.Binding; +using DotVVM.Framework.ViewModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using DotVVM.Framework.Testing; + +namespace DotVVM.Framework.Tests.ControlTests +{ + [TestClass] + public class ValidationErrorsCountTests + { + static readonly ControlTestHelper cth = new ControlTestHelper(); + OutputChecker check = new OutputChecker("testoutputs"); + + [TestMethod] + public async Task ValidationErrorsCount_BasicRendering() + { + var r = await cth.RunPage(typeof(TestViewModel), @" + @viewModel DotVVM.Framework.Tests.ControlTests.ValidationErrorsCountTests.TestViewModel + + + + + + + + + + + + + "); + + check.CheckString(r.FormattedHtml, fileExtension: "html"); + } + + [TestMethod] + public async Task ValidationErrorsCount_WithValidationDisabled() + { + var r = await cth.RunPage(typeof(TestViewModel), @" + @viewModel DotVVM.Framework.Tests.ControlTests.ValidationErrorsCountTests.TestViewModel + + + "); + + check.CheckString(r.FormattedHtml, fileExtension: "html"); + } + + public class TestViewModel : DotvvmViewModelBase + { + public PersonModel Person { get; set; } = new PersonModel(); + } + + public class PersonModel + { + [Required] + public string? Name { get; set; } + + [Required] + [EmailAddress] + public string? Email { get; set; } + } + } +} diff --git a/src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_BasicRendering.html b/src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_BasicRendering.html new file mode 100644 index 0000000000..83e5ccab95 --- /dev/null +++ b/src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_BasicRendering.html @@ -0,0 +1,23 @@ + + + + @viewModel DotVVM.Framework.Tests.ControlTests.ValidationErrorsCountTests.TestViewModel + + + + + + + + + + +
+ + + + + + + + diff --git a/src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_WithValidationDisabled.html b/src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_WithValidationDisabled.html new file mode 100644 index 0000000000..62db2d323d --- /dev/null +++ b/src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_WithValidationDisabled.html @@ -0,0 +1,8 @@ + + + + @viewModel DotVVM.Framework.Tests.ControlTests.ValidationErrorsCountTests.TestViewModel + + + + From 65c7dc7124e16a1cc29a05a0f44433440592c9b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:13:23 +0000 Subject: [PATCH 3/4] Address code review feedback - Add initial execution of updateErrorCount in JS to show count immediately - Add documentation clarifying IncludeErrorsFromChildren default differs from ValidationSummary Co-authored-by: tomasherceg <5599524+tomasherceg@users.noreply.github.com> --- .../Framework/Controls/ValidationErrorsCount.cs | 3 ++- .../Resources/Scripts/validation/validation.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Framework/Framework/Controls/ValidationErrorsCount.cs b/src/Framework/Framework/Controls/ValidationErrorsCount.cs index 202e9d59ee..c025a5405a 100644 --- a/src/Framework/Framework/Controls/ValidationErrorsCount.cs +++ b/src/Framework/Framework/Controls/ValidationErrorsCount.cs @@ -22,7 +22,8 @@ public ValidationErrorsCount() } /// - /// Gets or sets whether the errors from child objects in the viewmodel will be counted too. + /// Gets or sets whether the errors from child objects in the viewmodel will be counted too. + /// Default is true (unlike which defaults to false). /// [MarkupOptions(AllowBinding = false)] public bool IncludeErrorsFromChildren diff --git a/src/Framework/Framework/Resources/Scripts/validation/validation.ts b/src/Framework/Framework/Resources/Scripts/validation/validation.ts index 49450f5913..5022836f26 100644 --- a/src/Framework/Framework/Resources/Scripts/validation/validation.ts +++ b/src/Framework/Framework/Resources/Scripts/validation/validation.ts @@ -139,7 +139,7 @@ export function init() { init: (element: HTMLElement, valueAccessor: () => ValidationErrorsCountBinding) => { const binding = valueAccessor(); - validationErrorsChanged.subscribe(_ => { + const updateErrorCount = () => { const errors = getValidationErrors( binding.target, binding.includeErrorsFromChildren, @@ -154,6 +154,14 @@ export function init() { element.classList.remove(binding.invalidCssClass); } } + }; + + // Update initially to show current count + updateErrorCount(); + + // Subscribe to validation errors changes + validationErrorsChanged.subscribe(_ => { + updateErrorCount(); }); } } From 3433bdff6139b90d267a6f2aebbf0d0d556e1e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Thu, 22 Jan 2026 15:24:12 +0100 Subject: [PATCH 4/4] Manual fixes in Copilot code --- .../Controls/ValidationErrorsCount.cs | 17 ++----------- src/Framework/Framework/Controls/Validator.cs | 5 ++++ .../Scripts/validation/validation.ts | 25 ++++++++++++------- ...rorsFromTarget_PropertyPathNotNull.dothtml | 11 +++++--- .../ValidationErrorsCountTests.cs | 2 -- ....ValidationErrorsCount_BasicRendering.html | 3 --- 6 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/Framework/Framework/Controls/ValidationErrorsCount.cs b/src/Framework/Framework/Controls/ValidationErrorsCount.cs index c025a5405a..cbb0ee9ece 100644 --- a/src/Framework/Framework/Controls/ValidationErrorsCount.cs +++ b/src/Framework/Framework/Controls/ValidationErrorsCount.cs @@ -46,17 +46,6 @@ public bool IncludeErrorsFromTarget public static readonly DotvvmProperty IncludeErrorsFromTargetProperty = DotvvmProperty.Register(c => c.IncludeErrorsFromTarget, true); - /// - /// Gets or sets the CSS class that will be applied to the element when there is at least one validation error. - /// - public string? InvalidCssClass - { - get { return (string?)GetValue(InvalidCssClassProperty); } - set { SetValue(InvalidCssClassProperty, value); } - } - public static readonly DotvvmProperty InvalidCssClassProperty - = DotvvmProperty.Register(c => c.InvalidCssClass, null); - /// /// Gets or sets the name of the tag that wraps the control. /// @@ -94,12 +83,10 @@ protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequest group.Add("target", expression); group.Add("includeErrorsFromChildren", IncludeErrorsFromChildren.ToString().ToLowerInvariant()); group.Add("includeErrorsFromTarget", IncludeErrorsFromTarget.ToString().ToLowerInvariant()); - if (!string.IsNullOrEmpty(InvalidCssClass)) - { - group.Add("invalidCssClass", KnockoutHelper.MakeStringLiteral(InvalidCssClass)); - } } writer.AddKnockoutDataBind("dotvvm-validationErrorsCount", group); + + Validator.AddValidationOptionsBinding(writer, this); } } } diff --git a/src/Framework/Framework/Controls/Validator.cs b/src/Framework/Framework/Controls/Validator.cs index ea7140ee4c..8aafa248c5 100644 --- a/src/Framework/Framework/Controls/Validator.cs +++ b/src/Framework/Framework/Controls/Validator.cs @@ -110,6 +110,11 @@ private static void AddValidatedValue(IHtmlWriter writer, IDotvvmRequestContext } // render options + AddValidationOptionsBinding(writer, control); + } + + public static void AddValidationOptionsBinding(IHtmlWriter writer, DotvvmControl control) + { var bindingGroup = new KnockoutBindingGroup(); foreach (var property in ValidationOptionProperties) { diff --git a/src/Framework/Framework/Resources/Scripts/validation/validation.ts b/src/Framework/Framework/Resources/Scripts/validation/validation.ts index 5022836f26..ca66676b73 100644 --- a/src/Framework/Framework/Resources/Scripts/validation/validation.ts +++ b/src/Framework/Framework/Resources/Scripts/validation/validation.ts @@ -136,7 +136,7 @@ export function init() { // ValidationErrorsCount ko.bindingHandlers["dotvvm-validationErrorsCount"] = { - init: (element: HTMLElement, valueAccessor: () => ValidationErrorsCountBinding) => { + init: (element: HTMLElement, valueAccessor: () => ValidationErrorsCountBinding, allBindingsAccessor?: KnockoutAllBindingsAccessor) => { const binding = valueAccessor(); const updateErrorCount = () => { @@ -147,12 +147,9 @@ export function init() { ); element.innerText = errors.length.toString(); - if (binding.invalidCssClass) { - if (errors.length > 0) { - element.classList.add(binding.invalidCssClass); - } else { - element.classList.remove(binding.invalidCssClass); - } + const validationOptions = allBindingsAccessor!.get("dotvvm-validationOptions"); + if (validationOptions) { + applyValidatorActionsCore(element, errors, validationOptions); } }; @@ -160,8 +157,10 @@ export function init() { updateErrorCount(); // Subscribe to validation errors changes - validationErrorsChanged.subscribe(_ => { - updateErrorCount(); + validationErrorsChanged.subscribe(updateErrorCount); + + ko.utils.domNodeDisposal.addDisposeCallback(element, () => { + validationErrorsChanged.unsubscribe(updateErrorCount); }); } } @@ -413,6 +412,14 @@ function applyValidatorActions( validatorOptions: any): void { const errors = getErrors(observable); + applyValidatorActionsCore(validator, errors, validatorOptions) +} + +function applyValidatorActionsCore( + validator: HTMLElement, + errors: ValidationError[], + validatorOptions: any): void { + const errorMessages = errors.map(v => v.errorMessage); for (const option of keys(validatorOptions)) { elementActions[option]( diff --git a/src/Samples/Common/Views/ControlSamples/ValidationSummary/IncludeErrorsFromTarget_PropertyPathNotNull.dothtml b/src/Samples/Common/Views/ControlSamples/ValidationSummary/IncludeErrorsFromTarget_PropertyPathNotNull.dothtml index 4e6a83b967..76f74305cb 100644 --- a/src/Samples/Common/Views/ControlSamples/ValidationSummary/IncludeErrorsFromTarget_PropertyPathNotNull.dothtml +++ b/src/Samples/Common/Views/ControlSamples/ValidationSummary/IncludeErrorsFromTarget_PropertyPathNotNull.dothtml @@ -9,12 +9,12 @@
+ data-ui="validationSummary" /> +
- - diff --git a/src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_BasicRendering.html b/src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_BasicRendering.html index 83e5ccab95..0871366531 100644 --- a/src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_BasicRendering.html +++ b/src/Tests/ControlTests/testoutputs/ValidationErrorsCountTests.ValidationErrorsCount_BasicRendering.html @@ -8,9 +8,6 @@ - - -