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

Dialog forms have inconsistent `q.args` behavior #2211

Open aranvir opened 7 months ago

aranvir commented 7 months ago

Wave SDK Version, OS

Windows 10 Python 3.10.7 h2o-wave 1.0.0

Actual behavior

I'd like to use form verification: At least, check that no input is empty. If at least one input is empty, inform the user and keep all previous inputs. On every submit, all inputs are part of q.args. If the form is part of a card, this works fine. If the form is part of a dialog, it gets inconsistent:

Considering this it can get a extra funny:

Expected behavior

I'd expect a dialog form to behave the same as a regular page form. But I am aware that it is not recommended to use dialogs for "content-heavy" workflows, so I'm also wondering if this behavior is "intended" or just a limitation of how the dialog box is implemented.

Steps To Reproduce

The app has two pages: Page Form and Dialog Form

On Page Form:

On Dialog Form:

Example code:

from h2o_wave import main, app, Q, ui, on, run_on, data, core
from typing import Optional, List

# Use for page cards that should be removed when navigating away.
# For pages that should be always present on screen use q.page[key] = ...
def add_card(q, name, card) -> None:
    q.client.cards.add(name)
    q.page[name] = card

# Remove all the cards related to navigation.
def clear_cards(q, ignore: Optional[List[str]] = []) -> None:
    if not q.client.cards:
        return

    for name in q.client.cards.copy():
        if name not in ignore:
            del q.page[name]
            q.client.cards.remove(name)

#########################################################################
# PAGE FORM #############################################################
#########################################################################

def make_page1_items(q: Q, message: str = ""):
    items = [
            ui.textbox("page_input_1", label="Para 1", value=q.args.page_input_1 if q.args.page_input_1 else ""),
            ui.textbox("page_input_2", label="Para 2", value=q.args.page_input_2 if q.args.page_input_2 else ""),
            ui.textbox("page_input_3", label="Para 3", value=q.args.page_input_3 if q.args.page_input_3 else ""),
            ui.buttons([
                ui.button("page_send", label="Send", primary=True),
                ui.button("page_reset", label="Reset", primary=False),
            ])
        ]
    if message:
        items.append(ui.text_m(f'<span style="color: red">{message}</span>'))
    return items

@on('#page1')
async def page1(q: Q):
    q.page['sidebar'].value = '#page1'
    clear_cards(q)  # When routing, drop all the cards except of the main ones (header, sidebar, meta).

    add_card(q, "page_form", ui.form_card(
        box="horizontal",
        items=make_page1_items(q)
    ))

@on('page_send')
async def page_send(q: Q):
    print(q.args)
    if all([q.args.page_input_1, q.args.page_input_2, q.args.page_input_3]):
        q.args.page_input_1 = q.args.page_input_2 = q.args.page_input_3 = ""
        add_card(q, "page_form", ui.form_card(
            box="horizontal",
            items=make_page1_items(q, message="Sent!")
        ))
    else:
        add_card(q, "page_form", ui.form_card(
            box="horizontal",
            items=make_page1_items(q, message="No field may be empty!")
        ))

@on('page_reset')
async def page_reset(q: Q):
    print(q.args)
    q.args.page_input_1 = q.args.page_input_2 = q.args.page_input_3 = ""
    add_card(q, "page_form", ui.form_card(
        box="horizontal",
        items=make_page1_items(q, message="Reset!")
    ))

#########################################################################
# POPUP FORM ############################################################
#########################################################################

def make_popup_items(q: Q, message: str = ""):
    items = [
            ui.textbox("popup_input_1", label="Para 1", value=q.args.popup_input_1 if q.args.popup_input_1 else ""),
            ui.textbox("popup_input_2", label="Para 2", value=q.args.popup_input_2 if q.args.popup_input_2 else ""),
            ui.textbox("popup_input_3", label="Para 3", value=q.args.popup_input_3 if q.args.popup_input_3 else ""),
            ui.buttons([
                ui.button("popup_send", label="Send", primary=True),
                ui.button("popup_reset", label="Reset", primary=False),
            ])
        ]
    if message:
        items.append(ui.text_m(f'<span style="color: red">{message}</span>'))
    return items

@on('#page2')
async def page2(q: Q):
    q.page['sidebar'].value = '#page2'
    clear_cards(q)  # When routing, drop all the cards except of the main ones (header, sidebar, meta).

    add_card(q, "popup_form", ui.form_card(
        box="horizontal",
        items=[ui.button("open_dialog", "Open Dialog")]
    ))

