Skip to content

Commit

Permalink
feat(lab): new component ConfirmationDialog (#286)
Browse files Browse the repository at this point in the history
Commonly used to confirm actions such as deleting rows/objects from databases etc.
  • Loading branch information
hansnowak committed Oct 2, 2023
1 parent 115d423 commit b68e7fc
Show file tree
Hide file tree
Showing 6 changed files with 319 additions and 0 deletions.
1 change: 1 addition & 0 deletions solara/lab/components/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .confirmation_dialog import ConfirmationDialog # noqa: F401
from .tabs import Tab, Tabs # noqa: F401
165 changes: 165 additions & 0 deletions solara/lab/components/confirmation_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
from typing import Callable, List, Union, overload

import reacton.ipyvuetify as v

import solara


@overload
def ConfirmationDialog(
open: solara.Reactive[bool],
*,
on_close: Union[None, Callable[[], None]] = None,
content: Union[str, solara.Element] = "",
title: str = "Confirm action",
ok: Union[str, solara.Element] = "OK",
on_ok: Callable[[], None] = lambda: None,
cancel: Union[str, solara.Element] = "Cancel",
on_cancel: Callable[[], None] = lambda: None,
children: List[solara.Element] = [],
max_width: Union[int, str] = 500,
persistent: bool = False,
):
...


# when open is a boolean, on_close should be given, otherwise a dialog can never be closed
# TODO: copy this pattern to many other components
@overload
def ConfirmationDialog(
open: bool,
*,
on_close: Callable[[], None],
content: Union[str, solara.Element] = "",
title: str = "Confirm action",
ok: Union[str, solara.Element] = "OK",
on_ok: Callable[[], None] = lambda: None,
cancel: Union[str, solara.Element] = "Cancel",
on_cancel: Callable[[], None] = lambda: None,
children: List[solara.Element] = [],
max_width: Union[int, str] = 500,
persistent: bool = False,
):
...


@solara.component
def ConfirmationDialog(
open: Union[solara.Reactive[bool], bool],
*,
on_close: Union[None, Callable[[], None]] = None,
content: Union[str, solara.Element] = "",
title: str = "Confirm action",
ok: Union[str, solara.Element] = "OK",
on_ok: Callable[[], None] = lambda: None,
cancel: Union[str, solara.Element] = "Cancel",
on_cancel: Callable[[], None] = lambda: None,
children: List[solara.Element] = [],
max_width: Union[int, str] = 500,
persistent: bool = False,
):
"""A dialog used to confirm a user action.
(*Note: [This component is experimental and its API may change in the future](/docs/lab).*)
By default, has a title, a text explaining the
decision to be made, and two buttons "OK" and "Cancel".
## Basic examples
```solara
import solara
open_delete_confirmation = solara.reactive(False)
def delete_user():
# put your code to perform the action here
print("User being deleted...")
@solara.component
def Page():
solara.Button(label="Delete user", on_click=lambda: open_delete_confirmation.set(True))
solara.lab.ConfirmationDialog(open_delete_confirmation, ok="Ok, Delete", on_ok=delete_user, content="Are you sure you want to delete this user?")
```
## Arguments
* `open`: Indicates whether the dialog is being shown or not.
* `on_open`: Callback to call when the dialog opens of closes.
* `content`: Message that is displayed.
* `title`: Title of the dialog.
* `ok`: If a string, this text will be displayed on the confirmation button (default is "OK"). If a Button, it will be used instead of the default button.
* `on_ok`: Callback to be called when the OK button is clicked.
* `cancel`: If a string, this text will be displayed on the cancellation button (default is "Cancel"). If a Button, it will be used instead of the default
button.
* `on_cancel`: Callback to be called when the Cancel button is clicked. When persistent is False, clicking outside of the element or pressing esc key will
also trigger cancel.
* `children`: Additional components that will be shown under the dialog message, but before the buttons.
* `max_width`: Maximum width of the dialog window.
* `persistent`: When False (the default), clicking outside of the element or pressing esc key will trigger cancel.
"""

def on_open(open_value):
if not open_value:
if on_close:
on_close()

open_reactive = solara.use_reactive(open, on_open)
del open

def close():
open_reactive.set(False)

user_on_click_ok = None
user_on_click_cancel = None

def perform_ok():
if user_on_click_ok:
user_on_click_ok()
on_ok()
close()

def perform_cancel():
if user_on_click_cancel:
user_on_click_cancel()
on_cancel()
close()

def on_v_model(value):
if not value:
on_cancel()
open_reactive.set(value)

with v.Dialog(
v_model=open_reactive.value,
on_v_model=on_v_model,
persistent=persistent,
max_width=max_width,
):
with solara.v.Card():
solara.v.CardTitle(children=[title])
with solara.v.CardText(style_="min-height: 64px"):
if isinstance(content, str):
solara.Text(content)
else:
solara.display(content)
if children:
solara.display(*children)
with solara.v.CardActions(class_="justify-end"):
if isinstance(cancel, str):
solara.Button(label=cancel, on_click=perform_cancel, text=True, classes=["grey--text", "text--darken-2"])
else:
# the user may have passed in on_click already
user_on_click_cancel = cancel.kwargs.get("on_click")
# override or add our own on_click handler
cancel.kwargs = {**cancel.kwargs, "on_click": perform_cancel}
solara.display(cancel)

# similar as cancel
if isinstance(ok, str):
solara.Button(label=ok, on_click=perform_ok, dark=True, color="primary", elevation=0)
else:
user_on_click_ok = ok.kwargs.get("on_click")
ok.kwargs = {**ok.kwargs, "on_click": perform_ok}
solara.display(ok)
1 change: 1 addition & 0 deletions solara/website/pages/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"pages": [
"tab",
"tabs",
"confirmation_dialog",
],
},
]
Expand Down
55 changes: 55 additions & 0 deletions solara/website/pages/api/confirmation_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
# ConfirmationDialog
"""

from typing import Union

import solara
from solara.lab.components.confirmation_dialog import ConfirmationDialog
from solara.website.utils import apidoc

title = "ConfirmationDialog"
users = solara.reactive("Alice Bob Cindy Dirk Eve Fred".split())
user_to_be_deleted: solara.Reactive[Union[str, None]] = solara.reactive(None)


def ask_to_delete_user(user):
user_to_be_deleted.value = user


def clear_user_to_be_deleted():
user_to_be_deleted.value = None


def delete_user():
users.set([u for u in users.value if u != user_to_be_deleted.value])
clear_user_to_be_deleted()


@solara.component
def Page():
"""Create a list of users with a button to delete them.
A confirmation dialog will pop up first before deletion."""
solara.Markdown("#### Users:")
with solara.Column(style={"max-width": "300px"}):
for user in users.value:
with solara.Row(style={"align-items": "center"}):
solara.Text(user)
solara.v.Spacer()
solara.Button(icon_name="mdi-delete", on_click=lambda user=user: ask_to_delete_user(user), icon=True)
if not users.value:
solara.Text("(no users left)")

with ConfirmationDialog(
user_to_be_deleted.value is not None,
on_ok=delete_user,
on_close=clear_user_to_be_deleted,
ok="Ok, Delete",
title="Delete user",
):
solara.Markdown(f"Are you sure you want to delete user **{user_to_be_deleted.value}**?")


__doc__ += apidoc(solara.lab.components.confirmation_dialog.ConfirmationDialog.f) # type: ignore
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 97 additions & 0 deletions tests/unit/confirmation_dialog_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from unittest.mock import MagicMock

import ipyvuetify as vw

import solara
from solara.lab.components.confirmation_dialog import ConfirmationDialog


def test_confirmation_dialog_ok():
is_open = solara.reactive(True)
on_ok = MagicMock()
on_close = MagicMock()
el = ConfirmationDialog(is_open, on_ok=on_ok, on_close=on_close, content="Hello")
_, rc = solara.render(el, handle_error=False)
buttons = rc.find(vw.Btn)
assert len(buttons) == 2
buttons[1].widget.click()
assert on_ok.call_count == 1 # was OK button clicked?
assert on_close.call_count == 1 # always triggered
assert not is_open.value # is dialog closed?


def test_confirmation_dialog_cancel():
is_open = solara.reactive(True)
on_ok = MagicMock()
on_close = MagicMock()
el = ConfirmationDialog(is_open, on_ok=on_ok, on_close=on_close, content="Hello")
_, rc = solara.render(el, handle_error=False)
buttons = rc.find(vw.Btn)
assert len(buttons) == 2
buttons[0].widget.click()
assert on_ok.call_count == 0 # on_ok action should not have been executed
assert on_close.call_count == 1 # always triggered
assert not is_open.value # is dialog closed?


def test_confirm_external_close():
# e.g. when persistent=False, clicking away from the dialog closes it
is_open = solara.reactive(True)
on_ok = MagicMock()
on_cancel = MagicMock()
on_close = MagicMock()
el = ConfirmationDialog(is_open, on_ok=on_ok, on_cancel=on_cancel, on_close=on_close, content="Hello")
_, rc = solara.render(el, handle_error=False)
dialog = rc.find(vw.Dialog)[0].widget
assert dialog.v_model
dialog.v_model = False # trigger an external close, like escape or clicking away
assert not is_open.value # is dialog closed?
assert on_ok.call_count == 0 # on_ok action should not have been executed
assert on_cancel.call_count == 1 # on_cancel action should not have been executed
assert on_close.call_count == 1 # always triggered


def test_confirmation_dialog_custom_button_no_onclick():
is_open = solara.reactive(True)
on_ok = MagicMock()
my_button = solara.Button(label="Not OK") # no on_click
el = ConfirmationDialog(is_open, on_ok=on_ok, ok=my_button, content="Are you sure?")
_, rc = solara.render(el, handle_error=False)
buttons = rc.find(vw.Btn)
assert len(buttons) == 2
assert buttons[0].widget.children == ["Cancel"]
assert buttons[1].widget.children == ["Not OK"]
buttons[1].widget.click()
assert on_ok.call_count == 1 # should still be called


def test_confirmation_dialog_custom_button_with_onclick():
is_open = solara.reactive(True)
values = []

def on_ok():
values.append("on_ok")

def on_cancel():
values.append("on_cancel")

def on_click_ok():
values.append("on_click_ok")

def on_click_cancel():
values.append("on_click_cancel")

ok = solara.Button(label="Not OK", on_click=on_click_ok)
cancel = solara.Button(label="Not Cancel", on_click=on_click_cancel)
el = ConfirmationDialog(is_open, on_ok=on_ok, on_cancel=on_cancel, ok=ok, cancel=cancel, content="Are you sure?")
_, rc = solara.render(el, handle_error=False)
buttons = rc.find(vw.Btn)
assert len(buttons) == 2
assert buttons[1].widget.children == ["Not OK"]
assert buttons[0].widget.children == ["Not Cancel"]
buttons[1].widget.click()
assert values == ["on_click_ok", "on_ok"] # assert on_ok and on_click were both called, in that order
values.clear()
# now the same for cancel
buttons[0].widget.click()
assert values == ["on_click_cancel", "on_cancel"]

0 comments on commit b68e7fc

Please sign in to comment.