Skip to content

Commit

Permalink
Merge pull request #1776 from riganti/modal-dialog
Browse files Browse the repository at this point in the history
New dot:ModalDialog control, wrapper for <dialog>
  • Loading branch information
exyi committed Feb 27, 2024
2 parents 2304616 + a473fd1 commit d73e409
Show file tree
Hide file tree
Showing 10 changed files with 422 additions and 35 deletions.
82 changes: 82 additions & 0 deletions 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
{
/// <summary>
/// Renders a HTML native dialog element, it is opened using the showModal function when the <see cref="Open" /> property is set to true
/// </summary>
/// <remarks>
/// * 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 <see cref="CloseOnBackdropClick" /> = true
/// </remarks>
[ControlMarkupOptions()]
public class ModalDialog : HtmlGenericControl
{
public ModalDialog()
: base("dialog", false)
{
}

/// <summary> 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. </summary>
[MarkupOptions(AllowHardCodedValue = false)]
public object? Open
{
get { return GetValue(OpenProperty); }
set { SetValue(OpenProperty, value); }
}
public static readonly DotvvmProperty OpenProperty =
DotvvmProperty.Register<object, ModalDialog>(nameof(Open), null);

/// <summary> Add an event handler which closes the dialog when the backdrop is clicked. </summary>
public bool CloseOnBackdropClick
{
get { return (bool?)GetValue(CloseOnBackdropClickProperty) ?? false; }
set { SetValue(CloseOnBackdropClickProperty, value); }
}
public static readonly DotvvmProperty CloseOnBackdropClickProperty =
DotvvmProperty.Register<bool, ModalDialog>(nameof(CloseOnBackdropClick), false);

/// <summary> Triggered when the dialog is closed. Called only if it was closed by user input, not by <see cref="Open"/> change. </summary>
public Command? Close
{
get { return (Command?)GetValue(CloseProperty); }
set { SetValue(CloseProperty, value); }
}
public static readonly DotvvmProperty CloseProperty =
DotvvmProperty.Register<Command, ModalDialog>(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<bool>(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);
}
}
}
Expand Up @@ -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
Expand All @@ -26,7 +27,8 @@ const allHandlers: KnockoutHandlerDictionary = {
...gridviewdataset,
...namedCommand,
...fileUpload,
...jsComponents
...jsComponents,
...modalDialog
}

export default allHandlers
@@ -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();
}
}
}
})
}
}
}
@@ -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; }
}
}

}
@@ -0,0 +1,79 @@
@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.ModalDialog.ModalDialogViewModel

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title></title>

<style>
.button-active {
background-color: #4CAF50;
}
</style>
</head>
<body>
<h1>Modal dialogs</h1>

<p>
<dot:Button data-ui="btn-open-simple" Text="Simple dialog" Click="{staticCommand: Dialog1Shown = true}" Class-button-active={value: Dialog1Shown} />
<dot:Button data-ui="btn-open-chained1" Text="Chained dialog" Click="{staticCommand: DialogChained1Shown = true}" Class-button-active={value: DialogChained1Shown} />
<dot:Button data-ui="btn-open-close-event" Text="Dialog with clickable backdrop and close event" Click="{staticCommand: CloseEventDialogShown = true}" Class-button-active={value: CloseEventDialogShown} />
<dot:Button data-ui="btn-open-view-model" Text="Dialog with view model" Click="{command: ShowDialogWithModel()}" Class-button-active={value: DialogWithModel != null} />
<dot:Button data-ui="btn-open-int" Text="Dialog controlled by nullable number" Click="{command: NullableIntController = 0}" Class-button-active={value: NullableIntController != null} />
<dot:Button data-ui="btn-open-string" Text="Dialog controlled by nullable string" Click="{staticCommand: NullableStringController = ""}" Class-button-active={value: NullableStringController != null} />
</p>
<p>
Close events: <span data-ui="close-event-counter" InnerText={value: CloseEventCounter} />
</p>

<dot:ModalDialog Open={value: Dialog1Shown} data-ui=simple>
<form>
This is a simple modal dialog, close it by pressing ESC or clicking the <button data-ui=btn-close formmethod="dialog" type="submit">Form method=dialog</button> button.
</form>
</dot:ModalDialog>

<dot:ModalDialog Open={value: DialogChained1Shown} data-ui=chained1>
<p>This is the first chained modal dialog.</p>
<form>
<dot:Button data-ui=btn-next Text="Next" Click={staticCommand: DialogChained1Shown = false; DialogChained2Shown = true} />
<button data-ui=btn-close formmethod="dialog" type="submit">Cancel</button>
</form>
</dot:ModalDialog>

<dot:ModalDialog Open={value: DialogChained2Shown} data-ui=chained2>
<p>This is the second chained modal dialog.</p>
<dot:Button data-ui=btn-close Text="Close" Click={staticCommand: DialogChained2Shown = false} />
</dot:ModalDialog>

<dot:ModalDialog Open={value: CloseEventDialogShown} CloseOnBackdropClick Close={staticCommand: CloseEventCounter = CloseEventCounter + 1} data-ui=close-event>
Closing the dialog will increase the counter. Either
<ul>
<li>Click the backdrop</li>
<li>Press ESC</li>
<li><dot:Button data-ui=btn-close-staticcommand Click={staticCommand: CloseEventDialogShown=false}>Use staticCommand</dot:Button></li>
<li><dot:Button data-ui=btn-close-command Click={command: CloseDialogWithEvent()}>Use command</dot:Button></li>
<li> <form method="dialog"><button data-ui=btn-close-form type="submit">Form method=dialog</button></form></li>
</ul>
</dot:ModalDialog>

<dot:ModalDialog Open={value: DialogWithModel} data-ui=view-model>
<p>Edit this field: <dot:TextBox Text={value: DialogWithModel.Property} /> </p>
<p>
<dot:Button data-ui=btn-save Text="Save" Click={command: DialogWithModel = null} />
<form method="dialog"><button data-ui=btn-close type="submit">Cancel</button></form>
</p>
</dot:ModalDialog>

<dot:ModalDialog Open={value: NullableIntController} data-ui=int>
the number: <dot:TextBox data-ui=editor Text={value: NullableIntController} />
<form method="dialog"><button data-ui=btn-close type="submit">Close</button></form>
</dot:ModalDialog>

<dot:ModalDialog Open={value: NullableStringController} data-ui=string>
the string: <dot:TextBox data-ui=editor Text={value: NullableStringController} />
<form method="dialog"><button data-ui=btn-close type="submit">Close</button></form>
</dot:ModalDialog>
</body>
</html>

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions src/Samples/Tests/Tests/Feature/CompilationPageTests.cs
Expand Up @@ -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
Expand Down

0 comments on commit d73e409

Please sign in to comment.