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 a simple modal dialog, close it by pressing ESC or clicking the button. +
+
+ + +

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"