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

Input elements to disable on click #864

Open
3tilley opened this issue May 27, 2022 · 3 comments
Open

Input elements to disable on click #864

3tilley opened this issue May 27, 2022 · 3 comments
Labels
help wanted Extra attention is needed

Comments

@3tilley
Copy link
Contributor

3tilley commented May 27, 2022

Hi all,

We have an issue whereby multiple clicks of a button get sent to the backend before we can respond and disable the button in a callback. Imagine a button that you only want to be allow to get pressed once before the server has responded with some updated data. Perhaps a form with some values and an "Execute Trade" button. You would like to make sure the trade is sent once, portfolios and prices are calculated in dash and then updated on the client, before allowing them to press again. You might instead disable the button until the trade has totally completed.

We have explored different options on the backend, from opening a transaction block, to requiring a token that represents the latest update, but all of these methods are a bit fiddly, and offer a poor UX. What we would like to happen would be a button that upon clicking, disables itself, and then it's the server's job to renable that when it sees fit.

I can't see a way of doing this faster than someone can double-click other than doing it in the Javascript. I envisage something like dbc.Button(id="my-button", disable_on_click=True). You could do something more advanced like disabling automatically until completion of the callback, but perhaps it's clearer to just make the callback return Output("my-button", "disabled").

This is more of a question for dash than dbc I guess, but I think that LoadingState has to be engaged by the backend, so wouldn't work here?

Unless there is a workaround, does this seem useful? If so, could anyone offer me guidance on how to implement this? I'm a confident Python dev but an intimidated Javascript one. If there is some prior art I'm happy to take that and try and learn from it.

Thanks!

@AnnMarieW
Copy link
Contributor

@3tilley
Copy link
Contributor Author

3tilley commented May 27, 2022

Thanks for the swift response!

Yes we tried it, and for some reason it didn't behave reliably, as in the disabling on click didn't seem to be prompt. It seemed like the loading state still needed to be computed on the backend? Do you know if that's the case?

@3tilley
Copy link
Contributor Author

3tilley commented Jun 1, 2022

Hello,

I did a load of testing on this, and I wasn't able to use long_callback to avoid this issue. long_callback also introduced a number of other problems. It's not something I've used a lot before, so no doubt there is stuff for me to learn about using it optimally. I found that:

  • It was still possible to trigger two jobs before the button disabled, despite using running=(Output(...), True, False)
  • Once it was triggered once, the long_callback kept triggering over and again. This is likely an error somewhere in my implementation, but I did spend a fair amount of time on it tweaking bits to try and make it work - it's certainly not intuitive
  • I was unable to use the same debugging tools I would have to diagnose it. With long_callback in the example Intellij's debugger wasn't able to start, and I wasn't able to get the triggered prop id to log. The latter is well documented though
  • The timestamps from the frontend seemed unusual, they didn't seem to represent when they had been formally triggered in the browser
  • The server page reloaded every time the callback did. I guess this is to allow it to run different jobs in the background, but it was confusing at first. To avoid connection and config initialisation, it would require an annoying rearchitecture of most apps to allow this to happen in a performant manner

All in all, it brought a lot of complexity to my demo app, and while it looked like was I wanted, it ultimately wasn't.

I'm still of the mind that the way to do this is to the disabling into the Javascript of the button. Would you consider that PR?

Demo Code

import logging
import os
from datetime import datetime
from uuid import uuid4

from dash import Dash, html, Input, Output, State
import dash_bootstrap_components as dbc
from dash.long_callback import DiskcacheLongCallbackManager

## Diskcache
import diskcache

DEFAULT_STATUS = "No clicks received"
launch_uid = uuid4()
cache = diskcache.Cache("./cache")
long_callback_manager = DiskcacheLongCallbackManager(
    cache,
    cache_by=[lambda: launch_uid],
    expire=60,
)

logging.basicConfig(level=logging.DEBUG)

app = Dash(
    "clicker",
    external_stylesheets=[dbc.themes.BOOTSTRAP],
    long_callback_manager=long_callback_manager,
    prevent_initial_callbacks=True,
)

