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

Table `select` event prevents correct execution of other table events #2196

Closed aranvir closed 6 months ago

aranvir commented 7 months ago

Wave SDK Version, OS

Python 3.10.7 Wave 1.0.0 Windows10

Actual behavior

I wanted to built a table that provides filtering and searching but also a "detailed view" when clicking on one of the rows. I wrote a quick demo but the problem is: when the select event is active, all other events lead to the page being stuck in reload (Reloading overlay).

I modified the example using the old handle_on instead of run_on but the outcome is similar: the page refreshes immediately and any filters or search words vanish.

In both cases, opening a pop-up by clicking on the table works fine.

Expected behavior

The select event should only be emitted for selecting a row, not when clicking on the search bar or on the column to select a filter.

Steps To Reproduce


Example code with run_on()

from h2o_wave import main, app, Q, ui, on, run_on, data
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)

@on("table.select")
async def page1_table(q: Q):
    if not len(q.events.table.select) > 0:
        print("Empty: events.table.select")
    else:
        q.page['meta'].dialog = ui.dialog(
            title='Popup',
            name='popup',
            closable=True,
            events=['dismissed'],
            items=[
                ui.text(f'Clicked: {q.events.table.select[0]}'),
                ui.buttons([
                    ui.button(name='cancel', label='Cancel'),
                    ui.button(name='submit', label='Submit', primary=True),
                ]),
            ]
        )

@on('popup.dismissed')
async def page1_popup_dismissed(q: Q):
    q.page['meta'].dialog = None
    print("DISMISSED")

@on('cancel')
async def on_cancel(q: Q):
    q.page['meta'].dialog = None
    print("CANCEL")

@on('submit')
async def on_submit(q: Q):
    q.page['meta'].dialog = None
    print("SUBMIT")

@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, "example", ui.form_card(box='horizontal', items=[
        ui.text_l("test"),
        ui.table(
            name='table',
            columns=[ui.table_column(
                name='text', label='Table select event',
                filterable=True, searchable=True, sortable=True
            )],
            rows=[
                ui.table_row(name='row1', cells=['Row 1']),
                ui.table_row(name='row2', cells=['Row 2']),
                ui.table_row(name='row3', cells=['Row 3'])
            ],
            single=True,
            checkbox_visibility='hidden',
            events=['select'],
        )
    ]))

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='Home'),
            ]),
        ],
        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

    print(q.args)
    print(q.events)

    # Handle routing.
    await run_on(q)
    await q.page.save()

Example code with handle_on()

from h2o_wave import main, app, Q, ui, on, handle_on, data
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)

@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).

    if q.events.popup:
        if q.events.popup.dismissed:
            q.page['meta'].dialog = None
            print("DISMISSED")

    if q.events.table:
        if q.events.table.select:
            if not len(q.events.table.select) > 0:
                print("Empty: events.table.select")
            else:
                q.page['meta'].dialog = ui.dialog(
                    title='Popup',
                    name='popup',
                    closable=True,
                    events=['dismissed'],
                    items=[
                        ui.text(f'Clicked: {q.events.table.select[0]}'),
                        ui.buttons([
                            ui.button(name='cancel', label='Cancel'),
                            ui.button(name='submit', label='Submit', primary=True),
                        ]),
                    ]
                )

    if q.args.cancel:
        q.page['meta'].dialog = None
        print("CANCEL")
    elif q.args.submit:
        q.page['meta'].dialog = None
        print("SUBMIT")

    add_card(q, "example", ui.form_card(box='horizontal', items=[
        ui.text_l("test"),
        ui.table(
            name='table',
            columns=[ui.table_column(
                name='text', label='Table select event',
                filterable=True, searchable=True, sortable=True
            )],
            rows=[
                ui.table_row(name='row1', cells=['Row 1']),
                ui.table_row(name='row2', cells=['Row 2']),
                ui.table_row(name='row3', cells=['Row 3'])
            ],
            single=True,
            checkbox_visibility='hidden',
            events=['select'],
        )
    ]))

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='Home'),
            ]),
        ],
        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

    print(q.args)
    print(q.events)

    # Handle routing.
    await handle_on(q)
    await q.page.save()
aranvir commented 7 months ago

Update (because I remember encountering the problem before and finding a workaround):

Disable the select event and instead add a column with link=True. Handling the click on the link cell (@on(table)) allows opening the popup just as well but it does not interfere with any of the other table events.

Example with run_on()

from h2o_wave import main, app, Q, ui, on, run_on, handle_on, data
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)

@on("table")
async def page1_table(q: Q):
    q.page['meta'].dialog = ui.dialog(
        title='Popup',
        name='popup',
        closable=True,
        events=['dismissed'],
        items=[
            ui.text(f'Clicked: {q.events.table}'),
            ui.buttons([
                ui.button(name='cancel', label='Cancel'),
                ui.button(name='submit', label='Submit', primary=True),
            ]),
        ]
    )

@on('popup.dismissed')
async def page1_popup_dismissed(q: Q):
    q.page['meta'].dialog = None
    print("DISMISSED")

@on('cancel')
async def on_cancel(q: Q):
    q.page['meta'].dialog = None
    print("CANCEL")

@on('submit')
async def on_submit(q: Q):
    q.page['meta'].dialog = None
    print("SUBMIT")

@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, "example", ui.form_card(box='horizontal', items=[
        ui.text_l("test"),
        ui.table(
            name='table',
            columns=[
                ui.table_column(name='text', label='Table select event', filterable=True, searchable=True,sortable=True),
                ui.table_column(name='details', label='Details', link=True),
                ],
            rows=[
                ui.table_row(name='row1', cells=['Row 1', 'Details']),
                ui.table_row(name='row2', cells=['Row 2', 'Details']),
                ui.table_row(name='row3', cells=['Row 3', 'Details'])
            ],
            single=True,
            checkbox_visibility='hidden',
            # events=['select'],
            resettable=True
        )
    ]))

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='Home'),
            ]),
        ],
        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

    print(q.args)
    print(q.events)

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

Thanks for reporting @aranvir! @marek-mihok please have a look.