From 259476a63ed8c8ee9a0d61bc4755b65b568d9549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sun, 18 Feb 2024 22:30:17 +0100 Subject: [PATCH] New dot:ModalDialog control, wrapper for The control closes or shows the modal based on an Open property. The modal is always shown using the .showModal() method, non-modal dialog is already accessible by binding the open attribute of dialog HTML element. The Open property may either be a boolean or a nullable object, the dialog is shown if the value isn't false nor null. On close event, false or null is written back into the Open property. Otherwise, we'd quickly have inconsistent viewModel whenever the user closes the dialog with ESC. Close event is also provided for explicit event handling. We also optionaly provide a helper for implementing "close after backdrop click" functionality. It is not supported by the dialog element natively and could not be otherwise implemented without writing custom JS. It is enabled by setting CloseOnBackdropClick=true Resolves #1708 --- .../Framework/Controls/ModalDialog.cs | 81 +++++++++++++ .../Scripts/binding-handlers/all-handlers.ts | 4 +- .../Scripts/binding-handlers/modal-dialog.ts | 41 +++++++ .../ModalDialog/ModalDialogViewModel.cs | 42 +++++++ .../ModalDialog/ModalDialog.dothtml | 79 ++++++++++++ .../Abstractions/SamplesRouteUrls.designer.cs | 1 + .../Tests/Tests/Feature/ModalDialogTests.cs | 112 ++++++++++++++++++ 7 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 src/Framework/Framework/Controls/ModalDialog.cs create mode 100644 src/Framework/Framework/Resources/Scripts/binding-handlers/modal-dialog.ts create mode 100644 src/Samples/Common/ViewModels/FeatureSamples/ModalDialog/ModalDialogViewModel.cs create mode 100644 src/Samples/Common/Views/FeatureSamples/ModalDialog/ModalDialog.dothtml create mode 100644 src/Samples/Tests/Tests/Feature/ModalDialogTests.cs diff --git a/src/Framework/Framework/Controls/ModalDialog.cs b/src/Framework/Framework/Controls/ModalDialog.cs new file mode 100644 index 0000000000..a81a6e497f --- /dev/null +++ b/src/Framework/Framework/Controls/ModalDialog.cs @@ -0,0 +1,81 @@ +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 (bool?)GetValue(OpenProperty) ?? false; } + set { SetValue(OpenProperty, value); } + } + public static readonly DotvvmProperty OpenProperty = + DotvvmProperty.Register(nameof(Open), false); + + /// 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 regardless if it was closed by user input or 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-model-backdrop-close", x.GetJsExpression(this)); + } + + if (GetCommandBinding(CloseProperty) is {} close) + { + writer.AddAttribute("onclose", KnockoutHelper.GenerateClientPostBackScript(nameof(Close), close, this, returnValue: null)); + } + + 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..b745237de9 --- /dev/null +++ b/src/Framework/Framework/Resources/Scripts/binding-handlers/modal-dialog.ts @@ -0,0 +1,41 @@ +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.showModal() + } else { + element.close() + } + } + }, + }, + "dotvvm-model-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..24247674e1 --- /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/ModalDialogTests.cs b/src/Samples/Tests/Tests/Feature/ModalDialogTests.cs new file mode 100644 index 0000000000..ccd5c038d0 --- /dev/null +++ b/src/Samples/Tests/Tests/Feature/ModalDialogTests.cs @@ -0,0 +1,112 @@ +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-close1", SelectByDataUi).Click()); + AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "1"); + CheckDialogCloses(browser, "close-event", dialog => dialog.Single("btn-close2", SelectByDataUi).Click()); + AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "2"); + CheckDialogCloses(browser, "close-event", dialog => dialog.Single("btn-close3", SelectByDataUi).Click()); + AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "3"); + CheckDialogCloses(browser, "close-event", dialog => dialog.SendKeys(Keys.Escape)); + AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "4"); + + CheckDialogCloses(browser, "close-event", dialog => { + // dialog click + new Actions(browser.Driver).MoveToElement(dialog.WebElement, 1, 1).Click().Perform(); + AssertUI.IsDisplayed(dialog); + // backdrop click + new Actions(browser.Driver).MoveToLocation(1, 1).Click().Perform(); + }); + }); + } + + [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()); + }); + } + } +}