Open 3tilley opened 2 years ago
HI @3tilley
Have you tried using long_callback
in dash? https://dash.plotly.com/long-callbacks#example-2:-disable-button-while-callback-is-running
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?
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:
running=(Output(...), True, False)
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 intuitivelong_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 thoughAll 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?
```python 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:
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 returnOutput("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!