app.layout = html.Div(
    [
        dbc.Button("Standard button", id="btn-1"),
        html.Div(id="status", children=DEFAULT_STATUS),
        html.Div(id="token"),
        dbc.Button("Long Callback", id="btn-2"),
        html.Div(id="long-status", children=DEFAULT_STATUS),
        html.Div(id="long-token"),
    ]
)


token = 0
last_click = None

# long_callback needs multiprocessing tools
TOKEN_KEY = "SHARED_TOKEN_KEY"
LAST_CLICK_TIMESTAMP = "LAST_CLICK_TIMESTAMP"


def log_values():
    logging.info(f"{TOKEN_KEY}: {cache.get(TOKEN_KEY)}")
    logging.info(f"{LAST_CLICK_TIMESTAMP}: {cache.get(LAST_CLICK_TIMESTAMP)}")


logging.info(f"Module executed on process: {os.getpid()}")


@app.callback(
    Output("token", "children"),
    Output("status", "children"),
    Input("btn-1", "n_clicks_timestamp"),
    State("token", "children"),
)
def display_click(n_clicks_timestamp, current_token):
    logging.info(f"Regular callback triggered on {os.getpid()}")
    global token
    global last_click
    msg = DEFAULT_STATUS
    if n_clicks_timestamp:
        dt = datetime.fromtimestamp(n_clicks_timestamp / 1000.0)
        if last_click:
            elapsed = (dt - last_click).total_seconds()
            msg = f"Time since last click {elapsed}s. "
        last_click = dt

    if not current_token:
        pass
    elif int(current_token) == token:
        msg += "Front end updated in time"
    else:
        msg += "WARNING: Token out of date"
    token += 1

    return str(token), html.Div(msg)


@app.long_callback(
    Output("long-token", "children"),
    Output("long-status", "children"),
    Input("btn-2", "n_clicks_timestamp"),
    State("long-token", "children"),
    running=[(Output("btn-2", "disabled"), True, False)],
)
def callback(n_clicks_timestamp, current_token):
    log_values()
    logging.info(
        f"long_callback triggered on {os.getpid()}. app object at: {id(app)}"
    )
    logging.debug(f"Front-end token: {current_token}")
    logging.debug(f"Front-end timestamp: {n_clicks_timestamp}")
    logging.debug(f"Token: {cache.get(TOKEN_KEY)}")
    logging.debug(f"Last Click: {cache.get(LAST_CLICK_TIMESTAMP)}")
    msg = DEFAULT_STATUS
    if n_clicks_timestamp:
        with diskcache.Lock(cache, LAST_CLICK_TIMESTAMP + "_LOCK"):
            last_timestamp = cache.get(LAST_CLICK_TIMESTAMP)
            dt = datetime.fromtimestamp(n_clicks_timestamp / 1000.0)
            if last_timestamp:
                long_last_click = datetime.fromtimestamp(last_timestamp)
                elapsed = (dt - long_last_click).total_seconds()
                msg = f"Time since last click {elapsed}s. "

            cache.set(LAST_CLICK_TIMESTAMP, dt.timestamp())

    with diskcache.Lock(cache, TOKEN_KEY + "_LOCK"):
        token = cache.get(TOKEN_KEY)
        if n_clicks_timestamp:
            if not current_token or not n_clicks_timestamp:
                pass
            elif int(current_token) == token:
                msg += "Front end updated in time"
            else:
                msg += "WARNING: Token out of date"

            token += 1
            cache.set(TOKEN_KEY, token)
            logging.debug(f"Token: {token}")
            log_values()
    return str(token), html.Div(msg)


if __name__ == "__main__":
    # Cache has to be set here as long_callback reloads main.py every time
    cache.set(TOKEN_KEY, 0)
    cache.set(LAST_CLICK_TIMESTAMP, None)
    log_values()
    app.run(debug=True)

Collapsible until here.

Screenshot:

image

@tcbegley tcbegley added the help wanted Extra attention is needed label Dec 28, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help wanted Extra attention is needed
Development

No branches or pull requests

3 participants