From 6a1fb286444a5101f96d0f34dc9bf74626327aa7 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Wed, 14 Aug 2024 16:55:01 +0200 Subject: [PATCH 01/26] add datatable layout --- app/layouts.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/app/layouts.py b/app/layouts.py index b4cc67d..3bdf92d 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -2,6 +2,8 @@ import dash_bootstrap_components as dbc import dash_mantine_components as dmc import dash_uploader as du +import pandas as pd +from dash import dash_table from dash import dcc from dash import html @@ -102,11 +104,60 @@ ) # gm graph gm_graph = dcc.Graph(id="gm-graph", className="mt-5 mb-3", style={"display": "none"}) +# gm_table +## Sample data +df = pd.DataFrame( + { + "GCF ID": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"], + "# BGC": ["3", "1", "10", "6", "3", "1", "10", "6", "3", "1", "10", "6"], + } +) +## Table +gm_table = dbc.Card( + [ + dbc.CardHeader("Select data", id="gm-table-card-header", style={"color": "#888888"}), + dbc.CardBody( + [ + dash_table.DataTable( + id="gm-table", + columns=[ + {"name": i, "id": i, "deletable": False, "selectable": False} + for i in df.columns + ], + data=df.to_dict("records"), + editable=False, + filter_action="native", + sort_action="none", + sort_mode="multi", + column_selectable="single", + row_deletable=False, + row_selectable="multi", + selected_columns=[], + selected_rows=[], + page_action="native", + page_current=0, + page_size=10, + style_cell={"textAlign": "left", "padding": "5px"}, + style_header={ + "backgroundColor": "#FF6E42", + "fontWeight": "bold", + "color": "white", + }, + ), + ], + id="gm-table-card-body", + style={"display": "none"}, # Initially hide the CardBody + ), + html.Div(id="gm-table-output1", className="p-4"), + html.Div(id="gm-table-output2", className="p-4"), + ] +) # gm tab content gm_content = dbc.Row( [ - dbc.Col(gm_accordion, width=10, className="mx-auto"), + dbc.Col(gm_accordion, width=10, className="mx-auto dbc"), dbc.Col(gm_graph, width=10, className="mx-auto"), + dbc.Col(gm_table, width=10, className="mx-auto"), ] ) # mg tab content @@ -145,5 +196,5 @@ def create_layout(): # noqa: D103 return dmc.MantineProvider( - [dbc.Container([navbar, uploader, tabs], fluid=True, className="p-0 dbc")] + [dbc.Container([navbar, uploader, tabs], fluid=True, className="p-0")] ) From 7bcd06da63ba5aadc5a5b14ad3ae426f40cddd7b Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Wed, 14 Aug 2024 16:55:35 +0200 Subject: [PATCH 02/26] change card header --- app/layouts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/layouts.py b/app/layouts.py index 3bdf92d..4dc528b 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -115,7 +115,7 @@ ## Table gm_table = dbc.Card( [ - dbc.CardHeader("Select data", id="gm-table-card-header", style={"color": "#888888"}), + dbc.CardHeader("Data", id="gm-table-card-header", style={"color": "#888888"}), dbc.CardBody( [ dash_table.DataTable( From cfa6630c6e9096055e2ef636bd866f85c7c23edf Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Wed, 14 Aug 2024 16:57:13 +0200 Subject: [PATCH 03/26] add row selection callback --- app/callbacks.py | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 2214943..4a88aaa 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -7,6 +7,7 @@ import dash_bootstrap_components as dbc import dash_mantine_components as dmc import dash_uploader as du +import pandas as pd import plotly.graph_objects as go from config import GM_DROPDOWN_BGC_CLASS_OPTIONS from config import GM_DROPDOWN_MENU_OPTIONS @@ -22,7 +23,7 @@ from dash import html -dash._dash_renderer._set_react_version("18.2.0") +dash._dash_renderer._set_react_version("18.2.0") # type: ignore dbc_css = "https://cdn.jsdelivr.net/gh/AnnMarieW/dash-bootstrap-templates/dbc.min.css" @@ -77,6 +78,8 @@ def upload_data(status: du.UploadStatus) -> tuple[str, str | None]: [ Output("gm-tab", "disabled"), Output("gm-accordion-control", "disabled"), + Output("gm-table-card-header", "style"), + Output("gm-table-card-body", "style"), Output("mg-tab", "disabled"), Output("blocks-id", "data", allow_duplicate=True), Output("blocks-container", "children", allow_duplicate=True), @@ -86,7 +89,7 @@ def upload_data(status: du.UploadStatus) -> tuple[str, str | None]: ) def disable_tabs_and_reset_blocks( file_name: str | None, -) -> tuple[bool, bool, bool, list[str], list[dict[str, Any]]]: +) -> tuple[bool, bool, dict, dict[str, str], bool, list[str], list[dmc.Grid]]: """Manage tab states and reset blocks based on file upload status. Args: @@ -100,13 +103,13 @@ def disable_tabs_and_reset_blocks( """ if file_name is None: # Disable the tabs, don't change blocks - return True, True, True, [], [] + return True, True, {}, {"display": "block"}, True, [], [] # Enable the tabs and reset blocks initial_block_id = [str(uuid.uuid4())] new_blocks = [create_initial_block(initial_block_id[0])] - return False, False, False, initial_block_id, new_blocks + return False, False, {}, {"display": "block"}, False, initial_block_id, new_blocks def create_initial_block(block_id: str) -> dmc.Grid: @@ -337,3 +340,35 @@ def update_placeholder( else: # This case should never occur due to the Literal type, but it satisfies mypy return {"display": "none"}, {"display": "none"}, "", "", "", [] + + +df = pd.DataFrame( + { + "GCF ID": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"], + "# BGC": ["3", "1", "10", "6", "3", "1", "10", "6", "3", "1", "10", "6"], + } +) + + +@app.callback( + Output("gm-table-output1", "children"), + Output("gm-table-output2", "children"), + Input("gm-table", "derived_virtual_data"), + Input("gm-table", "derived_virtual_selected_rows"), +) +def select_rows(rows, selected_rows): + if not rows: + return "No data available.", "No rows selected." + + dff = pd.DataFrame(rows) + + if selected_rows is None: + selected_rows = [] + + selected_rows_data = dff.iloc[selected_rows] + + # to be removed later when the scoring part will be implemented + output1 = f"Total rows: {len(dff)}" + output2 = f"Selected rows: {len(selected_rows)}\nSelected GCF IDs: {', '.join(selected_rows_data['GCF ID'].astype(str))}" + + return output1, output2 From a95881608f8cf0bfbbdc6a63c78944217d9fae32 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Thu, 15 Aug 2024 10:48:29 +0200 Subject: [PATCH 04/26] add select/deselect button --- app/callbacks.py | 39 +++++++++++++++++++++++++++++++++++++++ app/layouts.py | 18 +++++++++++++++++- requirements.txt | 3 ++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 4a88aaa..a110320 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -350,6 +350,44 @@ def update_placeholder( ) +@app.callback( + [Output("gm-table", "selected_rows")], + [Input("gm-rows-selection-button", "n_clicks")], + [ + State("gm-table", "data"), + State("gm-table", "derived_virtual_data"), + State("gm-table", "derived_virtual_selected_rows"), + ], +) +def toggle_selection( + n_clicks: int, original_rows: list, filtered_rows: list, selected_rows: list +) -> list: + """Toggle between selecting all rows and deselecting all rows in a Dash DataTable. + + Args: + n_clicks: Number of button clicks (unused). + original_rows: All rows in the table. + filtered_rows: Rows visible after filtering. + selected_rows: Currently selected row indices. + + Returns: + Indices of selected rows after toggling. + + Raises: + PreventUpdate: If filtered_rows is None. + """ + if filtered_rows is None: + raise dash.exceptions.PreventUpdate + + if not selected_rows or len(selected_rows) < len(filtered_rows): + # If no rows are selected or not all rows are selected, select all filtered rows + selected_ids = [row for row in filtered_rows] + return [[i for i, row in enumerate(original_rows) if row in selected_ids]] + else: + # If all rows are selected, deselect all + return [[]] + + @app.callback( Output("gm-table-output1", "children"), Output("gm-table-output2", "children"), @@ -357,6 +395,7 @@ def update_placeholder( Input("gm-table", "derived_virtual_selected_rows"), ) def select_rows(rows, selected_rows): + """Display the total number of rows and the number of selected rows in the table.""" if not rows: return "No data available.", "No rows selected." diff --git a/app/layouts.py b/app/layouts.py index 4dc528b..1c2ee48 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -115,9 +115,25 @@ ## Table gm_table = dbc.Card( [ - dbc.CardHeader("Data", id="gm-table-card-header", style={"color": "#888888"}), + dbc.CardHeader( + [ + "Data", + ], + id="gm-table-card-header", + style={"color": "#888888"}, + ), dbc.CardBody( [ + dbc.Row( + dbc.Col( + dbc.Button( + "Select/deselect all", + id="gm-rows-selection-button", + className="mb-3", + ), + className="text-center", + ) + ), dash_table.DataTable( id="gm-table", columns=[ diff --git a/requirements.txt b/requirements.txt index d69e881..49b8ebc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ dash-mantine-components dash_bootstrap_templates numpy dash-uploader==0.7.0a1 -packaging==21.3.0 \ No newline at end of file +packaging==21.3.0 +python-dotenv \ No newline at end of file From bb1ed13e473d75b7396a1f3b1c5a26c5be23bc47 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Thu, 15 Aug 2024 11:35:27 +0200 Subject: [PATCH 05/26] store uploaded data into json and display them on the table --- app/callbacks.py | 139 +++++++++++++++++++++++++++++------------------ app/layouts.py | 17 +----- 2 files changed, 89 insertions(+), 67 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index a110320..a9076b6 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -1,3 +1,4 @@ +import json import os import pickle import tempfile @@ -74,12 +75,45 @@ def upload_data(status: du.UploadStatus) -> tuple[str, str | None]: return "No file uploaded", None +@app.callback( + Output("processed-data-store", "data"), Input("file-store", "data"), prevent_initial_call=True +) +def process_uploaded_data(file_path): + """Process the uploaded pickle file and store the processed data. + + Args: + file_path: Path to the uploaded pickle file. + + Returns: + JSON string of processed data or None if processing fails. + """ + if file_path is None: + return None + + try: + with open(file_path, "rb") as f: + data = pickle.load(f) + + # Extract and process only the necessary data + _, gcfs, *_ = data + processed_data = {"n_bgcs": {}, "gcf_data": [(gcf.id, len(gcf.bgcs)) for gcf in gcfs]} + for gcf_id, n_bgcs in processed_data["gcf_data"]: + if n_bgcs not in processed_data["n_bgcs"]: + processed_data["n_bgcs"][n_bgcs] = [] + processed_data["n_bgcs"][n_bgcs].append(gcf_id) + + return json.dumps(processed_data) + except Exception as e: + print(f"Error processing file: {str(e)}") + return None + + @app.callback( [ Output("gm-tab", "disabled"), Output("gm-accordion-control", "disabled"), Output("gm-table-card-header", "style"), - Output("gm-table-card-body", "style"), + Output("gm-table-card-body", "style", allow_duplicate=True), Output("mg-tab", "disabled"), Output("blocks-id", "data", allow_duplicate=True), Output("blocks-container", "children", allow_duplicate=True), @@ -166,51 +200,40 @@ def create_initial_block(block_id: str) -> dmc.Grid: Output("gm-graph", "figure"), Output("gm-graph", "style"), Output("file-content-mg", "children"), - [Input("file-store", "data")], + [Input("processed-data-store", "data")], ) -def gm_plot(file_path): # noqa: D103 - if file_path is not None: - with open(file_path, "rb") as f: - data = pickle.load(f) - # Process and display the data as needed - _, gcfs, _, _, _, _ = data - n_bgcs = {} - for gcf in gcfs: - n = len(gcf.bgcs) - if n not in n_bgcs: - n_bgcs[n] = [gcf.id] - else: - n_bgcs[n].append(gcf.id) - x_values = list(n_bgcs.keys()) - x_values.sort() - y_values = [len(n_bgcs[x]) for x in x_values] - hover_texts = [f"GCF IDs: {', '.join(n_bgcs[x])}" for x in x_values] - # Adjust bar width based on number of data points - if len(x_values) <= 5: - bar_width = 0.4 - else: - bar_width = None - # Create the bar plot - fig = go.Figure( - data=[ - go.Bar( - x=x_values, - y=y_values, - text=hover_texts, - hoverinfo="text", - textposition="none", - width=bar_width, # Set the bar width - ) - ] - ) - # Update layout - fig.update_layout( - xaxis_title="# BGCs", - yaxis_title="# GCFs", - xaxis=dict(type="category"), - ) - return fig, {"display": "block"}, "uploaded!!" - return {}, {"display": "none"}, "No data available" +def gm_plot(stored_data): # noqa: D103 + if stored_data is None: + return {}, {"display": "none"}, "No data available" + data = json.loads(stored_data) + n_bgcs = data["n_bgcs"] + + x_values = sorted(map(int, n_bgcs.keys())) + y_values = [len(n_bgcs[str(x)]) for x in x_values] + hover_texts = [f"GCF IDs: {', '.join(n_bgcs[str(x)])}" for x in x_values] + + # Adjust bar width based on number of data points + bar_width = 0.4 if len(x_values) <= 5 else None + # Create the bar plot + fig = go.Figure( + data=[ + go.Bar( + x=x_values, + y=y_values, + text=hover_texts, + hoverinfo="text", + textposition="none", + width=bar_width, + ) + ] + ) + # Update layout + fig.update_layout( + xaxis_title="# BGCs", + yaxis_title="# GCFs", + xaxis=dict(type="category"), + ) + return fig, {"display": "block"}, "Data loaded and plotted!!" @app.callback( @@ -342,12 +365,22 @@ def update_placeholder( return {"display": "none"}, {"display": "none"}, "", "", "", [] -df = pd.DataFrame( - { - "GCF ID": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"], - "# BGC": ["3", "1", "10", "6", "3", "1", "10", "6", "3", "1", "10", "6"], - } +@app.callback( + Output("gm-table", "data"), + Output("gm-table", "columns"), + Output("gm-table-card-body", "style"), + Input("processed-data-store", "data"), ) +def update_datatable(processed_data): + if processed_data is None: + return [], [], {"display": "none"} + + data = json.loads(processed_data) + df = pd.DataFrame(data["gcf_data"], columns=["GCF ID", "# BGCs"]) + + columns = [{"name": i, "id": i, "deletable": False, "selectable": False} for i in df.columns] + + return df.to_dict("records"), columns, {"display": "block"} @app.callback( @@ -399,15 +432,15 @@ def select_rows(rows, selected_rows): if not rows: return "No data available.", "No rows selected." - dff = pd.DataFrame(rows) + df = pd.DataFrame(rows) if selected_rows is None: selected_rows = [] - selected_rows_data = dff.iloc[selected_rows] + selected_rows_data = df.iloc[selected_rows] # to be removed later when the scoring part will be implemented - output1 = f"Total rows: {len(dff)}" + output1 = f"Total rows: {len(df)}" output2 = f"Selected rows: {len(selected_rows)}\nSelected GCF IDs: {', '.join(selected_rows_data['GCF ID'].astype(str))}" return output1, output2 diff --git a/app/layouts.py b/app/layouts.py index 1c2ee48..bb500a9 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -2,7 +2,6 @@ import dash_bootstrap_components as dbc import dash_mantine_components as dmc import dash_uploader as du -import pandas as pd from dash import dash_table from dash import dcc from dash import html @@ -67,6 +66,7 @@ ) ), dcc.Store(id="file-store"), # Store to keep the file contents + dcc.Store(id="processed-data-store"), # Store to keep the processed data ], className="p-5 ml-5 mr-5", ) @@ -105,14 +105,6 @@ # gm graph gm_graph = dcc.Graph(id="gm-graph", className="mt-5 mb-3", style={"display": "none"}) # gm_table -## Sample data -df = pd.DataFrame( - { - "GCF ID": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"], - "# BGC": ["3", "1", "10", "6", "3", "1", "10", "6", "3", "1", "10", "6"], - } -) -## Table gm_table = dbc.Card( [ dbc.CardHeader( @@ -136,11 +128,8 @@ ), dash_table.DataTable( id="gm-table", - columns=[ - {"name": i, "id": i, "deletable": False, "selectable": False} - for i in df.columns - ], - data=df.to_dict("records"), + columns=[], # Start with empty columns + data=[], # Start with empty data editable=False, filter_action="native", sort_action="none", From b54d799c335614273c0f380a51a7928ad59e944f Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Thu, 15 Aug 2024 14:04:25 +0200 Subject: [PATCH 06/26] link genomics filter to the datatable --- app/callbacks.py | 82 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 63 insertions(+), 19 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index a9076b6..1b0c84d 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -79,14 +79,6 @@ def upload_data(status: du.UploadStatus) -> tuple[str, str | None]: Output("processed-data-store", "data"), Input("file-store", "data"), prevent_initial_call=True ) def process_uploaded_data(file_path): - """Process the uploaded pickle file and store the processed data. - - Args: - file_path: Path to the uploaded pickle file. - - Returns: - JSON string of processed data or None if processing fails. - """ if file_path is None: return None @@ -94,13 +86,32 @@ def process_uploaded_data(file_path): with open(file_path, "rb") as f: data = pickle.load(f) - # Extract and process only the necessary data - _, gcfs, *_ = data - processed_data = {"n_bgcs": {}, "gcf_data": [(gcf.id, len(gcf.bgcs)) for gcf in gcfs]} - for gcf_id, n_bgcs in processed_data["gcf_data"]: - if n_bgcs not in processed_data["n_bgcs"]: - processed_data["n_bgcs"][n_bgcs] = [] - processed_data["n_bgcs"][n_bgcs].append(gcf_id) + # Extract and process the necessary data + bgcs, gcfs, *_ = data + + def process_bgc_class(bgc_class): + if bgc_class is None: + return ["Unknown"] + return list(bgc_class) # Convert tuple to list + + # Create a dictionary to map BGC to its class + bgc_to_class = {bgc.id: process_bgc_class(bgc.mibig_bgc_class) for bgc in bgcs} + + processed_data = {"n_bgcs": {}, "gcf_data": []} + + for gcf in gcfs: + gcf_bgc_classes = [cls for bgc in gcf.bgcs for cls in bgc_to_class[bgc.id]] + processed_data["gcf_data"].append( + { + "GCF ID": gcf.id, + "# BGCs": len(gcf.bgcs), + "BGC Classes": list(set(gcf_bgc_classes)), # Using set to get unique classes + } + ) + + if len(gcf.bgcs) not in processed_data["n_bgcs"]: + processed_data["n_bgcs"][len(gcf.bgcs)] = [] + processed_data["n_bgcs"][len(gcf.bgcs)].append(gcf.id) return json.dumps(processed_data) except Exception as e: @@ -365,22 +376,55 @@ def update_placeholder( return {"display": "none"}, {"display": "none"}, "", "", "", [] +def apply_filters(df, dropdown_menus, text_inputs, bgc_class_dropdowns): + masks = [] + + for menu, text_input, bgc_classes in zip(dropdown_menus, text_inputs, bgc_class_dropdowns): + if menu == "GCF_ID" and text_input: + gcf_ids = [id.strip() for id in text_input.split(",") if id.strip()] + if gcf_ids: + mask = df["GCF ID"].astype(str).isin(gcf_ids) + masks.append(mask) + elif menu == "BGC_CLASS" and bgc_classes: + mask = df["BGC Classes"].apply( + lambda x: any(bgc_class in x for bgc_class in bgc_classes) + ) + masks.append(mask) + + if masks: + # Combine all masks with OR operation + final_mask = pd.concat(masks, axis=1).any(axis=1) + return df[final_mask] + else: + return df + + @app.callback( Output("gm-table", "data"), Output("gm-table", "columns"), Output("gm-table-card-body", "style"), Input("processed-data-store", "data"), + Input({"type": "gm-dropdown-menu", "index": ALL}, "value"), + Input({"type": "gm-dropdown-ids-text-input", "index": ALL}, "value"), + Input({"type": "gm-dropdown-bgc-class-dropdown", "index": ALL}, "value"), ) -def update_datatable(processed_data): +def update_datatable(processed_data, dropdown_menus, text_inputs, bgc_class_dropdowns): if processed_data is None: return [], [], {"display": "none"} data = json.loads(processed_data) - df = pd.DataFrame(data["gcf_data"], columns=["GCF ID", "# BGCs"]) + df = pd.DataFrame(data["gcf_data"]) + + # Apply filters + filtered_df = apply_filters(df, dropdown_menus, text_inputs, bgc_class_dropdowns) + + display_df = filtered_df[["GCF ID", "# BGCs"]] - columns = [{"name": i, "id": i, "deletable": False, "selectable": False} for i in df.columns] + columns = [ + {"name": i, "id": i, "deletable": False, "selectable": False} for i in display_df.columns + ] - return df.to_dict("records"), columns, {"display": "block"} + return display_df.to_dict("records"), columns, {"display": "block"} @app.callback( From 009e983a9ced9b4d9a731cf22b41bdfdb38fb539 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Thu, 15 Aug 2024 14:16:59 +0200 Subject: [PATCH 07/26] add doc strings and type hint --- app/callbacks.py | 75 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 1b0c84d..7df6eec 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -78,7 +78,15 @@ def upload_data(status: du.UploadStatus) -> tuple[str, str | None]: @app.callback( Output("processed-data-store", "data"), Input("file-store", "data"), prevent_initial_call=True ) -def process_uploaded_data(file_path): +def process_uploaded_data(file_path: str | None) -> str | None: + """Process the uploaded pickle file and store the processed data. + + Args: + file_path: Path to the uploaded pickle file. + + Returns: + JSON string of processed data or None if processing fails. + """ if file_path is None: return None @@ -141,10 +149,7 @@ def disable_tabs_and_reset_blocks( file_name: The name of the uploaded file, or None if no file is uploaded. Returns: - A tuple containing: - - Boolean values for disabling gm-tab, gm-accordion-control, and mg-tab. - - A list with a single block ID. - - A list with a single block component. + Tuple containing boolean values for disabling tabs, styles, and new block data. """ if file_name is None: # Disable the tabs, don't change blocks @@ -164,7 +169,7 @@ def create_initial_block(block_id: str) -> dmc.Grid: block_id: A unique identifier for the block. Returns: - A dictionary representing a dmc.Grid component with nested elements. + A Grid component with nested elements. """ return dmc.Grid( id={"type": "gm-block", "index": block_id}, @@ -213,7 +218,15 @@ def create_initial_block(block_id: str) -> dmc.Grid: Output("file-content-mg", "children"), [Input("processed-data-store", "data")], ) -def gm_plot(stored_data): # noqa: D103 +def gm_plot(stored_data: str | None) -> tuple[dict, dict, str]: + """Create a bar plot based on the processed data. + + Args: + stored_data: JSON string of processed data or None. + + Returns: + Tuple containing the plot figure, style, and a status message. + """ if stored_data is None: return {}, {"display": "none"}, "No data available" data = json.loads(stored_data) @@ -376,7 +389,23 @@ def update_placeholder( return {"display": "none"}, {"display": "none"}, "", "", "", [] -def apply_filters(df, dropdown_menus, text_inputs, bgc_class_dropdowns): +def apply_filters( + df: pd.DataFrame, + dropdown_menus: list[str], + text_inputs: list[str], + bgc_class_dropdowns: list[list[str]], +) -> pd.DataFrame: + """Apply filters to the DataFrame based on user inputs. + + Args: + df: The input DataFrame. + dropdown_menus: List of selected dropdown menu options. + text_inputs: List of text inputs for GCF IDs. + bgc_class_dropdowns: List of selected BGC classes. + + Returns: + Filtered DataFrame. + """ masks = [] for menu, text_input, bgc_classes in zip(dropdown_menus, text_inputs, bgc_class_dropdowns): @@ -408,7 +437,23 @@ def apply_filters(df, dropdown_menus, text_inputs, bgc_class_dropdowns): Input({"type": "gm-dropdown-ids-text-input", "index": ALL}, "value"), Input({"type": "gm-dropdown-bgc-class-dropdown", "index": ALL}, "value"), ) -def update_datatable(processed_data, dropdown_menus, text_inputs, bgc_class_dropdowns): +def update_datatable( + processed_data: str, + dropdown_menus: list[str], + text_inputs: list[str], + bgc_class_dropdowns: list[list[str]], +) -> tuple[list[dict], list[dict], dict]: + """Update the DataTable based on processed data and applied filters. + + Args: + processed_data : JSON string of processed data. + dropdown_menus: List of selected dropdown menu options. + text_inputs: List of text inputs for GCF IDs. + bgc_class_dropdowns: List of selected BGC classes. + + Returns: + Tuple containing table data, column definitions, and style. + """ if processed_data is None: return [], [], {"display": "none"} @@ -471,8 +516,16 @@ def toggle_selection( Input("gm-table", "derived_virtual_data"), Input("gm-table", "derived_virtual_selected_rows"), ) -def select_rows(rows, selected_rows): - """Display the total number of rows and the number of selected rows in the table.""" +def select_rows(rows: list[dict[str, Any]], selected_rows: list[int] | None) -> tuple[str, str]: + """Display the total number of rows and the number of selected rows in the table. + + Args: + rows: List of row data from the DataTable. + selected_rows: Indices of selected rows. + + Returns: + Strings describing total rows and selected rows. + """ if not rows: return "No data available.", "No rows selected." From 8a5f6766028aa54a531be81f515375f5dfc34d67 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Thu, 15 Aug 2024 14:22:08 +0200 Subject: [PATCH 08/26] fix mypy errors --- app/callbacks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 7df6eec..48a6f46 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -97,7 +97,7 @@ def process_uploaded_data(file_path: str | None) -> str | None: # Extract and process the necessary data bgcs, gcfs, *_ = data - def process_bgc_class(bgc_class): + def process_bgc_class(bgc_class: tuple[str, ...] | None) -> list[str]: if bgc_class is None: return ["Unknown"] return list(bgc_class) # Convert tuple to list @@ -105,7 +105,7 @@ def process_bgc_class(bgc_class): # Create a dictionary to map BGC to its class bgc_to_class = {bgc.id: process_bgc_class(bgc.mibig_bgc_class) for bgc in bgcs} - processed_data = {"n_bgcs": {}, "gcf_data": []} + processed_data: dict[str, Any] = {"n_bgcs": {}, "gcf_data": []} for gcf in gcfs: gcf_bgc_classes = [cls for bgc in gcf.bgcs for cls in bgc_to_class[bgc.id]] From 816389a06d310eeff00f820be822229874953826 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Thu, 15 Aug 2024 14:22:51 +0200 Subject: [PATCH 09/26] fix type hint --- app/callbacks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/callbacks.py b/app/callbacks.py index 48a6f46..e322907 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -218,7 +218,7 @@ def create_initial_block(block_id: str) -> dmc.Grid: Output("file-content-mg", "children"), [Input("processed-data-store", "data")], ) -def gm_plot(stored_data: str | None) -> tuple[dict, dict, str]: +def gm_plot(stored_data: str | None) -> tuple[dict | go.Figure, dict, str]: """Create a bar plot based on the processed data. Args: From c6b699ac2d019ed7ce46884e289f07390dabf0f1 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Thu, 15 Aug 2024 15:17:34 +0200 Subject: [PATCH 10/26] fix tests --- app/callbacks.py | 9 ++++---- tests/test_callbacks.py | 46 ++++++++++++++++++++++++++--------------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index e322907..5a54515 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -3,6 +3,7 @@ import pickle import tempfile import uuid +from pathlib import Path from typing import Any import dash import dash_bootstrap_components as dbc @@ -78,7 +79,7 @@ def upload_data(status: du.UploadStatus) -> tuple[str, str | None]: @app.callback( Output("processed-data-store", "data"), Input("file-store", "data"), prevent_initial_call=True ) -def process_uploaded_data(file_path: str | None) -> str | None: +def process_uploaded_data(file_path: Path | str | None) -> str | None: """Process the uploaded pickle file and store the processed data. Args: @@ -141,17 +142,17 @@ def process_bgc_class(bgc_class: tuple[str, ...] | None) -> list[str]: prevent_initial_call=True, ) def disable_tabs_and_reset_blocks( - file_name: str | None, + file_path: Path | str | None, ) -> tuple[bool, bool, dict, dict[str, str], bool, list[str], list[dmc.Grid]]: """Manage tab states and reset blocks based on file upload status. Args: - file_name: The name of the uploaded file, or None if no file is uploaded. + file_path: The name of the uploaded file, or None if no file is uploaded. Returns: Tuple containing boolean values for disabling tabs, styles, and new block data. """ - if file_name is None: + if file_path is None: # Disable the tabs, don't change blocks return True, True, {}, {"display": "block"}, True, [], [] diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 6ed9010..3ce9859 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -1,5 +1,6 @@ import uuid import dash +import dash_mantine_components as dmc import pytest from dash_uploader import UploadStatus from app.callbacks import add_block @@ -23,30 +24,41 @@ def test_upload_data(): assert path_string == str(MOCK_FILE_PATH) -def test_disable_tabs(): +@pytest.fixture +def mock_uuid(monkeypatch): + def mock_uuid4(): + return "test-uuid" + + monkeypatch.setattr(uuid, "uuid4", mock_uuid4) + + +def test_disable_tabs(mock_uuid): # Test with None as input result = disable_tabs_and_reset_blocks(None) - assert result[0] is True # GM tab should be disabled - assert result[1] is True # GM accordion should be disabled - assert result[2] is True # MG tab should be disabled - assert result[3] == [] # No blocks should be displayed - assert result[4] == [] # No blocks should be displayed + assert result == (True, True, {}, {"display": "block"}, True, [], []) # Test with a string as input result = disable_tabs_and_reset_blocks(MOCK_FILE_PATH) - assert result[0] is False # GM tab should be enabled - assert result[1] is False # GM accordion should be enabled - assert result[2] is False # MG tab should be enabled - assert len(result[3]) == 1 # One block should be displayed - assert len(result[4]) == 1 # One block should be displayed + # Unpack the result for easier assertion + ( + gm_tab_disabled, + gm_accordion_disabled, + table_header_style, + table_body_style, + mg_tab_disabled, + block_ids, + blocks, + ) = result -@pytest.fixture -def mock_uuid(monkeypatch): - def mock_uuid4(): - return "test-uuid" - - monkeypatch.setattr(uuid, "uuid4", mock_uuid4) + assert gm_tab_disabled is False + assert gm_accordion_disabled is False + assert table_header_style == {} + assert table_body_style == {"display": "block"} + assert mg_tab_disabled is False + assert block_ids == ["test-uuid"] + assert len(blocks) == 1 + assert isinstance(blocks[0], dmc.Grid) @pytest.mark.parametrize( From ccb980b6a866919ba2b37023eb82d7db9f9b8ec1 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Thu, 15 Aug 2024 15:22:40 +0200 Subject: [PATCH 11/26] add test for process_uploaded_data --- tests/test_callbacks.py | 62 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 3ce9859..6f4f8ae 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -1,10 +1,13 @@ +import json import uuid +from pathlib import Path import dash import dash_mantine_components as dmc import pytest from dash_uploader import UploadStatus from app.callbacks import add_block from app.callbacks import disable_tabs_and_reset_blocks +from app.callbacks import process_uploaded_data from app.callbacks import upload_data from . import DATA_DIR @@ -61,6 +64,65 @@ def test_disable_tabs(mock_uuid): assert isinstance(blocks[0], dmc.Grid) +@pytest.mark.parametrize("input_path", [None, Path("non_existent_file.pkl")]) +def test_process_uploaded_data_invalid_input(input_path): + result = process_uploaded_data(input_path) + assert result is None + + +def test_process_uploaded_data_success(): + result = process_uploaded_data(MOCK_FILE_PATH) + + assert result is not None + processed_data = json.loads(result) + + assert "n_bgcs" in processed_data + assert "gcf_data" in processed_data + + # Add more specific assertions based on the expected content of your mock_obj_data.pkl + # For example: + assert len(processed_data["gcf_data"]) > 0 + + first_gcf = processed_data["gcf_data"][0] + assert "GCF ID" in first_gcf + assert "# BGCs" in first_gcf + assert "BGC Classes" in first_gcf + + # Check if n_bgcs contains at least one key-value pair + assert len(processed_data["n_bgcs"]) > 0 + + # You can add more detailed assertions here based on what you know about the content of mock_obj_data.pkl + + +def test_process_uploaded_data_structure(): + result = process_uploaded_data(MOCK_FILE_PATH) + + assert result is not None + processed_data = json.loads(result) + + # Check overall structure + assert isinstance(processed_data, dict) + assert "n_bgcs" in processed_data + assert "gcf_data" in processed_data + + # Check n_bgcs structure + assert isinstance(processed_data["n_bgcs"], dict) + for key, value in processed_data["n_bgcs"].items(): + assert isinstance(key, str) # Keys should be strings (JSON converts int to str) + assert isinstance(value, list) + + # Check gcf_data structure + assert isinstance(processed_data["gcf_data"], list) + for gcf in processed_data["gcf_data"]: + assert isinstance(gcf, dict) + assert "GCF ID" in gcf + assert "# BGCs" in gcf + assert "BGC Classes" in gcf + assert isinstance(gcf["GCF ID"], str) + assert isinstance(gcf["# BGCs"], int) + assert isinstance(gcf["BGC Classes"], list) + + @pytest.mark.parametrize( "n_clicks, initial_blocks, expected_result", [ From ca4423364c416c796e1bd52f3abafb1a07d3eb8a Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Thu, 15 Aug 2024 15:44:46 +0200 Subject: [PATCH 12/26] improve style data conditional and remove filter --- app/layouts.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/layouts.py b/app/layouts.py index bb500a9..10e26b2 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -131,10 +131,10 @@ columns=[], # Start with empty columns data=[], # Start with empty data editable=False, - filter_action="native", + filter_action="none", sort_action="none", sort_mode="multi", - column_selectable="single", + column_selectable=False, row_deletable=False, row_selectable="multi", selected_columns=[], @@ -148,6 +148,17 @@ "fontWeight": "bold", "color": "white", }, + style_data={ + "border": "1px solid #ddd" # Ensure data cells have borders + }, + style_data_conditional=[ + { + "if": {"state": "selected"}, + "backgroundColor": "white", # Light gray background for selected rows + "border": "1px solid #ddd", # Preserve borders for selected rows + } + ], + style_cell_conditional=[{"if": {"column_id": "selector"}, "width": "30px"}], ), ], id="gm-table-card-body", From 1840fe93b1714d72de52bf52289aa5f476b499f0 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Thu, 15 Aug 2024 15:53:12 +0200 Subject: [PATCH 13/26] add tests for the link btw filter and datatable --- app/callbacks.py | 4 +- tests/test_callbacks.py | 151 ++++++++++++++++++++++++++++++---------- 2 files changed, 117 insertions(+), 38 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 5a54515..e32a94f 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -439,7 +439,7 @@ def apply_filters( Input({"type": "gm-dropdown-bgc-class-dropdown", "index": ALL}, "value"), ) def update_datatable( - processed_data: str, + processed_data: str | None, dropdown_menus: list[str], text_inputs: list[str], bgc_class_dropdowns: list[list[str]], @@ -483,7 +483,7 @@ def update_datatable( ], ) def toggle_selection( - n_clicks: int, original_rows: list, filtered_rows: list, selected_rows: list + n_clicks: int, original_rows: list, filtered_rows: list | None, selected_rows: list ) -> list: """Toggle between selecting all rows and deselecting all rows in a Dash DataTable. diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 6f4f8ae..53dd9e5 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -3,11 +3,16 @@ from pathlib import Path import dash import dash_mantine_components as dmc +import pandas as pd import pytest from dash_uploader import UploadStatus from app.callbacks import add_block +from app.callbacks import apply_filters from app.callbacks import disable_tabs_and_reset_blocks from app.callbacks import process_uploaded_data +from app.callbacks import select_rows +from app.callbacks import toggle_selection +from app.callbacks import update_datatable from app.callbacks import upload_data from . import DATA_DIR @@ -15,18 +20,6 @@ MOCK_FILE_PATH = DATA_DIR / "mock_obj_data.pkl" -def test_upload_data(): - # Create an UploadStatus object - status = UploadStatus( - uploaded_files=[MOCK_FILE_PATH], n_total=1, uploaded_size_mb=5.39, total_size_mb=5.39 - ) - upload_string, path_string = upload_data(status) - - # Check the result - assert upload_string == f"Successfully uploaded: {MOCK_FILE_PATH.name} [5.39 MB]" - assert path_string == str(MOCK_FILE_PATH) - - @pytest.fixture def mock_uuid(monkeypatch): def mock_uuid4(): @@ -35,33 +28,29 @@ def mock_uuid4(): monkeypatch.setattr(uuid, "uuid4", mock_uuid4) -def test_disable_tabs(mock_uuid): - # Test with None as input - result = disable_tabs_and_reset_blocks(None) - assert result == (True, True, {}, {"display": "block"}, True, [], []) +@pytest.fixture +def processed_data(): + # Use the actual process_uploaded_data function to get the processed data + return process_uploaded_data(MOCK_FILE_PATH) - # Test with a string as input - result = disable_tabs_and_reset_blocks(MOCK_FILE_PATH) - # Unpack the result for easier assertion - ( - gm_tab_disabled, - gm_accordion_disabled, - table_header_style, - table_body_style, - mg_tab_disabled, - block_ids, - blocks, - ) = result +@pytest.fixture +def sample_df(processed_data): + # Convert the processed data back to a DataFrame + data = json.loads(processed_data) + return pd.DataFrame(data["gcf_data"]) - assert gm_tab_disabled is False - assert gm_accordion_disabled is False - assert table_header_style == {} - assert table_body_style == {"display": "block"} - assert mg_tab_disabled is False - assert block_ids == ["test-uuid"] - assert len(blocks) == 1 - assert isinstance(blocks[0], dmc.Grid) + +def test_upload_data(): + # Create an UploadStatus object + status = UploadStatus( + uploaded_files=[MOCK_FILE_PATH], n_total=1, uploaded_size_mb=5.39, total_size_mb=5.39 + ) + upload_string, path_string = upload_data(status) + + # Check the result + assert upload_string == f"Successfully uploaded: {MOCK_FILE_PATH.name} [5.39 MB]" + assert path_string == str(MOCK_FILE_PATH) @pytest.mark.parametrize("input_path", [None, Path("non_existent_file.pkl")]) @@ -123,6 +112,35 @@ def test_process_uploaded_data_structure(): assert isinstance(gcf["BGC Classes"], list) +def test_disable_tabs(mock_uuid): + # Test with None as input + result = disable_tabs_and_reset_blocks(None) + assert result == (True, True, {}, {"display": "block"}, True, [], []) + + # Test with a string as input + result = disable_tabs_and_reset_blocks(MOCK_FILE_PATH) + + # Unpack the result for easier assertion + ( + gm_tab_disabled, + gm_accordion_disabled, + table_header_style, + table_body_style, + mg_tab_disabled, + block_ids, + blocks, + ) = result + + assert gm_tab_disabled is False + assert gm_accordion_disabled is False + assert table_header_style == {} + assert table_body_style == {"display": "block"} + assert mg_tab_disabled is False + assert block_ids == ["test-uuid"] + assert len(blocks) == 1 + assert isinstance(blocks[0], dmc.Grid) + + @pytest.mark.parametrize( "n_clicks, initial_blocks, expected_result", [ @@ -142,3 +160,64 @@ def test_add_block(mock_uuid, n_clicks, initial_blocks, expected_result): else: with expected_result: add_block(n_clicks, initial_blocks) + + +def test_apply_filters(sample_df): + # Test GCF_ID filter + gcf_ids = sample_df["GCF ID"].iloc[:2].tolist() + filtered_df = apply_filters(sample_df, ["GCF_ID"], [",".join(gcf_ids)], [[]]) + assert len(filtered_df) == 2 + assert set(filtered_df["GCF ID"]) == set(gcf_ids) + + # Test BGC_CLASS filter + bgc_class = sample_df["BGC Classes"].iloc[0][0] # Get the first BGC class from the first row + filtered_df = apply_filters(sample_df, ["BGC_CLASS"], [""], [[bgc_class]]) + assert len(filtered_df) > 0 + assert all(bgc_class in classes for classes in filtered_df["BGC Classes"]) + + # Test no filter + filtered_df = apply_filters(sample_df, [], [], []) + assert len(filtered_df) == len(sample_df) + + +def test_update_datatable(processed_data): + result = update_datatable(processed_data, [], [], []) + assert len(result) == 3 + assert isinstance(result[0], list) # data + assert isinstance(result[1], list) # columns + assert result[2] == {"display": "block"} # style + + # Test with None input + result = update_datatable(None, [], [], []) + assert result == ([], [], {"display": "none"}) + + +def test_toggle_selection(sample_df): + original_rows = sample_df.to_dict("records") + filtered_rows = original_rows[:2] + + # Test selecting all rows + result = toggle_selection(1, original_rows, filtered_rows, []) + assert result == [[0, 1]] + + # Test deselecting all rows + result = toggle_selection(1, original_rows, filtered_rows, [0, 1]) + assert result == [[]] + + # Test with None filtered_rows + with pytest.raises(dash.exceptions.PreventUpdate): + toggle_selection(1, original_rows, None, []) + + +def test_select_rows(sample_df): + rows = sample_df.to_dict("records") + selected_rows = [0, 2] + + output1, output2 = select_rows(rows, selected_rows) + assert output1 == f"Total rows: {len(rows)}" + assert output2.startswith(f"Selected rows: {len(selected_rows)}\nSelected GCF IDs: ") + + # Test with no rows + output1, output2 = select_rows([], None) + assert output1 == "No data available." + assert output2 == "No rows selected." From 758da5e0086d771e203bc54dd5413ff975c23122 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Wed, 28 Aug 2024 09:50:33 +0200 Subject: [PATCH 14/26] transform button to checkbox --- app/callbacks.py | 6 +++--- app/layouts.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index e32a94f..0997856 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -475,7 +475,7 @@ def update_datatable( @app.callback( [Output("gm-table", "selected_rows")], - [Input("gm-rows-selection-button", "n_clicks")], + [Input("gm-rows-selection-checkbox", "value")], [ State("gm-table", "data"), State("gm-table", "derived_virtual_data"), @@ -483,12 +483,12 @@ def update_datatable( ], ) def toggle_selection( - n_clicks: int, original_rows: list, filtered_rows: list | None, selected_rows: list + value: int, original_rows: list, filtered_rows: list | None, selected_rows: list ) -> list: """Toggle between selecting all rows and deselecting all rows in a Dash DataTable. Args: - n_clicks: Number of button clicks (unused). + value: Checkbox selected. original_rows: All rows in the table. filtered_rows: Rows visible after filtering. selected_rows: Currently selected row indices. diff --git a/app/layouts.py b/app/layouts.py index 10e26b2..4bce176 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -118,9 +118,9 @@ [ dbc.Row( dbc.Col( - dbc.Button( - "Select/deselect all", - id="gm-rows-selection-button", + dcc.Checklist( + options={"disabled": ""}, + id="gm-rows-selection-checkbox", className="mb-3", ), className="text-center", From f2e58c3bc5546bb45937a11b748af4e06e38a0d5 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Wed, 28 Aug 2024 10:17:47 +0200 Subject: [PATCH 15/26] move and center select all checkbox --- app/callbacks.py | 2 +- app/layouts.py | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 0997856..37a58cf 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -475,7 +475,7 @@ def update_datatable( @app.callback( [Output("gm-table", "selected_rows")], - [Input("gm-rows-selection-checkbox", "value")], + [Input("select-all-checkbox", "value")], [ State("gm-table", "data"), State("gm-table", "derived_virtual_data"), diff --git a/app/layouts.py b/app/layouts.py index 4bce176..f1291de 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -116,15 +116,18 @@ ), dbc.CardBody( [ - dbc.Row( - dbc.Col( - dcc.Checklist( - options={"disabled": ""}, - id="gm-rows-selection-checkbox", - className="mb-3", - ), - className="text-center", - ) + html.Div( + dcc.Checklist( + options={"disabled": ""}, + id="select-all-checkbox", + style={ + "position": "absolute", + "top": "4px", + "left": "10px", + "zIndex": "1000", + }, + ), + style={"position": "relative", "height": "0px"}, ), dash_table.DataTable( id="gm-table", From e321fbda59294df727c8c4acfe4a45173288b9ac Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Wed, 28 Aug 2024 13:25:59 +0200 Subject: [PATCH 16/26] add OR label --- app/callbacks.py | 52 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 37a58cf..3a97078 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -181,7 +181,7 @@ def create_initial_block(block_id: str) -> dmc.Grid: id={"type": "gm-add-button", "index": block_id}, className="btn-primary", ), - span=1, + span=2, ), dmc.GridCol( dcc.Dropdown( @@ -190,7 +190,7 @@ def create_initial_block(block_id: str) -> dmc.Grid: id={"type": "gm-dropdown-menu", "index": block_id}, clearable=False, ), - span=6, + span=4, ), dmc.GridCol( [ @@ -206,7 +206,7 @@ def create_initial_block(block_id: str) -> dmc.Grid: style={"display": "none"}, ), ], - span=5, + span=6, ), ], gutter="md", @@ -306,12 +306,31 @@ def display_blocks(blocks_id: list[str], existing_blocks: list[dmc.Grid]) -> lis id={"type": "gm-block", "index": new_block_id}, children=[ dmc.GridCol( - dbc.Button( - [html.I(className="fas fa-plus")], - id={"type": "gm-add-button", "index": new_block_id}, - className="btn-primary", + html.Div( + [ + dbc.Button( + [html.I(className="fas fa-plus")], + id={"type": "gm-add-button", "index": new_block_id}, + className="btn-primary", + ), + html.Label( + "OR", + id={"type": "gm-or-label", "index": new_block_id}, + className="ms-2 px-2 py-1 rounded", + style={ + "color": "green", + "backgroundColor": "#f0f0f0", + "display": "inline-block", + "position": "absolute", + "left": "50px", # Adjust based on button width + "top": "50%", + "transform": "translateY(-50%)", + }, + ), + ], + style={"position": "relative", "height": "38px"}, ), - span=1, + span=2, ), dmc.GridCol( dcc.Dropdown( @@ -320,7 +339,7 @@ def display_blocks(blocks_id: list[str], existing_blocks: list[dmc.Grid]) -> lis id={"type": "gm-dropdown-menu", "index": new_block_id}, clearable=False, ), - span=6, + span=4, ), dmc.GridCol( [ @@ -336,16 +355,21 @@ def display_blocks(blocks_id: list[str], existing_blocks: list[dmc.Grid]) -> lis style={"display": "none"}, ), ], - span=5, + span=6, ), ], gutter="md", ) - # Hide the add button on the previous last block - existing_blocks[-1]["props"]["children"][0]["props"]["children"]["props"]["style"] = { - "display": "none" - } + # Hide the add button and OR label on the previous last block + if len(existing_blocks) == 1: + existing_blocks[-1]["props"]["children"][0]["props"]["children"]["props"]["style"] = { + "display": "none" + } + else: + existing_blocks[-1]["props"]["children"][0]["props"]["children"]["props"]["children"][ + 0 + ]["props"]["style"] = {"display": "none"} return existing_blocks + [new_block] return existing_blocks From fc3307f4c652f19240f7e73fbb4c2113e453c833 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Mon, 9 Sep 2024 14:26:02 +0200 Subject: [PATCH 17/26] remove unused dep --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 49b8ebc..d69e881 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,4 @@ dash-mantine-components dash_bootstrap_templates numpy dash-uploader==0.7.0a1 -packaging==21.3.0 -python-dotenv \ No newline at end of file +packaging==21.3.0 \ No newline at end of file From dd308d55dff4ccefa78037c837c1e63c91c07349 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Mon, 9 Sep 2024 14:53:56 +0200 Subject: [PATCH 18/26] fix bgc class filter --- app/callbacks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/callbacks.py b/app/callbacks.py index 3a97078..69e5a3b 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -441,7 +441,7 @@ def apply_filters( masks.append(mask) elif menu == "BGC_CLASS" and bgc_classes: mask = df["BGC Classes"].apply( - lambda x: any(bgc_class in x for bgc_class in bgc_classes) + lambda x: any(bc.lower() in [y.lower() for y in x] for bc in bgc_classes) ) masks.append(mask) From 56d00039b9b5fdccdb54e31ac076a2bf6a2f7cdc Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Fri, 20 Sep 2024 11:25:43 +0200 Subject: [PATCH 19/26] add button for applying filters --- app/layouts.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/layouts.py b/app/layouts.py index f1291de..4098cb7 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -95,7 +95,20 @@ id="gm-accordion-control", className="mt-5 mb-3", ), - dmc.AccordionPanel(gm_input_group), + dmc.AccordionPanel( + [ + gm_input_group, + html.Div( + dbc.Button( + "Apply Filters", + id="apply-filters-button", + color="primary", + className="mt-3", + ), + className="d-flex justify-content-center", + ), + ] + ), ], value="gm-accordion", ), @@ -118,7 +131,7 @@ [ html.Div( dcc.Checklist( - options={"disabled": ""}, + options=[{"label": "", "value": "disabled"}], id="select-all-checkbox", style={ "position": "absolute", From 4417dc229ecad1ecff2cd9b0bec79ec74bd86854 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Fri, 20 Sep 2024 11:58:19 +0200 Subject: [PATCH 20/26] add the logic to handle the select filter button and improve toggle_selection --- app/callbacks.py | 91 ++++++++++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 69e5a3b..8435a98 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -457,36 +457,55 @@ def apply_filters( Output("gm-table", "data"), Output("gm-table", "columns"), Output("gm-table-card-body", "style"), + Output("gm-table", "selected_rows", allow_duplicate=True), + Output("select-all-checkbox", "value"), Input("processed-data-store", "data"), - Input({"type": "gm-dropdown-menu", "index": ALL}, "value"), - Input({"type": "gm-dropdown-ids-text-input", "index": ALL}, "value"), - Input({"type": "gm-dropdown-bgc-class-dropdown", "index": ALL}, "value"), + Input("apply-filters-button", "n_clicks"), + State({"type": "gm-dropdown-menu", "index": ALL}, "value"), + State({"type": "gm-dropdown-ids-text-input", "index": ALL}, "value"), + State({"type": "gm-dropdown-bgc-class-dropdown", "index": ALL}, "value"), + State("select-all-checkbox", "value"), + prevent_initial_call=True, ) def update_datatable( processed_data: str | None, + n_clicks: int | None, dropdown_menus: list[str], text_inputs: list[str], bgc_class_dropdowns: list[list[str]], -) -> tuple[list[dict], list[dict], dict]: - """Update the DataTable based on processed data and applied filters. + checkbox_value: list | None, +) -> tuple[list[dict], list[dict], dict, list, list]: + """Update the DataTable based on processed data and applied filters when the button is clicked. Args: - processed_data : JSON string of processed data. + processed_data: JSON string of processed data. + n_clicks: Number of times the Apply Filters button has been clicked. dropdown_menus: List of selected dropdown menu options. text_inputs: List of text inputs for GCF IDs. bgc_class_dropdowns: List of selected BGC classes. + checkbox_value: Current value of the select-all checkbox. Returns: - Tuple containing table data, column definitions, and style. + Tuple containing table data, column definitions, style, empty selected rows, and updated checkbox value. """ if processed_data is None: - return [], [], {"display": "none"} - - data = json.loads(processed_data) - df = pd.DataFrame(data["gcf_data"]) + return [], [], {"display": "none"}, [], [] - # Apply filters - filtered_df = apply_filters(df, dropdown_menus, text_inputs, bgc_class_dropdowns) + try: + data = json.loads(processed_data) + df = pd.DataFrame(data["gcf_data"]) + except (json.JSONDecodeError, KeyError, pd.errors.EmptyDataError): + return [], [], {"display": "none"}, [], [] + + if ctx.triggered_id == "apply-filters-button": + # Apply filters only when the button is clicked + filtered_df = apply_filters(df, dropdown_menus, text_inputs, bgc_class_dropdowns) + # Reset the checkbox when filters are applied + new_checkbox_value = [] + else: + # On initial load or when processed data changes, show all data + filtered_df = df + new_checkbox_value = checkbox_value if checkbox_value is not None else [] display_df = filtered_df[["GCF ID", "# BGCs"]] @@ -494,45 +513,41 @@ def update_datatable( {"name": i, "id": i, "deletable": False, "selectable": False} for i in display_df.columns ] - return display_df.to_dict("records"), columns, {"display": "block"} + return display_df.to_dict("records"), columns, {"display": "block"}, [], new_checkbox_value @app.callback( - [Output("gm-table", "selected_rows")], - [Input("select-all-checkbox", "value")], - [ - State("gm-table", "data"), - State("gm-table", "derived_virtual_data"), - State("gm-table", "derived_virtual_selected_rows"), - ], + Output("gm-table", "selected_rows", allow_duplicate=True), + Input("select-all-checkbox", "value"), + State("gm-table", "data"), + State("gm-table", "derived_virtual_data"), + prevent_initial_call=True, ) def toggle_selection( - value: int, original_rows: list, filtered_rows: list | None, selected_rows: list + value: list | None, + original_rows: list, + filtered_rows: list | None, ) -> list: - """Toggle between selecting all rows and deselecting all rows in a Dash DataTable. + """Toggle between selecting all rows and deselecting all rows in the current view of a Dash DataTable. Args: - value: Checkbox selected. + value: Value of the select-all checkbox. original_rows: All rows in the table. - filtered_rows: Rows visible after filtering. - selected_rows: Currently selected row indices. + filtered_rows: Rows visible after filtering, or None if no filter is applied. Returns: - Indices of selected rows after toggling. - - Raises: - PreventUpdate: If filtered_rows is None. + List of indices of selected rows after toggling. """ - if filtered_rows is None: - raise dash.exceptions.PreventUpdate + is_checked = value and "disabled" in value - if not selected_rows or len(selected_rows) < len(filtered_rows): - # If no rows are selected or not all rows are selected, select all filtered rows - selected_ids = [row for row in filtered_rows] - return [[i for i, row in enumerate(original_rows) if row in selected_ids]] + if filtered_rows is None: + # No filtering applied, toggle all rows + return list(range(len(original_rows))) if is_checked else [] else: - # If all rows are selected, deselect all - return [[]] + # Filtering applied, toggle only visible rows + return ( + [i for i, row in enumerate(original_rows) if row in filtered_rows] if is_checked else [] + ) @app.callback( From 738bc9511df944a3f10795af35d5f4af97502e2e Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Fri, 20 Sep 2024 12:30:56 +0200 Subject: [PATCH 21/26] fix tests and add sample_processed_data instead of sample_df --- tests/test_callbacks.py | 130 +++++++++++++++++++++++++++++----------- 1 file changed, 94 insertions(+), 36 deletions(-) diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 53dd9e5..e614a29 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -1,6 +1,7 @@ import json import uuid from pathlib import Path +from unittest.mock import patch import dash import dash_mantine_components as dmc import pandas as pd @@ -35,10 +36,14 @@ def processed_data(): @pytest.fixture -def sample_df(processed_data): - # Convert the processed data back to a DataFrame - data = json.loads(processed_data) - return pd.DataFrame(data["gcf_data"]) +def sample_processed_data(): + data = { + "gcf_data": [ + {"GCF ID": "GCF_1", "# BGCs": 3, "BGC Classes": ["NRPS", "PKS"]}, + {"GCF ID": "GCF_2", "# BGCs": 2, "BGC Classes": ["RiPP", "Terpene"]}, + ] + } + return json.dumps(data) def test_upload_data(): @@ -162,56 +167,109 @@ def test_add_block(mock_uuid, n_clicks, initial_blocks, expected_result): add_block(n_clicks, initial_blocks) -def test_apply_filters(sample_df): +def test_apply_filters(sample_processed_data): + data = json.loads(sample_processed_data) + df = pd.DataFrame(data["gcf_data"]) + # Test GCF_ID filter - gcf_ids = sample_df["GCF ID"].iloc[:2].tolist() - filtered_df = apply_filters(sample_df, ["GCF_ID"], [",".join(gcf_ids)], [[]]) + gcf_ids = df["GCF ID"].iloc[:2].tolist() + filtered_df = apply_filters(df, ["GCF_ID"], [",".join(gcf_ids)], [[]]) assert len(filtered_df) == 2 assert set(filtered_df["GCF ID"]) == set(gcf_ids) # Test BGC_CLASS filter - bgc_class = sample_df["BGC Classes"].iloc[0][0] # Get the first BGC class from the first row - filtered_df = apply_filters(sample_df, ["BGC_CLASS"], [""], [[bgc_class]]) + bgc_class = df["BGC Classes"].iloc[0][0] # Get the first BGC class from the first row + filtered_df = apply_filters(df, ["BGC_CLASS"], [""], [[bgc_class]]) assert len(filtered_df) > 0 assert all(bgc_class in classes for classes in filtered_df["BGC Classes"]) # Test no filter - filtered_df = apply_filters(sample_df, [], [], []) - assert len(filtered_df) == len(sample_df) - - -def test_update_datatable(processed_data): - result = update_datatable(processed_data, [], [], []) - assert len(result) == 3 - assert isinstance(result[0], list) # data - assert isinstance(result[1], list) # columns - assert result[2] == {"display": "block"} # style - - # Test with None input - result = update_datatable(None, [], [], []) - assert result == ([], [], {"display": "none"}) - - -def test_toggle_selection(sample_df): - original_rows = sample_df.to_dict("records") + filtered_df = apply_filters(df, [], [], []) + assert len(filtered_df) == len(df) + + +def test_update_datatable(sample_processed_data): + with patch("app.callbacks.ctx") as mock_ctx: + # Test with processed data and no filters applied + mock_ctx.triggered_id = None + result = update_datatable( + sample_processed_data, + None, # n_clicks + [], # dropdown_menus + [], # text_inputs + [], # bgc_class_dropdowns + None, # checkbox_value + ) + + assert len(result) == 5 + data, columns, style, selected_rows, checkbox_value = result + + # Check data + assert len(data) == 2 + assert data[0]["GCF ID"] == "GCF_1" + assert data[1]["GCF ID"] == "GCF_2" + + # Check columns + assert len(columns) == 2 + assert columns[0]["name"] == "GCF ID" + assert columns[1]["name"] == "# BGCs" + + # Check style + assert style == {"display": "block"} + + # Check selected_rows + assert selected_rows == [] + + # Check checkbox_value + assert checkbox_value == [] + + # Test with None input + result = update_datatable(None, None, [], [], [], None) + assert result == ([], [], {"display": "none"}, [], []) + + # Test with apply-filters-button triggered + mock_ctx.triggered_id = "apply-filters-button" + result = update_datatable( + sample_processed_data, + 1, # n_clicks + ["GCF_ID"], # dropdown_menus + ["GCF_1"], # text_inputs + [[]], # bgc_class_dropdowns + ["disabled"], # checkbox_value + ) + + data, columns, style, selected_rows, checkbox_value = result + assert len(data) == 1 + assert data[0]["GCF ID"] == "GCF_1" + assert checkbox_value == [] + + +def test_toggle_selection(sample_processed_data): + data = json.loads(sample_processed_data) + original_rows = data["gcf_data"] filtered_rows = original_rows[:2] # Test selecting all rows - result = toggle_selection(1, original_rows, filtered_rows, []) - assert result == [[0, 1]] + result = toggle_selection(["disabled"], original_rows, filtered_rows) + assert result == [0, 1] # Assuming it now returns a list of indices directly # Test deselecting all rows - result = toggle_selection(1, original_rows, filtered_rows, [0, 1]) - assert result == [[]] + result = toggle_selection([], original_rows, filtered_rows) + assert result == [] # Test with None filtered_rows - with pytest.raises(dash.exceptions.PreventUpdate): - toggle_selection(1, original_rows, None, []) + result = toggle_selection(["disabled"], original_rows, None) + assert result == list(range(len(original_rows))) # Should select all rows in original_rows + + # Test with empty value (deselecting when no filter is applied) + result = toggle_selection([], original_rows, None) + assert result == [] -def test_select_rows(sample_df): - rows = sample_df.to_dict("records") - selected_rows = [0, 2] +def test_select_rows(sample_processed_data): + data = json.loads(sample_processed_data) + rows = data["gcf_data"] + selected_rows = [0, 1] output1, output2 = select_rows(rows, selected_rows) assert output1 == f"Total rows: {len(rows)}" From 003ecf8f330664f28c0bd043f5ad622a78c08e17 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Mon, 23 Sep 2024 16:10:13 +0200 Subject: [PATCH 22/26] add tooltip functionality --- app/callbacks.py | 32 +++++++++++++++++++++++++------- app/layouts.py | 17 ++++++++++++----- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 8435a98..10e3606 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -110,11 +110,15 @@ def process_bgc_class(bgc_class: tuple[str, ...] | None) -> list[str]: for gcf in gcfs: gcf_bgc_classes = [cls for bgc in gcf.bgcs for cls in bgc_to_class[bgc.id]] + bgc_ids = [bgc.id for bgc in gcf.bgcs] + strains = [str(gcf.strains)] processed_data["gcf_data"].append( { "GCF ID": gcf.id, "# BGCs": len(gcf.bgcs), "BGC Classes": list(set(gcf_bgc_classes)), # Using set to get unique classes + "BGC IDs": bgc_ids, + "strains": strains, } ) @@ -456,6 +460,7 @@ def apply_filters( @app.callback( Output("gm-table", "data"), Output("gm-table", "columns"), + Output("gm-table", "tooltip_data"), Output("gm-table-card-body", "style"), Output("gm-table", "selected_rows", allow_duplicate=True), Output("select-all-checkbox", "value"), @@ -474,7 +479,7 @@ def update_datatable( text_inputs: list[str], bgc_class_dropdowns: list[list[str]], checkbox_value: list | None, -) -> tuple[list[dict], list[dict], dict, list, list]: +) -> tuple[list[dict], list[dict], list[dict], dict, list, list]: """Update the DataTable based on processed data and applied filters when the button is clicked. Args: @@ -486,16 +491,16 @@ def update_datatable( checkbox_value: Current value of the select-all checkbox. Returns: - Tuple containing table data, column definitions, style, empty selected rows, and updated checkbox value. + Tuple containing table data, column definitions, tooltips data, style, empty selected rows, and updated checkbox value. """ if processed_data is None: - return [], [], {"display": "none"}, [], [] + return [], [], [], {"display": "none"}, [], [] try: data = json.loads(processed_data) df = pd.DataFrame(data["gcf_data"]) except (json.JSONDecodeError, KeyError, pd.errors.EmptyDataError): - return [], [], {"display": "none"}, [], [] + return [], [], [], {"display": "none"}, [], [] if ctx.triggered_id == "apply-filters-button": # Apply filters only when the button is clicked @@ -507,13 +512,26 @@ def update_datatable( filtered_df = df new_checkbox_value = checkbox_value if checkbox_value is not None else [] - display_df = filtered_df[["GCF ID", "# BGCs"]] + # Prepare the data for display + display_df = filtered_df[["GCF ID", "# BGCs", "BGC IDs", "strains"]] + display_data = display_df[["GCF ID", "# BGCs"]].to_dict("records") + + # Prepare tooltip data + tooltip_data = [] + for _, row in display_df.iterrows(): + tooltip_data.append( + { + "# BGCs": {"value": f"BGC IDs: {', '.join(row['BGC IDs'])}"}, + "GCF ID": {"value": f"strains: {', '.join(row['strains'])}"}, + }, + ) columns = [ - {"name": i, "id": i, "deletable": False, "selectable": False} for i in display_df.columns + {"name": "GCF ID", "id": "GCF ID"}, + {"name": "# BGCs", "id": "# BGCs", "type": "numeric"}, ] - return display_df.to_dict("records"), columns, {"display": "block"}, [], new_checkbox_value + return display_data, columns, tooltip_data, {"display": "block"}, [], new_checkbox_value @app.callback( diff --git a/app/layouts.py b/app/layouts.py index 4098cb7..e03ec57 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -146,6 +146,7 @@ id="gm-table", columns=[], # Start with empty columns data=[], # Start with empty data + tooltip_data=[], # Start with empty tooltip data editable=False, filter_action="none", sort_action="none", @@ -164,17 +165,23 @@ "fontWeight": "bold", "color": "white", }, - style_data={ - "border": "1px solid #ddd" # Ensure data cells have borders - }, + style_data={"border": "1px solid #ddd"}, style_data_conditional=[ { "if": {"state": "selected"}, - "backgroundColor": "white", # Light gray background for selected rows - "border": "1px solid #ddd", # Preserve borders for selected rows + "backgroundColor": "white", + "border": "1px solid #ddd", } ], style_cell_conditional=[{"if": {"column_id": "selector"}, "width": "30px"}], + tooltip_delay=500, + tooltip_duration=None, + css=[ + { + "selector": ".dash-table-tooltip", + "rule": "background-color: white; font-family: monospace; max-width: none !important", + } + ], ), ], id="gm-table-card-body", From c07255a23517c98638467e8b179f64e81c8a844d Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Mon, 23 Sep 2024 16:14:36 +0200 Subject: [PATCH 23/26] fix tests --- tests/test_callbacks.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index e614a29..d3c1daa 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -39,8 +39,20 @@ def processed_data(): def sample_processed_data(): data = { "gcf_data": [ - {"GCF ID": "GCF_1", "# BGCs": 3, "BGC Classes": ["NRPS", "PKS"]}, - {"GCF ID": "GCF_2", "# BGCs": 2, "BGC Classes": ["RiPP", "Terpene"]}, + { + "GCF ID": "GCF_1", + "# BGCs": 3, + "BGC Classes": ["NRPS", "PKS"], + "BGC IDs": ["BGC_1", "BGC_2", "BGC_3"], + "strains": ["Strain_1", "Strain_2", "Strain_3"], + }, + { + "GCF ID": "GCF_2", + "# BGCs": 2, + "BGC Classes": ["RiPP", "Terpene"], + "BGC IDs": ["BGC_1", "BGC_3"], + "strains": ["Strain_3"], + }, ] } return json.dumps(data) @@ -201,8 +213,8 @@ def test_update_datatable(sample_processed_data): None, # checkbox_value ) - assert len(result) == 5 - data, columns, style, selected_rows, checkbox_value = result + assert len(result) == 6 + data, columns, tooltip_data, style, selected_rows, checkbox_value = result # Check data assert len(data) == 2 @@ -225,7 +237,7 @@ def test_update_datatable(sample_processed_data): # Test with None input result = update_datatable(None, None, [], [], [], None) - assert result == ([], [], {"display": "none"}, [], []) + assert result == ([], [], [], {"display": "none"}, [], []) # Test with apply-filters-button triggered mock_ctx.triggered_id = "apply-filters-button" @@ -238,7 +250,7 @@ def test_update_datatable(sample_processed_data): ["disabled"], # checkbox_value ) - data, columns, style, selected_rows, checkbox_value = result + data, columns, tooltip_data, style, selected_rows, checkbox_value = result assert len(data) == 1 assert data[0]["GCF ID"] == "GCF_1" assert checkbox_value == [] From 70ccd5efad2c92def29e98100c5f5fe6a023d415 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Tue, 24 Sep 2024 16:13:12 +0200 Subject: [PATCH 24/26] make tooltips tables md formatted --- app/callbacks.py | 20 ++++++++++++++++---- app/layouts.py | 13 +++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 10e3606..91899f1 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -111,6 +111,10 @@ def process_bgc_class(bgc_class: tuple[str, ...] | None) -> list[str]: for gcf in gcfs: gcf_bgc_classes = [cls for bgc in gcf.bgcs for cls in bgc_to_class[bgc.id]] bgc_ids = [bgc.id for bgc in gcf.bgcs] + bgc_smiles = [ + bgc.smiles[0] if bgc.smiles and bgc.smiles[0] is not None else "N/A" + for bgc in gcf.bgcs + ] strains = [str(gcf.strains)] processed_data["gcf_data"].append( { @@ -118,6 +122,7 @@ def process_bgc_class(bgc_class: tuple[str, ...] | None) -> list[str]: "# BGCs": len(gcf.bgcs), "BGC Classes": list(set(gcf_bgc_classes)), # Using set to get unique classes "BGC IDs": bgc_ids, + "BGC smiles": bgc_smiles, "strains": strains, } ) @@ -513,17 +518,24 @@ def update_datatable( new_checkbox_value = checkbox_value if checkbox_value is not None else [] # Prepare the data for display - display_df = filtered_df[["GCF ID", "# BGCs", "BGC IDs", "strains"]] + display_df = filtered_df[["GCF ID", "# BGCs", "BGC IDs", "BGC smiles", "strains"]] display_data = display_df[["GCF ID", "# BGCs"]].to_dict("records") # Prepare tooltip data tooltip_data = [] for _, row in display_df.iterrows(): + bgc_ids_smiles_markdown = "| BGC IDs | SMILES |\n|---------|--------|\n" + "\n".join( + [f"| {id} | {smiles} |" for id, smiles in zip(row["BGC IDs"], row["BGC smiles"])] + ) + strains_markdown = "| Strains |\n|----------|\n" + "\n".join( + [f"| {strain} |" for strain in row["strains"]] + ) + tooltip_data.append( { - "# BGCs": {"value": f"BGC IDs: {', '.join(row['BGC IDs'])}"}, - "GCF ID": {"value": f"strains: {', '.join(row['strains'])}"}, - }, + "# BGCs": {"value": bgc_ids_smiles_markdown, "type": "markdown"}, + "GCF ID": {"value": strains_markdown, "type": "markdown"}, + } ) columns = [ diff --git a/app/layouts.py b/app/layouts.py index e03ec57..0ae4cb9 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -144,9 +144,9 @@ ), dash_table.DataTable( id="gm-table", - columns=[], # Start with empty columns - data=[], # Start with empty data - tooltip_data=[], # Start with empty tooltip data + columns=[], + data=[], + tooltip_data=[], editable=False, filter_action="none", sort_action="none", @@ -174,18 +174,19 @@ } ], style_cell_conditional=[{"if": {"column_id": "selector"}, "width": "30px"}], - tooltip_delay=500, + tooltip_delay=0, tooltip_duration=None, css=[ { "selector": ".dash-table-tooltip", - "rule": "background-color: white; font-family: monospace; max-width: none !important", + "rule": "background-color: white; font-family: monospace; max-width: none !important; white-space: pre-wrap; padding: 5px;", } ], + tooltip={"type": "markdown"}, ), ], id="gm-table-card-body", - style={"display": "none"}, # Initially hide the CardBody + style={"display": "none"}, ), html.Div(id="gm-table-output1", className="p-4"), html.Div(id="gm-table-output2", className="p-4"), From a74d8b5c304830038f011282d9a89565a669201d Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Tue, 24 Sep 2024 16:24:36 +0200 Subject: [PATCH 25/26] fix tests --- tests/test_callbacks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index d3c1daa..45a2a57 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -44,6 +44,7 @@ def sample_processed_data(): "# BGCs": 3, "BGC Classes": ["NRPS", "PKS"], "BGC IDs": ["BGC_1", "BGC_2", "BGC_3"], + "BGC smiles": ["CCO", "CCN", "N/A"], "strains": ["Strain_1", "Strain_2", "Strain_3"], }, { @@ -51,6 +52,7 @@ def sample_processed_data(): "# BGCs": 2, "BGC Classes": ["RiPP", "Terpene"], "BGC IDs": ["BGC_1", "BGC_3"], + "BGC smiles": ["CCO", "N/A"], "strains": ["Strain_3"], }, ] From e73db0f74a76fd0474fd8e0ab12701c037d8b05b Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Fri, 27 Sep 2024 14:27:22 +0200 Subject: [PATCH 26/26] improve tooltips style and strains visualization --- app/callbacks.py | 14 ++++++++------ app/layouts.py | 11 ++++++++++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 91899f1..52338cd 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -110,19 +110,21 @@ def process_bgc_class(bgc_class: tuple[str, ...] | None) -> list[str]: for gcf in gcfs: gcf_bgc_classes = [cls for bgc in gcf.bgcs for cls in bgc_to_class[bgc.id]] - bgc_ids = [bgc.id for bgc in gcf.bgcs] - bgc_smiles = [ - bgc.smiles[0] if bgc.smiles and bgc.smiles[0] is not None else "N/A" + bgc_data = [ + (bgc.id, bgc.smiles[0] if bgc.smiles and bgc.smiles[0] is not None else "N/A") for bgc in gcf.bgcs ] - strains = [str(gcf.strains)] + bgc_data.sort(key=lambda x: x[0]) + bgc_ids, bgc_smiles = zip(*bgc_data) + strains = [s.id for s in gcf.strains._strains] + strains.sort() processed_data["gcf_data"].append( { "GCF ID": gcf.id, "# BGCs": len(gcf.bgcs), "BGC Classes": list(set(gcf_bgc_classes)), # Using set to get unique classes - "BGC IDs": bgc_ids, - "BGC smiles": bgc_smiles, + "BGC IDs": list(bgc_ids), + "BGC smiles": list(bgc_smiles), "strains": strains, } ) diff --git a/app/layouts.py b/app/layouts.py index 0ae4cb9..394cee9 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -179,7 +179,16 @@ css=[ { "selector": ".dash-table-tooltip", - "rule": "background-color: white; font-family: monospace; max-width: none !important; white-space: pre-wrap; padding: 5px;", + "rule": """ + background-color: #ffd8cc; + font-family: monospace; + font-size: 12px; + max-width: none !important; + white-space: pre-wrap; + padding: 8px; + border: 1px solid #FF6E42; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); + """, } ], tooltip={"type": "markdown"},