facultyai / dash-bootstrap-components

Bootstrap components for Plotly Dash
https://dash-bootstrap-components.opensource.faculty.ai/
Apache License 2.0
1.12k stars 220 forks source link

Input elements to disable on click #864

Open 3tilley opened 2 years ago

3tilley commented 2 years ago

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 commented 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

3tilley commented 2 years ago

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 commented 2 years ago

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:

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

```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:

image