h2oai / wave

Realtime Web Apps and Dashboards for Python and R
https://wave.h2o.ai
Apache License 2.0
3.9k stars 323 forks source link

trigger=True on textbox causes text not to appear in args when the Form's button is clicked. #2217

Closed petergaultney closed 6 months ago

petergaultney commented 6 months ago

Wave SDK Version, OS

1.0.0

Actual behavior

An application with ui.form_card(items=[ui.textbox(name='text', trigger=True), ui.button(name='act')]) will cause text not to be present in q.args when the act button is clicked. The exact same application without the trigger=True configuration will have text present and equal to the value of the textbox when the same act button is clicked.

Expected behavior

Clicking act should always cause q.args.text to be the value of the textbox in the form, regardless of whether trigger is True for the textbox.

Steps To Reproduce

from typing import List

from h2o_wave import Q, app, main, on, run_on, ui

_id = 0

# HASH ROUTES
_NEW = "#new"
_SHOW_TODOS = "#show_todos"

# A simple class that represents a to-do item.
class TodoItem:
    def __init__(self, text):
        global _id
        _id += 1
        self.id = f"todo_{_id}"
        self.text = text
        self.done = False

def _redirect(page: str, q: Q):
    assert page.startswith("#"), f"Page must start with # -- {page}"
    print("redirecting to ", page)
    q.page["meta"].redirect = page

def validate_text(text: str, q: Q) -> bool:
    print("validating text", text)
    if text and text.startswith("z"):  # bad dog!
        q.page["form"].add_todo.disabled = True
        return False
    else:
        q.page["form"].add_todo.disabled = False
        return True

@on(_SHOW_TODOS)
async def show_todos(q: Q):
    # Get items for this user.
    todos: List[TodoItem] = q.user.todos

    # Create a sample list if we don't have any.
    if todos is None:
        q.user.todos = todos = [TodoItem("Do this"), TodoItem("Do that"), TodoItem("Do something else")]

    # If the user checked/unchecked an item, update our list.
    for todo in todos:
        if todo.id in q.args:
            todo.done = q.args[todo.id]

    # Create done/not-done checkboxes.
    done = [
        ui.checkbox(name=todo.id, label=todo.text, value=True, trigger=True)
        for todo in todos
        if todo.done
    ]
    not_done = [
        ui.checkbox(name=todo.id, label=todo.text, trigger=True) for todo in todos if not todo.done
    ]

    # Display list
    q.page["form"] = ui.form_card(
        box="1 1 3 10",
        items=[
            ui.text_l("To Do"),
            ui.button(name="new_todo", label="New To Do...", primary=True),
            *not_done,
            *([ui.separator("Done")] if len(done) else []),
            *done,
        ],
    )

@on()
async def add_todo(q: Q):
    if validate_text(q.args.text, q):
        # Insert a new item
        q.user.todos.insert(0, TodoItem(q.args.text or "Untitled"))
        _redirect(_SHOW_TODOS, q)
    else:
        _redirect(_NEW, q)

@on(_NEW)
async def new_todo_validation(q: Q):
    validate_text(q.args.text, q)

@on()
async def new_todo(q: Q):
    print("rendering new_todo")
    # Display an input form
    q.page["form"] = ui.form_card(
        box="1 1 3 10",
        items=[
            ui.text_l("New To Do"),
            ui.textbox(name="text", label="What needs to be done?", multiline=True),
            ui.buttons(
                [
                    ui.button(name="add_todo", label="Add", primary=True),
                    ui.button(name=_SHOW_TODOS, label="Back"),
                ]
            ),
        ],
    )
    _redirect(_NEW, q)

@app("/todo")
async def serve(q: Q):
    print(q.args)
    if not q.client.initialized:
        print("initializing")
        q.client.initialized = True
        q.page["meta"] = ui.meta_card(box="")
        await show_todos(q)
        print("finished")
    else:
        did_invoke = await run_on(q)

        # workaround for odd behavior in `run_on` causing `#new` not to get routed more than once.
        hash_route = q.args["#"]
        if did_invoke:
            print(f"saving after {hash_route}")
        else:
            print(f"found no matching route for '{hash_route}'")
            validate_text(q.args.text, q)
    await q.page.save()

"logs":


text:'setsetset', #:'new', __wave_submission_name__:'text'
found no matching route for 'new'
validating text setsetset
2023/12/06 22:43:25 * /aae40c05-6d10-40b0-80ef-8727686b0395 {"d":[{"k":"form add_todo disabled","v":false}]}
saved
INFO:     127.0.0.1:58845 - "POST / HTTP/1.1" 200 OK
add_todo:True, #:'new', __wave_submission_name__:'add_todo'
validating text None
redirecting to  #show_todos
saving after new
2023/12/06 22:43:26 * /aae40c05-6d10-40b0-80ef-8727686b0395 {"d":[{"k":"form add_todo disabled","v":false},{"k":"meta redirect","v":"#show_todos"}]}```
mturoci commented 6 months ago

This is by design. Wave submits only "dirty" state on user interaction.

regular (trigger=False) behavior

User fills in form, but the submission to server happens only on button click thus only the changed fields compared to the last submit are submitted.

trigger=True behavior

Each change to a form field causes server submission which means there is always going to be just a single q.args value present. If you need cross-field validation, you need to store the form state into q.client that can survive throughout serve calls.