@on("open_dialog")
async def page2(q: Q):
    q.page['meta'].dialog = ui.dialog(
        title='Command',
        name="popup",
        closable=True,
        events=['dismissed'],
        items=make_popup_items(q)
    )

@on(f'popup.dismissed')
async def commanding_popup_dismissed(q: Q):
    q.page['meta'].dialog = None

@on('popup_send')
async def popup_send(q: Q):
    print(q.args)
    if all([q.args.popup_input_1, q.args.popup_input_2, q.args.popup_input_3]):
        q.args.popup_input_1 = q.args.popup_input_2 = q.args.popup_input_3 = ""
        q.page['meta'].dialog.items = make_popup_items(q, message="Sent")
    else:
        q.page['meta'].dialog.items = make_popup_items(q, message="No field may be empty!")

    await q.page.save()

@on('popup_reset')
async def popup_reset(q: Q):
    print(q.args)
    q.args.popup_input_1 = q.args.popup_input_2 = q.args.popup_input_3 = ""
    q.page['meta'].dialog.items = make_popup_items(q, message="Reset")
    await q.page.save()

#########################################################################
#########################################################################

async def init(q: Q) -> None:
    q.page['meta'] = ui.meta_card(box='', layouts=[ui.layout(breakpoint='xs', min_height='100vh', zones=[
        ui.zone('main', size='1', direction=ui.ZoneDirection.ROW, zones=[
            ui.zone('sidebar', size='250px'),
            ui.zone('body', zones=[
                ui.zone('content', zones=[
                    # Specify various zones and use the one that is currently needed. Empty zones are ignored.
                    ui.zone('horizontal', direction=ui.ZoneDirection.ROW),
                    ui.zone('vertical'),
                    ui.zone('grid', direction=ui.ZoneDirection.ROW, wrap='stretch', justify='center')
                ]),
            ]),
        ])
    ])])
    q.page['sidebar'] = ui.nav_card(
        box='sidebar', color='primary', title='My App', subtitle="Let's conquer the world!",
        value=f'#{q.args["#"]}' if q.args['#'] else '#page1',
        image='https://wave.h2o.ai/img/h2o-logo.svg', items=[
            ui.nav_group('Menu', items=[
                ui.nav_item(name='#page1', label='Page Form'),
                ui.nav_item(name='#page2', label='Dialog Form'),
            ]),
        ],
        secondary_items=[
            ui.persona(title='John Doe', subtitle='Developer', size='s',
                       image='https://images.pexels.com/photos/220453/pexels-photo-220453.jpeg?auto=compress&h=750&w=1260'),
        ]
    )

    # If no active hash present, render page1.
    if q.args['#'] is None:
        await page1(q)

@app('/')
async def serve(q: Q):
    # Run only once per client connection.
    if not q.client.initialized:
        q.client.cards = set()
        await init(q)
        q.client.initialized = True

    # Handle routing.
    await run_on(q)
    await q.page.save()
mtanco commented 7 months ago

Hello,

Thank you for all the details! While this bug and resolution are discussed, I wanted to let you know about copy_expando which might help if you are currently blocked.

You are currently doing a lot of work in the Query Arguments q.args variable, but you are looking at content for each browser session that you want to access across many calls. Instead, it would be preferred to use the q.client variable. These will persist across interactions in the client session, and be separate for each browser session.

  1. You can assign each args to a client in your popup_send function.

    ...
    q.client.popup_input_3 = q.args.popup_input_3
  2. Or, if your form gets long and this is tedious, you can copy all values from q.args into q.client such as

from h2o_wave import ... copy_expando

@app('/')
async def serve(q: Q):
    copy_expando(q.args, q.client)
    ...

# replace all q.args.variable with q.client.variable

Hope this helps!

aranvir commented 6 months ago

Hi, thanks for the feedback! I am using q.client to buffer certain things but was not aware of copy_expando. That looks like a good workaround for my actual use case where I generate the input forms dynamically.

Regarding using q.args a lot: I don't understand why q.client should be the preferred solution for this. I tend to use q.client for information I want to persist during the client session over multiple pages and that is not always form related. To me it feels natural to use q.args to persist data for the current form since I then don't have to worry about cleaning it when I move to the next page/form. There, I did run once into the problem that I was buffering data in q.client for a form template that I used on multiple pages and thus had to add a reset to that specific buffer just in case.

So, of course that's not a biggy, but with q.args I could skip these steps :) Still, happy to learn about better alternatives!