# Dialogs, notifications and validation

> Talk to the user in a Pyzed app: field errors computed from state, a confirmation dialog over the page and toast notifications that disappear on their own.

Source: https://albakore.github.io/pyzed/tutorials/intermediate/dialogs-feedback/

A good app tells the user what is going on: what is wrong with the form, what is about to happen, what just
happened. Pyzed has a ready-made piece for each: **field errors**, **dialogs** and **toasts** (small notifications
in the corner).

| Goal | Time | Level | You need |
| --- | --- | --- | --- |
| A sign-up form with errors, a confirmation and notifications | 30 minutes | Intermediate · Python | [Lists and tables](https://albakore.github.io/pyzed/tutorials/intermediate/lists-tables/) |

## What you'll learn

- Compute validation errors from your state and show them with `ui.field(..., error=...)`.
- Show them only after the first try, not while the user is still typing.
- Open a **confirmation dialog** over the page with `ui.dialog_layer` and `ui.confirm`.
- Show **toasts** with `ui.toasts` and remove them after a few seconds without freezing the window.

## Steps

1. **Errors are a function of the state.** One method returns `{field: message}` for whatever is wrong right now:

```python
def errors(self):
    found = {}
    if not self.name.strip():
        found["name"] = "Tell us your name."
    if "@" not in self.email:
        found["email"] = "Enter an email such as ada@example.com."
    if len(self.password) < 8:
        found["password"] = "At least 8 characters."
    return found
```

2. **Show them under each field**, but only once the user has tried to submit (`self.tried`). Nobody likes red
   text before they had a chance:

```python
errors = self.errors() if self.tried else {}
ui.field("Email", ui.input(self.email, lambda v: setattr(self, "email", v)), error=errors.get("email"))
```

3. **A confirmation dialog.** `ui.dialog_layer(page, dialog)` draws `dialog` centered over a dimmed page;
   `dialog=None` shows just the page. So "is the dialog open?" is one attribute:

```python
dialog = None
if self.confirming:
    dialog = ui.confirm(f"Create an account for {self.email}?", self.create,
                        lambda: setattr(self, "confirming", False), title="Almost there",
                        confirm_label="Create")
page = ui.dialog_layer(page, dialog, on_dismiss=lambda: setattr(self, "confirming", False))
```

4. **Toasts.** Keep a list of notes, draw them with `ui.toasts`, and remove each one after 4 seconds.
   `Command.perform` waits in a background thread, so the window keeps responding:

```python
def notify(self, kind, title, text=""):
    note_id = next(_ids)
    self.notes.append((note_id, kind, title, text))
    return pz.Command.perform(lambda: time.sleep(4), lambda _: self.dismiss(note_id))

# in view():
return ui.toasts(page, [ui.toast(title, text, kind=kind, on_dismiss=lambda i=i: self.dismiss(i))
                        for i, kind, title, text in self.notes])
```

5. **Put it together.** The button sets `tried`, shows an error toast if something is wrong, or opens the dialog;
   confirming clears the form and shows a success toast.

**Other ready-made dialogs:**

`ui.alert` (one button), `ui.prompt` (asks for a line of text), `ui.choice` (your own buttons) and
`ui.unsaved_changes` ("Save before closing?") work the same way. See them all in `examples/dialogs.py` and in the
[Alert](https://albakore.github.io/pyzed/library/alert/) and [Confirm](https://albakore.github.io/pyzed/library/confirm/) pages of the library.

**Validating with Pydantic:**

If your data already has a Pydantic (or SQLModel, Django, FastAPI) model, let it validate and turn its errors into
`{field: message}` with `pyzed.forms.validation_errors(error)`. See [Pydantic](https://albakore.github.io/pyzed/integrations/pydantic/).

## Full code

`examples/tutorial/feedback.py`:

```python
"""Talking to the user: validation messages, a confirmation dialog and toast notifications.

    python examples/tutorial/feedback.py

- ``ui.field(..., error=...)`` shows a message under a field; the errors are computed from the state.
- ``ui.dialog_layer(page, dialog)`` puts a dialog (``ui.confirm``, ``ui.alert``...) over the page;
  ``dialog`` is ``None`` when nothing is open.
- ``ui.toasts(page, [...])`` stacks notifications in the corner; ``Command.perform`` waits a few
  seconds in the background and then removes them.

Walkthrough: https://albakore.github.io/pyzed/tutorials/intermediate/dialogs-feedback/
"""

import itertools
import time

import pyzed as pz
from pyzed import ui

_ids = itertools.count()

class Signup(pz.App):
    title = "Create an account"
    size = (640, 560)

    def __init__(self):
        self.name = ""
        self.email = ""
        self.password = ""
        self.tried = False   # show errors only after the first try
        self.confirming = False
        self.notes = []      # (id, kind, title, text) of the visible toasts

    # -- validation -----------------------------------------------------------------------
    def errors(self):
        found = {}
        if not self.name.strip():
            found["name"] = "Tell us your name."
        if "@" not in self.email or "." not in self.email.split("@")[-1]:
            found["email"] = "Enter an email such as ada@example.com."
        if len(self.password) < 8:
            found["password"] = "At least 8 characters."
        return found

    # -- toasts ---------------------------------------------------------------------------
    def notify(self, kind, title, text=""):
        note_id = next(_ids)
        self.notes.append((note_id, kind, title, text))
        # wait 4 seconds in a background thread, then remove it (the window never freezes)
        return pz.Command.perform(lambda: time.sleep(4), lambda _: self.dismiss(note_id))

    def dismiss(self, note_id):
        self.notes = [n for n in self.notes if n[0] != note_id]

    # -- actions --------------------------------------------------------------------------
    def submit(self):
        self.tried = True
        if self.errors():
            return self.notify("error", "Check the form", "Some fields need your attention.")
        self.confirming = True  # open the dialog

    def create(self):
        self.confirming = False
        name = self.name
        self.name = self.email = self.password = ""
        self.tried = False
        return self.notify("success", "Account created", f"Welcome, {name}!")

    # -- view -----------------------------------------------------------------------------
    def view(self):
        t = self.resolved_theme()
        errors = self.errors() if self.tried else {}
        form = pz.column([
            ui.heading("Create an account", level=2),
            ui.muted("Leave a field empty and press the button to see the messages."),
            ui.field("Name", ui.input(self.name, lambda v: setattr(self, "name", v), placeholder="Ada Lovelace"),
                     error=errors.get("name")),
            ui.field("Email", ui.input(self.email, lambda v: setattr(self, "email", v), icon="mail",
                                       placeholder="ada@example.com"), error=errors.get("email")),
            ui.field("Password", ui.input(self.password, lambda v: setattr(self, "password", v), secure=True,
                                          on_submit=self.submit), error=errors.get("password"),
                     description="8 characters or more"),
            pz.row([pz.hspace(), ui.button("Create account", self.submit, variant="solid", icon="user-plus")]),
        ], spacing=14, padding=28, width="fill")
        page = pz.container(form, width="fill", height="fill", bg=t.background)

        dialog = None
        if self.confirming:
            dialog = ui.confirm(f"Create an account for {self.email}?", self.create,
                                lambda: setattr(self, "confirming", False), title="Almost there",
                                confirm_label="Create")
        page = ui.dialog_layer(page, dialog, on_dismiss=lambda: setattr(self, "confirming", False))
        return ui.toasts(page, [ui.toast(title, text, kind=kind, on_dismiss=lambda i=i: self.dismiss(i))
                                for i, kind, title, text in self.notes])

if __name__ == "__main__":
    pz.run(Signup)
```

## Events in this tutorial

| Widget | Event | Handler |
| --- | --- | --- |
| `ui.input` (three fields) | `on_input` | `lambda v: setattr(self, "name", v)`… |
| `ui.input` (password) | `on_submit` | `self.submit` |
| **Create account** | `on_press` | `self.submit` |
| `ui.confirm` | `on_confirm`, `on_cancel` | `self.create`, close the dialog |
| `ui.dialog_layer` | `on_dismiss` | close the dialog (a click on the backdrop) |
| `ui.toast` | `on_dismiss` | `lambda i=i: self.dismiss(i)` |

### Try more events

```python
def on_key(self, combo):
    if combo == "escape" and self.confirming:   # Escape closes the dialog
        self.confirming = False
        return []                               # handled: the key goes no further

def view(self):
    ...
    toasts = [ui.toast(title, text, kind=kind,
                       on_dismiss=lambda i=i: self.dismiss(i),
                       on_press=lambda i=i: self.dismiss(i))   # a click on the card closes it too
              for i, kind, title, text in self.notes]
```

## Next

- [A custom component](https://albakore.github.io/pyzed/tutorials/intermediate/custom-component/) in the designer's Component view.
- [Background tasks](https://albakore.github.io/pyzed/tutorials/intermediate/background-tasks/): progress bars and toasts from threads.
