Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lab): new component ConfirmationDialog #286

Merged
Merged
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"]
Loading