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

Dialog forms have inconsistent q.args behavior #2211

Open
aranvir opened this issue Dec 1, 2023 · 2 comments
Open

Dialog forms have inconsistent q.args behavior #2211

aranvir opened this issue Dec 1, 2023 · 2 comments
Labels
bug Bug in code

Comments

@aranvir
Copy link
Contributor

aranvir commented Dec 1, 2023

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:

  • When submitting the form for the first time, all inputs, empty or not, are part of q.args and can be passed as values to the reloaded form.
  • When submitting the form for the second time, NO inputs are part of q.args even not those that were visible before in the ui. The ui now also appears empty.
  • Funnily, when submitting the form for a third time, ONLY the inputs that had a value initially are part of q.args but are now empty
  • From the fourth time on, no inputs are part of q.args unless provided

Considering this it can get a extra funny:

  • Submit the form with 1 value missing. The other values show up in the form.
  • If you now add the missing value and submit again, the previously present values disappear and only the recent added input is maintained
  • The game continues unless all inputs are filled in one go. Clicking on an input is not enough, it must be edited to be registered as a change

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:

  • Click Send without any input -> q.args includes all input fields
  • Click Send without any input multiple times -> q.args includes all input fields
  • Click Send with partial input -> q.args includes all input fields and the ui shows the previous input
  • Click Send with partial input multiple times-> q.args includes all input fields and the ui shows the previous input
  • Click Send with complete input -> q.args includes all input fields. The form is submitted and the fields are cleared
  • Click Reset -> The fields are cleared

On Dialog Form:

  • Repeat the same steps as for Page Form
  • It will NOT show the same result. Instead, the "Actual behavior" will occur

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()
@aranvir aranvir added the bug Bug in code label Dec 1, 2023
@mtanco
Copy link
Contributor

mtanco commented Dec 5, 2023

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
  1. 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
Copy link
Contributor Author

aranvir commented Dec 6, 2023

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Bug in code
Projects
None yet
Development

No branches or pull requests

2 participants