diff --git a/src/Framework/Framework/Controls/ModalDialog.cs b/src/Framework/Framework/Controls/ModalDialog.cs
new file mode 100644
index 0000000000..c1bbf9d1c7
--- /dev/null
+++ b/src/Framework/Framework/Controls/ModalDialog.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Net;
+using System.Text;
+using DotVVM.Framework.Binding;
+using DotVVM.Framework.Binding.Expressions;
+using DotVVM.Framework.Hosting;
+using DotVVM.Framework.ResourceManagement;
+using Newtonsoft.Json;
+
+namespace DotVVM.Framework.Controls
+{
+ ///
+ /// Renders a HTML native dialog element, it is opened using the showModal function when the property is set to true
+ ///
+ ///
+ /// * Non-modal dialogs may be simply binding the attribute of the HTML dialog element
+ /// * The dialog may be closed by button with formmethod="dialog", when ESC is pressed, or when the backdrop is clicked if = true
+ ///
+ [ControlMarkupOptions()]
+ public class ModalDialog : HtmlGenericControl
+ {
+ public ModalDialog()
+ : base("dialog", false)
+ {
+ }
+
+ /// A value indicating whether the dialog is open. The value can either be a boolean or an object (not false or not null -> shown). On close, the value is written back into the Open binding.
+ [MarkupOptions(AllowHardCodedValue = false)]
+ public object? Open
+ {
+ get { return GetValue(OpenProperty); }
+ set { SetValue(OpenProperty, value); }
+ }
+ public static readonly DotvvmProperty OpenProperty =
+ DotvvmProperty.Register(nameof(Open), null);
+
+ /// Add an event handler which closes the dialog when the backdrop is clicked.
+ public bool CloseOnBackdropClick
+ {
+ get { return (bool?)GetValue(CloseOnBackdropClickProperty) ?? false; }
+ set { SetValue(CloseOnBackdropClickProperty, value); }
+ }
+ public static readonly DotvvmProperty CloseOnBackdropClickProperty =
+ DotvvmProperty.Register(nameof(CloseOnBackdropClick), false);
+
+ /// Triggered when the dialog is closed. Called only if it was closed by user input, not by change.
+ public Command? Close
+ {
+ get { return (Command?)GetValue(CloseProperty); }
+ set { SetValue(CloseProperty, value); }
+ }
+ public static readonly DotvvmProperty CloseProperty =
+ DotvvmProperty.Register(nameof(Close));
+
+ protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequestContext context)
+ {
+ var valueBinding = GetValueBinding(OpenProperty);
+ if (valueBinding is {})
+ {
+ writer.AddKnockoutDataBind("dotvvm-modal-open", this, valueBinding);
+ }
+ else if (!(Open is false or null))
+ {
+ // we have to use the binding handler instead of `open` attribute, because we need to call the showModal function
+ writer.AddKnockoutDataBind("dotvvm-modal-open", "true");
+ }
+
+ if (GetValueOrBinding(CloseOnBackdropClickProperty) is {} x && !x.ValueEquals(false))
+ {
+ writer.AddKnockoutDataBind("dotvvm-modal-backdrop-close", x.GetJsExpression(this));
+ }
+
+ if (GetCommandBinding(CloseProperty) is {} close)
+ {
+ var postbackScript = KnockoutHelper.GenerateClientPostBackScript(nameof(Close), close, this, returnValue: null);
+ writer.AddAttribute("onclose", "if (event.target.returnValue!=\"_dotvvm_modal_supress_onclose\") {" + postbackScript + "}");
+ }
+
+ base.AddAttributesToRender(writer, context);
+ }
+ }
+}
diff --git a/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts b/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts
index ccacf44549..6480b3a0a2 100644
--- a/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts
+++ b/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts
@@ -10,6 +10,7 @@ import gridviewdataset from './gridviewdataset'
import namedCommand from './named-command'
import fileUpload from './file-upload'
import jsComponents from './js-component'
+import modalDialog from './modal-dialog'
type KnockoutHandlerDictionary = {
[name: string]: KnockoutBindingHandler
@@ -26,7 +27,8 @@ const allHandlers: KnockoutHandlerDictionary = {
...gridviewdataset,
...namedCommand,
...fileUpload,
- ...jsComponents
+ ...jsComponents,
+ ...modalDialog
}
export default allHandlers
diff --git a/src/Framework/Framework/Resources/Scripts/binding-handlers/modal-dialog.ts b/src/Framework/Framework/Resources/Scripts/binding-handlers/modal-dialog.ts
new file mode 100644
index 0000000000..9d25f3a0c1
--- /dev/null
+++ b/src/Framework/Framework/Resources/Scripts/binding-handlers/modal-dialog.ts
@@ -0,0 +1,42 @@
+export default {
+ "dotvvm-modal-open": {
+ init(element: HTMLDialogElement, valueAccessor: () => any) {
+ element.addEventListener("close", () => {
+ const value = valueAccessor();
+ if (ko.isWriteableObservable(value)) {
+ // if the value is object, set it to null
+ value(typeof value.peek() == "boolean" ? false : null)
+ }
+ })
+ },
+ update(element: HTMLDialogElement, valueAccessor: () => any) {
+ const value = ko.unwrap(valueAccessor()),
+ shouldOpen = value != null && value !== false;
+ if (shouldOpen != element.open) {
+ if (shouldOpen) {
+ element.returnValue = "" // reset returnValue, ESC key leaves the old return value
+ element.showModal()
+ } else {
+ element.close("_dotvvm_modal_supress_onclose")
+ }
+ }
+ },
+ },
+ "dotvvm-modal-backdrop-close": {
+ init(element: HTMLDialogElement, valueAccessor: () => any) {
+ // closes the dialog when the backdrop is clicked
+ element.addEventListener("click", (e) => {
+ if (e.target == element) {
+ const elementRect = element.getBoundingClientRect(),
+ x = e.clientX,
+ y = e.clientY;
+ if (x < elementRect.left || x > elementRect.right || y < elementRect.top || y > elementRect.bottom) {
+ if (ko.unwrap(valueAccessor())) {
+ element.close();
+ }
+ }
+ }
+ })
+ }
+ }
+}
diff --git a/src/Samples/Common/ViewModels/FeatureSamples/ModalDialog/ModalDialogViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/ModalDialog/ModalDialogViewModel.cs
new file mode 100644
index 0000000000..f0181260aa
--- /dev/null
+++ b/src/Samples/Common/ViewModels/FeatureSamples/ModalDialog/ModalDialogViewModel.cs
@@ -0,0 +1,42 @@
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Net.Http.Headers;
+using System.Text;
+using DotVVM.Framework.ViewModel;
+
+namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.ModalDialog
+{
+ public class ModalDialogViewModel : DotvvmViewModelBase
+ {
+ public bool Dialog1Shown { get; set; }
+ public bool DialogChained1Shown { get; set; }
+ public bool DialogChained2Shown { get; set; }
+ public bool CloseEventDialogShown { get; set; }
+
+ public int? NullableIntController { get; set; }
+ public string NullableStringController { get; set; }
+
+ public DialogModel DialogWithModel { get; set; } = null;
+
+ public int CloseEventCounter { get; set; } = 0;
+
+ public void ShowDialogWithModel()
+ {
+ DialogWithModel = new DialogModel() { Property = "Hello" };
+ }
+
+ public void CloseDialogWithEvent()
+ {
+ CloseEventDialogShown = false;
+ }
+
+ public class DialogModel
+ {
+ public string Property { get; set; }
+ }
+ }
+
+}
diff --git a/src/Samples/Common/Views/FeatureSamples/ModalDialog/ModalDialog.dothtml b/src/Samples/Common/Views/FeatureSamples/ModalDialog/ModalDialog.dothtml
new file mode 100644
index 0000000000..ac0188eb3a
--- /dev/null
+++ b/src/Samples/Common/Views/FeatureSamples/ModalDialog/ModalDialog.dothtml
@@ -0,0 +1,79 @@
+@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.ModalDialog.ModalDialogViewModel
+
+
+
+
+
+
+
+
+
+
+ Modal dialogs
+
+
+
+
+
+
+
+
+
+
+ Close events:
+
+
+
+
+
+
+
+ This is the first chained modal dialog.
+
+
+
+
+ This is the second chained modal dialog.
+
+
+
+
+ Closing the dialog will increase the counter. Either
+
+ Click the backdrop
+ Press ESC
+ Use staticCommand
+ Use command
+
+
+
+
+
+ Edit this field:
+
+
+
+
+
+
+
+ the number:
+
+
+
+
+ the string:
+
+
+
+
+
diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs
index b7a57dbe57..6b287d9ae9 100644
--- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs
+++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs
@@ -297,6 +297,7 @@ public partial class SamplesRouteUrls
public const string FeatureSamples_MarkupControl_ResourceBindingInControlProperty = "FeatureSamples/MarkupControl/ResourceBindingInControlProperty";
public const string FeatureSamples_MarkupControl_StaticCommandInMarkupControl = "FeatureSamples/MarkupControl/StaticCommandInMarkupControl";
public const string FeatureSamples_MarkupControl_StaticCommandInMarkupControlCallingRegularCommand = "FeatureSamples/MarkupControl/StaticCommandInMarkupControlCallingRegularCommand";
+ public const string FeatureSamples_ModalDialog_ModalDialog = "FeatureSamples/ModalDialog/ModalDialog";
public const string FeatureSamples_NestedMasterPages_Content = "FeatureSamples/NestedMasterPages/Content";
public const string FeatureSamples_NoJsForm_NoJsForm = "FeatureSamples/NoJsForm/NoJsForm";
public const string FeatureSamples_ParameterBinding_OptionalParameterBinding = "FeatureSamples/ParameterBinding/OptionalParameterBinding";
diff --git a/src/Samples/Tests/Tests/Feature/CompilationPageTests.cs b/src/Samples/Tests/Tests/Feature/CompilationPageTests.cs
index a408d3b97e..e5823f40a3 100644
--- a/src/Samples/Tests/Tests/Feature/CompilationPageTests.cs
+++ b/src/Samples/Tests/Tests/Feature/CompilationPageTests.cs
@@ -19,15 +19,18 @@ public void Feature_CompilationPage_SmokeTest()
{
RunInAllBrowsers(browser => {
browser.NavigateToUrl("/_dotvvm/diagnostics/compilation");
- browser.Single("compile-all-button", By.Id).Click();
+ browser.WaitFor(() => { browser.Single("compile-all-button", By.Id).Click(); }, timeout: 15_000);
browser.Single("Routes", SelectByButtonText).Click();
// shows failed pages
- Assert.InRange(browser.FindElements("tbody tr.success").Count, 10, int.MaxValue);
- Assert.InRange(browser.FindElements("tbody tr.failure").Count, 10, int.MaxValue);
browser.WaitFor(() => {
- AssertUI.HasClass(TableRow(browser, "FeatureSamples_CompilationPage_BindingsTestError"), "failure", waitForOptions: WaitForOptions.Disabled);
- }, timeout: 10_000);
+ Assert.InRange(browser.FindElements("tbody tr.success").Count, 10, int.MaxValue);
+ Assert.InRange(browser.FindElements("tbody tr.failure").Count, 10, int.MaxValue);
+ var failedRow = () => TableRow(browser, "FeatureSamples_CompilationPage_BindingsTestError");
+ AssertUI.InnerTextEquals(failedRow().ElementAt("td", 1), "FeatureSamples/CompilationPage/BindingsTestError");
+ AssertUI.InnerTextEquals(failedRow().ElementAt("td", 3), "CompilationFailed");
+ AssertUI.HasClass(failedRow(), "failure", waitForOptions: WaitForOptions.Disabled);
+ }, timeout: 60_000);
AssertUI.HasNotClass(TableRow(browser, "FeatureSamples_CompilationPage_BindingsTest"), "failure");
// shows some errors and warnings
diff --git a/src/Samples/Tests/Tests/Feature/ModalDialogTests.cs b/src/Samples/Tests/Tests/Feature/ModalDialogTests.cs
new file mode 100644
index 0000000000..5984d245ea
--- /dev/null
+++ b/src/Samples/Tests/Tests/Feature/ModalDialogTests.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Linq;
+using DotVVM.Samples.Tests.Base;
+using DotVVM.Testing.Abstractions;
+using OpenQA.Selenium;
+using OpenQA.Selenium.Interactions;
+using Riganti.Selenium.Core;
+using Riganti.Selenium.Core.Abstractions;
+using Riganti.Selenium.Core.Api;
+using Riganti.Selenium.DotVVM;
+using Xunit;
+
+namespace DotVVM.Samples.Tests.Feature
+{
+ public class ModalDialogTests : AppSeleniumTest
+ {
+ public ModalDialogTests(Xunit.Abstractions.ITestOutputHelper output) : base(output)
+ {
+ }
+
+ IElementWrapper OpenDialog(IBrowserWrapper browser, string dialogId)
+ {
+ var button = browser.Single($"btn-open-{dialogId}", SelectByDataUi);
+ AssertUI.IsNotDisplayed(browser.Single(dialogId, SelectByDataUi));
+ button.Click();
+ AssertUI.HasClass(browser.Single($"btn-open-{dialogId}", SelectByDataUi), "button-active");
+ var dialog = browser.Single(dialogId, SelectByDataUi);
+ AssertUI.IsDisplayed(dialog);
+ return dialog;
+ }
+
+ void CheckDialogCloses(IBrowserWrapper browser, string id, Action closeAction)
+ {
+ var dialog = OpenDialog(browser, id);
+ closeAction(dialog);
+ AssertUI.IsNotDisplayed(browser.Single(id, SelectByDataUi));
+ AssertUI.HasNotClass(browser.Single($"btn-open-{id}", SelectByDataUi), "button-active");
+ }
+
+ [Fact]
+ public void Feature_ModalDialog_Simple()
+ {
+ RunInAllBrowsers(browser => {
+ browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_ModalDialog_ModalDialog);
+ CheckDialogCloses(browser, "simple", dialog => dialog.Single("btn-close", SelectByDataUi).Click());
+ CheckDialogCloses(browser, "simple", dialog => {
+ // backdrop click does nothing
+ new Actions(browser.Driver).MoveToLocation(1, 1).Click().Perform();
+ AssertUI.IsDisplayed(dialog);
+
+ dialog.SendKeys(Keys.Escape);
+ AssertUI.IsNotDisplayed(dialog);
+ });
+
+ });
+ }
+
+ [Fact]
+ public void Feature_ModalDialog_Chained()
+ {
+ RunInAllBrowsers(browser => {
+ browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_ModalDialog_ModalDialog);
+ var dialog1 = OpenDialog(browser, "chained1");
+ dialog1.Single("btn-next", SelectByDataUi).Click();
+ AssertUI.IsNotDisplayed(dialog1);
+ var dialog2 = browser.Single("chained2", SelectByDataUi);
+ AssertUI.IsDisplayed(dialog2);
+ dialog2.Single("btn-close", SelectByDataUi).Click();
+ AssertUI.IsNotDisplayed(dialog2);
+ });
+ }
+
+ [Fact]
+ public void Feature_ModalDialog_CloseEvent()
+ {
+ RunInAllBrowsers(browser => {
+ browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_ModalDialog_ModalDialog);
+
+ CheckDialogCloses(browser, "close-event", dialog => dialog.Single("btn-close-staticcommand", SelectByDataUi).Click());
+ AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "0");
+ CheckDialogCloses(browser, "close-event", dialog => dialog.Single("btn-close-command", SelectByDataUi).Click());
+ AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "0");
+ CheckDialogCloses(browser, "close-event", dialog => dialog.Single("btn-close-form", SelectByDataUi).Click());
+ AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "1");
+ CheckDialogCloses(browser, "close-event", dialog => dialog.SendKeys(Keys.Escape));
+ AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "2");
+
+ CheckDialogCloses(browser, "close-event", dialog => {
+ // dialog click
+ this.TestOutput.WriteLine($"Dialog location: {dialog.WebElement.Location}");
+ new Actions(browser.Driver).MoveToLocation(dialog.WebElement.Location.X + 10, dialog.WebElement.Location.Y + 10).Click().Perform();
+ AssertUI.IsDisplayed(dialog);
+ // backdrop click
+ new Actions(browser.Driver).MoveToLocation(1, 1).Click().Perform();
+
+ });
+ AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "3");
+ });
+ }
+
+ [Fact]
+ public void Feature_ModalDialog_ModelController()
+ {
+ RunInAllBrowsers(browser => {
+ browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_ModalDialog_ModalDialog);
+ CheckDialogCloses(browser, "view-model", dialog => dialog.Single("btn-save", SelectByDataUi).Click());
+ CheckDialogCloses(browser, "view-model", dialog => dialog.Single("btn-close", SelectByDataUi).Click());
+ CheckDialogCloses(browser, "int", dialog => dialog.Single("btn-close", SelectByDataUi).Click());
+ // clearing the numeric input puts null into the nullable integer controller
+ CheckDialogCloses(browser, "int", dialog => dialog.Single("editor", SelectByDataUi).Clear());
+ CheckDialogCloses(browser, "string", dialog => dialog.Single("btn-close", SelectByDataUi).Click());
+ });
+ }
+ }
+}
diff --git a/src/Samples/Tests/Tests/Feature/PostbackConcurrencyTests.cs b/src/Samples/Tests/Tests/Feature/PostbackConcurrencyTests.cs
index 16a7a559a5..d7f2d84662 100644
--- a/src/Samples/Tests/Tests/Feature/PostbackConcurrencyTests.cs
+++ b/src/Samples/Tests/Tests/Feature/PostbackConcurrencyTests.cs
@@ -156,16 +156,17 @@ public void Feature_PostbackConcurrency_StressTest_Default()
browser.ElementAt("input[type=button]", 1).Click();
- Thread.Sleep(10000);
- var before = int.Parse(browser.Single(".result-before").GetInnerText().Trim());
- var rejected = int.Parse(browser.Single(".result-rejected").GetInnerText().Trim());
- var after = int.Parse(browser.Single(".result-after").GetInnerText().Trim());
- var value = int.Parse(browser.Single(".result-value").GetInnerText().Trim());
-
- Assert.True(0 < value && value <= 100);
- Assert.Equal(100, before);
- Assert.Equal(100, after);
- Assert.Equal(0, rejected);
+ browser.WaitFor(() => {
+ var before = int.Parse(browser.Single(".result-before").GetInnerText().Trim());
+ var rejected = int.Parse(browser.Single(".result-rejected").GetInnerText().Trim());
+ var after = int.Parse(browser.Single(".result-after").GetInnerText().Trim());
+ var value = int.Parse(browser.Single(".result-value").GetInnerText().Trim());
+
+ Assert.InRange(value, 1, 100);
+ Assert.Equal(100, before);
+ Assert.Equal(100, after);
+ Assert.Equal(0, rejected);
+ }, timeout: 30_000);
});
}
@@ -178,15 +179,16 @@ public void Feature_PostbackConcurrency_StressTest_Deny()
browser.ElementAt("input[type=button]", 3).Click();
- Thread.Sleep(10000);
- var before = int.Parse(browser.Single(".result-before").GetInnerText().Trim());
- var rejected = int.Parse(browser.Single(".result-rejected").GetInnerText().Trim());
- var after = int.Parse(browser.Single(".result-after").GetInnerText().Trim());
- var value = int.Parse(browser.Single(".result-value").GetInnerText().Trim());
-
- Assert.True(0 < value && value <= 100);
- Assert.Equal(100, before + rejected);
- Assert.Equal(100, after);
+ browser.WaitFor(() => {
+ var before = int.Parse(browser.Single(".result-before").GetInnerText().Trim());
+ var rejected = int.Parse(browser.Single(".result-rejected").GetInnerText().Trim());
+ var after = int.Parse(browser.Single(".result-after").GetInnerText().Trim());
+ var value = int.Parse(browser.Single(".result-value").GetInnerText().Trim());
+
+ Assert.InRange(value, 1, 100);
+ Assert.Equal(100, before + rejected);
+ Assert.Equal(100, after);
+ }, timeout: 30_000);
});
}
@@ -199,16 +201,17 @@ public void Feature_PostbackConcurrency_StressTest_Queue()
browser.ElementAt("input[type=button]", 5).Click();
- Thread.Sleep(10000);
- var before = int.Parse(browser.Single(".result-before").GetInnerText().Trim());
- var rejected = int.Parse(browser.Single(".result-rejected").GetInnerText().Trim());
- var after = int.Parse(browser.Single(".result-after").GetInnerText().Trim());
- var value = int.Parse(browser.Single(".result-value").GetInnerText().Trim());
-
- Assert.Equal(100, value);
- Assert.Equal(100, before);
- Assert.Equal(100, after);
- Assert.Equal(0, rejected);
+ browser.WaitFor(() => {
+ var before = int.Parse(browser.Single(".result-before").GetInnerText().Trim());
+ var rejected = int.Parse(browser.Single(".result-rejected").GetInnerText().Trim());
+ var after = int.Parse(browser.Single(".result-after").GetInnerText().Trim());
+ var value = int.Parse(browser.Single(".result-value").GetInnerText().Trim());
+
+ Assert.Equal(100, value);
+ Assert.Equal(100, before);
+ Assert.Equal(100, after);
+ Assert.Equal(0, rejected);
+ }, timeout: 30_000);
});
}
diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json
index 17991ed4b6..4f5cde2cf5 100644
--- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json
+++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json
@@ -1177,6 +1177,20 @@
"defaultValue": ""
}
},
+ "DotVVM.Framework.Controls.ModalDialog": {
+ "Close": {
+ "type": "DotVVM.Framework.Binding.Expressions.Command, DotVVM.Framework",
+ "isCommand": true
+ },
+ "CloseOnBackdropClick": {
+ "type": "System.Boolean",
+ "defaultValue": false
+ },
+ "Open": {
+ "type": "System.Object",
+ "onlyBindings": true
+ }
+ },
"DotVVM.Framework.Controls.MultiSelector": {
"SelectedValues": {
"type": "System.Object",
@@ -2216,6 +2230,10 @@
"baseType": "DotVVM.Framework.Controls.HtmlGenericControl, DotVVM.Framework",
"withoutContent": true
},
+ "DotVVM.Framework.Controls.ModalDialog": {
+ "assembly": "DotVVM.Framework",
+ "baseType": "DotVVM.Framework.Controls.HtmlGenericControl, DotVVM.Framework"
+ },
"DotVVM.Framework.Controls.MultiSelect": {
"assembly": "DotVVM.Framework",
"baseType": "DotVVM.Framework.Controls.MultiSelectHtmlControlBase, DotVVM.Framework"