diff --git a/digest/app.py b/digest/app.py index 56d83c0..9662a23 100644 --- a/digest/app.py +++ b/digest/app.py @@ -155,6 +155,40 @@ def display_dataset_metadata(parsed_data): ) +@app.callback( + Output("filtering-syntax-help", "style"), + Input("memory-overview", "data"), + prevent_initial_call=True, +) +def display_filtering_syntax_help(parsed_data): + """When successfully uploaded data changes, show collapsible help text for datatable filtering syntax.""" + if parsed_data is None: + return {"display": "none"} + return {"display": "block"} + + +@app.callback( + [ + Output("filtering-syntax-help-collapse", "is_open"), + Output("filtering-syntax-help-icon", "className"), + ], + Input("filtering-syntax-help-button", "n_clicks"), + [ + State("filtering-syntax-help-collapse", "is_open"), + State("filtering-syntax-help-icon", "className"), + ], + prevent_initial_call=True, +) +def toggle_filtering_syntax_collapse_content(n_clicks, is_open, class_name): + """When filtering syntax help button is clicked, toggle visibility of the help text.""" + if n_clicks: + if class_name == "bi bi-caret-right-fill me-1": + return not is_open, "bi bi-caret-down-fill me-1" + return not is_open, "bi bi-caret-right-fill me-1" + + return is_open, class_name + + @app.callback( [ Output("session-dropdown", "options"), @@ -251,7 +285,13 @@ def update_outputs( status_values=pipeline_selected_filters, ) tbl_columns = [ - {"name": i, "id": i, "hideable": True} for i in data.columns + { + "name": i, + "id": i, + "hideable": True, + "type": util.type_column_for_dashtable(data[i]), + } + for i in data.columns ] tbl_data = data.to_dict("records") @@ -447,6 +487,7 @@ def plot_phenotypic_column( @app.callback( [ Output("column-summary-title", "children"), + Output("column-data-type", "children"), Output("column-summary", "children"), Output("column-summary-card", "style"), ], @@ -454,20 +495,36 @@ def plot_phenotypic_column( Input("phenotypic-column-plotting-dropdown", "value"), Input("interactive-datatable", "derived_virtual_data"), ], - State("memory-overview", "data"), + [ + State("memory-overview", "data"), + State("interactive-datatable", "columns"), + ], prevent_initial_call=True, ) def generate_column_summary( - selected_column: str, virtual_data: list, parsed_data: dict + selected_column: str, + virtual_data: list, + parsed_data: dict, + datatable_columns: list, ): """When a column is selected from the dropdown, generate summary stats of the column values.""" if selected_column is None or parsed_data.get("type") != "phenotypic": - return None, None, {"display": "none"} + return None, None, None, {"display": "none"} + + column_datatype = next( + ( + column.get("type", None) + for column in datatable_columns + if column["name"] == selected_column + ), + None, + ) # If no data is visible in the datatable (i.e., zero matches), return an informative message if not virtual_data: return ( selected_column, + column_datatype, html.I("No matching records available to compute value summary"), {"display": "block"}, ) @@ -475,6 +532,7 @@ def generate_column_summary( column_data = pd.DataFrame.from_dict(virtual_data)[selected_column] return ( selected_column, + column_datatype, util.generate_column_summary_str(column_data), {"display": "block"}, ) @@ -494,4 +552,4 @@ def display_session_switch(selected_column: str): if __name__ == "__main__": - app.run_server(debug=True) + app.run(debug=True) diff --git a/digest/layout.py b/digest/layout.py index c717bd6..204f27e 100644 --- a/digest/layout.py +++ b/digest/layout.py @@ -223,9 +223,16 @@ def status_legend_card(): id="title-tooltip-target", ), dbc.Tooltip( - dcc.Markdown( - "These are the recommended status definitions for processing progress. " - "For more details, see the [schema for an imaging digest file](https://github.com/neurobagel/digest/blob/main/schemas/bagel_schema.json)." + html.P( + [ + "These are the recommended status definitions for processing progress. For more details, see the ", + html.A( + "schema for an imaging digest file", + href="https://github.com/neurobagel/digest/blob/main/schemas/bagel_schema.json", + target="_blank", + ), + ], + className="mb-0", ), autohide=False, target="title-tooltip-target", @@ -247,6 +254,64 @@ def status_legend_card(): ) +def filtering_syntax_help_collapse(): + """Generates the collapse element that displays syntax help for built-in datatable filtering.""" + return html.Div( + [ + dbc.Button( + [ + html.I( + id="filtering-syntax-help-icon", + className="bi bi-caret-right-fill me-1", + ), + "Built-in datatable filtering syntax", + ], + color="link", + id="filtering-syntax-help-button", + n_clicks=0, + className="ps-0", + ), + dbc.Collapse( + dbc.Card( + html.P( + [ + dcc.Markdown( + "To filter column values in the table below, " + "supported operators include: `contains` (default), " + "`=`, `>`, `<`, `>=`, `<=`, `!=`. " + "To filter a column for missing (empty) values, use `is blank`.\n" + "(Note: there is currently no filter for `is not blank`. This is a known limitation that will be fixed in the future.)", + style={"white-space": "pre-wrap"}, + # NOTE: dcc.Markdown actually has problems rendering custom padding/margin (https://community.plotly.com/t/dcc-markdown-style-margin-adjustment/15208) and by default always has bottom padding + # As a result, the below setting actually doesn't anything (but is left here in case dcc.Markdown is fixed in the future) + className="mb-0", + ), + html.P( + [ + "For detailed info on the filtering syntax available, see ", + html.A( + children="here.", + href="https://dash.plotly.com/datatable/filtering", + target="_blank", + ), + " To filter based on multiple sessions simultaneously, use the advanced filtering options below.", + ], + className="mb-0", + ), + ], + className="mb-0", + ), + body=True, + ), + id="filtering-syntax-help-collapse", + is_open=False, + ), + ], + id="filtering-syntax-help", + style={"display": "none"}, + ) + + def overview_table(): """Generates overview table for the tasks in the tabular data (imaging or phenotypic).""" return dash_table.DataTable( @@ -285,9 +350,14 @@ def advanced_filter_form_title(): id="tooltip-question-target", ), dbc.Tooltip( - dcc.Markdown( - "Filter based on multiple sessions simultaneously. " - "Note that any data filters selected here will always be applied *before* any column filters specified directly in the data table." + html.P( + [ + "Filter based on multiple sessions simultaneously. " + "Note that any data filters selected here will always be applied ", + html.I("before"), + " any column filters specified directly in the data table.", + ], + className="mb-0", ), target="tooltip-question-target", ), @@ -389,9 +459,17 @@ def column_summary_card(): className="card-title", ), html.P( - id="column-summary", - style={"whiteSpace": "pre-wrap"}, # preserve newlines + [ + "column data type: ", + html.Span(id="column-data-type"), + html.P(), + html.P( + id="column-summary", + className="mb-0", + ), + ], className="card-text", + style={"whiteSpace": "pre-wrap"}, ), ], ), @@ -440,6 +518,7 @@ def construct_layout(): ), ] ), + filtering_syntax_help_collapse(), overview_table(), ], style={"margin-top": "10px", "margin-bottom": "10px"}, diff --git a/digest/utility.py b/digest/utility.py index 37e7168..653e0be 100644 --- a/digest/utility.py +++ b/digest/utility.py @@ -43,6 +43,23 @@ def reset_column_dtypes(data: pd.DataFrame) -> pd.DataFrame: return data_retyped +def type_column_for_dashtable(df_column: pd.Series) -> str: + """ + Determines the appropriate type for a given column for use in a dash datatable, using Pandas and the column dtype. + This is needed because dash datatable does not automatically infer column data types, and will treat all columns as 'text' for filtering purposes by default + (the actual default column type is 'any' if not defined manually). + + See also https://dash.plotly.com/datatable/filtering. + + # TODO: + # - This is pretty simplistic and mainly to enable easier selection of filtering UI syntax - we might be able to remove this after switch to AG Grid + # - If needed, in future could support explicitly setting 'datetime' type as well, by applying pd.to_datetime() and catching any errors + """ + if np.issubdtype(df_column.dtype, np.number): + return "numeric" + return "text" + + def construct_legend_str(status_desc: dict) -> str: """From a dictionary, constructs a legend-style string with multiple lines in the format of key: value.""" return "\n".join(