Dashでdcc.CheckListのチェック状態を全選択・全解除するボタンの実装

日本語と英語で検索しても全然ロクな文献がヒットしなかったので、検索ひっかかるように併記しておきます

Adding a 'Select All' and 'Remove All' Button to a dcc.CheckList.


# 達成したいこと
- 複数個のチェックボックスを、別に用意したボタンによって全選択・全解除する

結果だけ知りたい方は下へスクロールしてください

# やったこと

## dcc.CheckListのvalueをcallbackで制御する

チェックリストとボタンだけのさっぱりした環境で調査

<img width="821" alt="スクリーンショット 2020-02-19 15.43.06.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529606/5936b1ae-5030-26ca-8896-3402d3076e89.png">

当初のソース / at first
`python {ファイル名.py} access localhost:{任意のポート}`
で起動できます

    import dash
    import dash_core_components as dcc
    import dash_html_components as html
    import dash_table
    from dash.dependencies import Input, Output, State
    from flask import Flask, request


    server = Flask(__name__)
    external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
    app = dash.Dash(__name__, external_stylesheets=external_stylesheets, server=server)

    app.title = 'checklist-test'

    selected_key = None


    checklists = dcc.Checklist(
        id='checklist-states',
        options=[
            {'label': 'New York City', 'value': 'NYC'},
            {'label': 'Montréal', 'value': 'MTL'},
            {'label': 'San Francisco', 'value': 'SF'}
        ],
        value=['MTL', 'SF']
    )


    app.layout = html.Div(id='main',children=[
        html.H1(children='チェックリストのテスト'),
        dcc.Location(id='location', refresh=False),
        html.Div(className='main-block', children=[checklists]),
        html.Div(className='second', children=[
            html.Button('全選択', id='filter-check-button', className='filter_button', n_clicks=0),
            html.Button('全解除', id='filter-remove-button', className='filter_button', n_clicks=0)
            ])
        ])


    @app.callback(
        [Output('checklist-states', 'value')],
        [Input('filter-check-button', 'n_clicks'),
        Input('filter-remove-button', 'n_clicks')]
    )
    def remove_check(all_check, all_remove):

        if all_check:
            return ['NYC', 'MTL', 'SF']
        if all_remove:
            return []



    if __name__ == '__main__':
        app.run_server(host='0.0.0.0', debug=True)

---


#### CheckListのvalueへ任意のリストを返却すればいけるっしょ！と思ったが違う

全選択ボタンを押下したとき `return ['NYC', 'MTL', 'SF']`

    dash.exceptions.InvalidCallbackReturnValue: Invalid number of output values for ..checklist-states.value...
     Expected 1 got 3

全解除ボタンを押下したとき `return []`

    dash.exceptions.InvalidCallbackReturnValue: Invalid number of output values for ..checklist-states.value...
     Expected 1 got 0

#### どうも勝手に皮むきするっぽいので応急処置

    if all_check:
        return [['NYC', 'MTL', 'SF']]
    if all_remove:
        return [[]]

動くもののエラーは出る
どうもボタンがロードされる時？に一度実行されるっぽく、もろもろが未定義な状態で返り値を渡してしまうためのエラーっぽい

    dash.exceptions.InvalidCallbackReturnValue: The callback ..checklist-states.value.. is a multi-output.
    Expected the output type to be a list or tuple but got None.

#### クリック以外で呼ばれたときを明示的に対処する

checklistのvalueをStateを使って読み込む

    callbackにState
        @app.callback(
        [Output('checklist-states', 'value')],
        [Input('filter-check-button', 'n_clicks'),
        Input('filter-remove-button', 'n_clicks')],
        [State('checklist-states', 'value')]    # callbackが発生したときに値を取得する
    )
    def update_check(all_check, all_remove, value_checking):

どっちもNoneなら元々valueに入ってる値を返す
皮むき対策も忘れずに

        if all_check:
            return [['NYC', 'MTL', 'SF']]

        if all_remove:
            return [[]]

        if all_check is None and all_remove is None:
            return [value_checking]    # 皮むき対策



## ボタンを何回もクリックする

#### n_clicksがダメっぽい

いけた！と思ったらクリック1発目しか機能しない
調べてみるとn_clickは*クリック回数をカウントする*という機能らしく、クリックすると0,1,2,3,4...と値が増えていくらしい

これはこの野郎…！と思いながら撮ったSS
<img width="175" alt="スクリーンショット 2020-02-19 17.38.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529606/d05935d1-3b02-b2f0-510a-977406404cf1.png">

現在Dashには、そもそもクリックされたことだけを検知する機能は実装されていないらしい
代わりに *クリックしたunixtimestamp(ミリ秒まで)を取得する* n_clicks_timestampというものが用意されているので、それを使用する

#### n_clicks_timestampを使う
イベントが発生したタイミングと今が1秒以内ならオッケーという処理にしておく

        if not all_check is None:
            if (time.time() * 1000 - all_check) < 1000:
                return [['NYC', 'MTL', 'SF']]

        if not all_remove is None:
            if (time.time() * 1000 - all_remove) < 1000:
                return [[]]

        if all_check is None and all_remove is None:
            return [checking]


# 結果

*𝐬𝐮𝐜𝐜𝐞𝐬𝐬*

![8nd91-kgmbx.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529606/ff5c3691-0550-9f56-cc6f-efaa2413c6fb.gif)


最終的なソース / in the end


    import dash
    import dash_core_components as dcc
    import dash_html_components as html
    import dash_table
    from dash.dependencies import Input, Output, State
    from flask import Flask, request

    import time

    server = Flask(__name__)
    external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
    app = dash.Dash(__name__, external_stylesheets=external_stylesheets, server=server)

    app.title = 'checklist-test'

    selected_key = None


    checklists = dcc.Checklist(
        id='checklist-states',
        options=[
            {'label': 'New York City', 'value': 'NYC'},
            {'label': 'Montréal', 'value': 'MTL'},
            {'label': 'San Francisco', 'value': 'SF'}
        ],
        value=['MTL', 'SF']
    )


    app.layout = html.Div(id='main',children=[
        html.H1(children='チェックリストのテスト'),
        dcc.Location(id='location', refresh=False),
        html.Div(className='main-block', children=[checklists]),
        html.Div(className='second', children=[
            html.Button('全選択', id='filter-check-button', className='filter_button'),
            html.Button('全解除', id='filter-remove-button', className='filter_button')
            ])
        ])


    @app.callback(
        [Output('checklist-states', 'value')],
        [Input('filter-check-button', 'n_clicks_timestamp'),
        Input('filter-remove-button', 'n_clicks_timestamp')],
        [State('checklist-states', 'value')]
    )
    def update_check(all_check, all_remove, checking):

            if not all_check is None:
                if (time.time() * 1000 - all_check) < 1000:
                    return [['NYC', 'MTL', 'SF']]

            if not all_remove is None:
                if (time.time() * 1000 - all_remove) < 1000:
                    return [[]]

            if all_check is None and all_remove is None:
                return [checking]



    if __name__ == '__main__':
        app.run_server(host='0.0.0.0', debug=True)