From 1bf7a309b307e3a7259383cf34e67bbf95542144 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Mon, 24 Mar 2025 16:17:00 +0100 Subject: [PATCH 01/12] update dev notes with branching and releasing sections --- README.dev.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/README.dev.md b/README.dev.md index 9ec53a5..d9d593c 100644 --- a/README.dev.md +++ b/README.dev.md @@ -95,4 +95,32 @@ Mypy configurations are set in [mypy.ini](mypy.ini) file. For more info about static typing and mypy, see: - [Static typing with Python](https://typing.readthedocs.io/en/latest/index.html#) -- [Mypy doc](https://mypy.readthedocs.io/en/stable/) \ No newline at end of file +- [Mypy doc](https://mypy.readthedocs.io/en/stable/) + +## Branching workflow + +We use a [Git Flow](https://nvie.com/posts/a-successful-git-branching-model/)-inspired branching workflow for development. This repository is based on two main branches with infinite lifetime: + +- `main` — this branch contains production (stable) code. All development code is merged into `main` in sometime. +- `dev` — this branch contains pre-production code. When the features are finished then they are merged into `dev`. + +During the development cycle, three main supporting branches are used: + +- Feature branches - Branches that branch off from `dev` and must merge into `dev`: used to develop new features for the upcoming releases. +- Hotfix branches - Branches that branch off from `main` and must merge into `main` and `dev`: necessary to act immediately upon an undesired status of `main`. +- Release branches - Branches that branch off from `dev` and must merge into `main` and `dev`: support preparation of a new production release. They allow many minor bug to be fixed and preparation of meta-data for a release. + +## GitHub release + +0. Make sure you have all required developers tools installed `pip install -e .'[test]'`. +1. Create a `release-` branch from `main` (if there has been an hotfix) or `dev` (regular new production release). +2. Prepare the branch for release by ensuring all tests pass (`pytest -v`), and that linting (`ruff check`), formatting (`ruff format --check`) and static typing (`mypy app tests`) rules are adhered to. Make sure that the debug mode in the `app/main.py` file is set to `False`. +3. Merge the release branch into both `main` and `dev`. +4. On the [Releases page](https://github.com/neurogym/neurogym/releases): + 1. Click "Draft a new release" + 2. By convention, use `v` as both the release title and as a tag for the release. Decide on the [version level increase](#versioning), following [semantic versioning conventions](https://semver.org/) (MAJOR.MINOR.PATCH). + 3. Click "Generate release notes" to automatically load release notes from merged PRs since the last release. + 4. Adjust the notes as required. + 5. Ensure that "Set as latest release" is checked and that both other boxes are unchecked. + 6. Hit "Publish release". + - This will automatically trigger a [GitHub workflow](https://github.com/NPLinker/nplinker-webapp/blob/main/.github/workflows/release_ghcr.yml) that will take care of updating the version number in the relevant files and publishing the image of the dashboard to the GitHub Container Registry. From 9c08cd5c0788298f939658415c27ee1970d5a871 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Mon, 24 Mar 2025 16:19:58 +0100 Subject: [PATCH 02/12] change test to dev --- README.dev.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.dev.md b/README.dev.md index d9d593c..30014de 100644 --- a/README.dev.md +++ b/README.dev.md @@ -112,7 +112,7 @@ During the development cycle, three main supporting branches are used: ## GitHub release -0. Make sure you have all required developers tools installed `pip install -e .'[test]'`. +0. Make sure you have all required developers tools installed `pip install -e .'[dev]'`. 1. Create a `release-` branch from `main` (if there has been an hotfix) or `dev` (regular new production release). 2. Prepare the branch for release by ensuring all tests pass (`pytest -v`), and that linting (`ruff check`), formatting (`ruff format --check`) and static typing (`mypy app tests`) rules are adhered to. Make sure that the debug mode in the `app/main.py` file is set to `False`. 3. Merge the release branch into both `main` and `dev`. From fc82c038c329f2234336c15b2c3b08f50e17df84 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Mon, 24 Mar 2025 16:21:37 +0100 Subject: [PATCH 03/12] set debug false for release --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index e0f5ce9..ead1e5f 100644 --- a/app/main.py +++ b/app/main.py @@ -5,4 +5,4 @@ app.layout = create_layout() if __name__ == "__main__": - app.run_server(debug=True, host="0.0.0.0") + app.run_server(debug=False, host="0.0.0.0") From 6bc49f74f1014be0adc0a27b67fd6dd4e8992ad3 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Mon, 24 Mar 2025 16:24:16 +0100 Subject: [PATCH 04/12] add detail about debug --- README.dev.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.dev.md b/README.dev.md index 30014de..57ee49f 100644 --- a/README.dev.md +++ b/README.dev.md @@ -114,9 +114,10 @@ During the development cycle, three main supporting branches are used: 0. Make sure you have all required developers tools installed `pip install -e .'[dev]'`. 1. Create a `release-` branch from `main` (if there has been an hotfix) or `dev` (regular new production release). -2. Prepare the branch for release by ensuring all tests pass (`pytest -v`), and that linting (`ruff check`), formatting (`ruff format --check`) and static typing (`mypy app tests`) rules are adhered to. Make sure that the debug mode in the `app/main.py` file is set to `False`. -3. Merge the release branch into both `main` and `dev`. -4. On the [Releases page](https://github.com/neurogym/neurogym/releases): +2. Prepare the branch for release by ensuring all tests pass (`pytest -v`), and that linting (`ruff check`), formatting (`ruff format --check`) and static typing (`mypy app tests`) rules are adhered to. +3. Merge the release branch into `dev`. +4. Make sure that the debug mode in the `app/main.py` file is set to `False`. Merge the release branch into `main` and delete the release branch. +5. On the [Releases page](https://github.com/neurogym/neurogym/releases): 1. Click "Draft a new release" 2. By convention, use `v` as both the release title and as a tag for the release. Decide on the [version level increase](#versioning), following [semantic versioning conventions](https://semver.org/) (MAJOR.MINOR.PATCH). 3. Click "Generate release notes" to automatically load release notes from merged PRs since the last release. From dc6c5bafadc6646595c5dca3c4168f224940f25d Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Mon, 24 Mar 2025 16:24:56 +0100 Subject: [PATCH 05/12] debug true --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index ead1e5f..e0f5ce9 100644 --- a/app/main.py +++ b/app/main.py @@ -5,4 +5,4 @@ app.layout = create_layout() if __name__ == "__main__": - app.run_server(debug=False, host="0.0.0.0") + app.run_server(debug=True, host="0.0.0.0") From c45b2488bd2826ab1c4029d085614db6036f7fc0 Mon Sep 17 00:00:00 2001 From: Giulia Crocioni <55382553+gcroci2@users.noreply.github.com> Date: Thu, 22 May 2025 14:51:07 +0200 Subject: [PATCH 06/12] remove darkmode components (#58) --- app/callbacks.py | 12 ------------ app/layouts.py | 17 ----------------- 2 files changed, 29 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 0ddefc5..d98ef46 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -31,7 +31,6 @@ from dash import Output from dash import State from dash import callback_context as ctx -from dash import clientside_callback from dash import dcc from dash import html from nplinker.metabolomics.molecular_family import MolecularFamily @@ -47,17 +46,6 @@ TEMP_DIR = tempfile.mkdtemp() du.configure_upload(app, TEMP_DIR) -clientside_callback( - """ - (switchOn) => { - document.documentElement.setAttribute('data-bs-theme', switchOn ? 'light' : 'dark'); - return window.dash_clientside.no_update - } - """, - Output("color-mode-switch", "id"), - Input("color-mode-switch", "value"), -) - # ------------------ Upload and Process Data ------------------ # @du.callback( diff --git a/app/layouts.py b/app/layouts.py index c608900..5ba37ca 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -575,19 +575,6 @@ def create_tab_content(prefix, filter_title, checkl_options, no_sort_columns): # ------------------ Nav Bar ------------------ # -color_mode_switch = html.Span( - [ - dbc.Label(className="fa fa-moon", html_for="color-mode-switch"), - dbc.Switch( - id="color-mode-switch", - value=False, - className="d-inline-block ms-1", - persistence=True, - ), - dbc.Label(className="fa fa-sun", html_for="color-mode-switch"), - ], - className="p-2", -) navbar = dbc.Row( dbc.Col( dbc.NavbarSimple( @@ -596,10 +583,6 @@ def create_tab_content(prefix, filter_title, checkl_options, no_sort_columns): dbc.NavItem( dbc.NavLink("About", href="https://github.com/NPLinker/nplinker-webapp"), ), - dbc.NavItem( - color_mode_switch, - className="mt-1 p-1", - ), ], brand="NPLinker Webapp", color="primary", From e45220c88a981ccf57552f098016f63512dc9ccd Mon Sep 17 00:00:00 2001 From: Giulia Crocioni <55382553+gcroci2@users.noreply.github.com> Date: Thu, 22 May 2025 14:51:31 +0200 Subject: [PATCH 07/12] feat: add the possibility to change x-axis in the gm table plot (#59) * add class_bgcs key to processed_data * add dropdown option for x-axis * match styles * display none selector when there is not data * make sure that the plot is reset to default x-axis when new file gets uploaded * fix tests --- app/callbacks.py | 147 ++++++++++++++++++++++++++++++---------- app/layouts.py | 35 +++++++++- tests/test_callbacks.py | 15 ++-- 3 files changed, 155 insertions(+), 42 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index d98ef46..f627265 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -116,7 +116,12 @@ def process_bgc_class(bgc_class: tuple[str, ...] | None) -> list[str]: return ["Unknown"] return list(bgc_class) # Convert tuple to list - processed_data: dict[str, Any] = {"n_bgcs": {}, "gcf_data": [], "mf_data": []} + processed_data: dict[str, Any] = { + "gcf_data": [], + "n_bgcs": {}, + "class_bgcs": {}, + "mf_data": [], + } for gcf in gcfs: sorted_bgcs = sorted(gcf.bgcs, key=lambda bgc: bgc.id) @@ -137,6 +142,12 @@ def process_bgc_class(bgc_class: tuple[str, ...] | None) -> list[str]: processed_data["n_bgcs"][len(gcf.bgcs)] = [] processed_data["n_bgcs"][len(gcf.bgcs)].append(gcf.id) + for bgc_class_list in bgc_classes: + for bgc_class in bgc_class_list: + if bgc_class not in processed_data["class_bgcs"]: + processed_data["class_bgcs"][bgc_class] = [] + processed_data["class_bgcs"][bgc_class].append(gcf.id) + for mf in mfs: sorted_spectra = sorted(mf.spectra, key=lambda spectrum: spectrum.id) processed_data["mf_data"].append( @@ -254,7 +265,7 @@ def process_mg_link(mf, gcf, methods_data): Output("gm-filter-accordion-component", "value", allow_duplicate=True), Output("gm-scoring-accordion-component", "value", allow_duplicate=True), Output("gm-results-table-column-toggle", "value", allow_duplicate=True), - # MG tab outputs + Output("gm-graph-x-axis-selector", "value"), Output("mg-tab", "disabled"), Output("mg-filter-accordion-control", "disabled"), Output("mg-filter-blocks-id", "data", allow_duplicate=True), @@ -314,6 +325,7 @@ def disable_tabs_and_reset_blocks( [], [], default_gm_column_value, + "n_bgcs", # MG tab - disabled True, True, @@ -362,6 +374,7 @@ def disable_tabs_and_reset_blocks( [], [], default_gm_column_value, + "n_bgcs", # MG tab - enabled with initial blocks False, False, @@ -385,50 +398,116 @@ def disable_tabs_and_reset_blocks( @app.callback( Output("gm-graph", "figure"), Output("gm-graph", "style"), - [Input("processed-data-store", "data")], + Output("gm-graph-selector-container", "style"), + [Input("processed-data-store", "data"), Input("gm-graph-x-axis-selector", "value")], ) -def gm_plot(stored_data: str | None) -> tuple[dict | go.Figure, dict]: +def gm_plot(stored_data: str | None, x_axis_selection: str) -> tuple[dict | go.Figure, dict, dict]: """Create a bar plot based on the processed data. Args: stored_data: JSON string of processed data or None. + x_axis_selection: Selected x-axis type ('n_bgcs' or 'class_bgcs'). Returns: - Tuple containing the plot figure, style, and a status message. + Tuple containing the plot figure, style for graph, and style for selector. """ if stored_data is None: - return {}, {"display": "none"} - data = json.loads(stored_data) - n_bgcs = data["n_bgcs"] + return {}, {"display": "none"}, {"display": "none"} - 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(str(gcf_id) for gcf_id in n_bgcs[str(x)])}" for x in x_values - ] + data = json.loads(stored_data) - # 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, - ) + if x_axis_selection == "n_bgcs": + 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(str(gcf_id) for gcf_id in n_bgcs[str(x)])}" for x in x_values ] - ) - # Update layout - fig.update_layout( - xaxis_title="# BGCs", - yaxis_title="# GCFs", - xaxis=dict(type="category"), - ) - return fig, {"display": "block"} + + # 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"), + ) + + else: # x_axis_selection == "class_bgcs" + class_bgcs = data["class_bgcs"] + + # Count unique GCF IDs for each class + class_gcf_counts = {} + for bgc_class, gcf_ids in class_bgcs.items(): + # Count unique GCF IDs + class_gcf_counts[bgc_class] = len(set(gcf_ids)) + + # Sort classes by count for better visualization + sorted_classes = sorted(class_gcf_counts.items(), key=lambda x: x[1], reverse=True) + x_values = [item[0] for item in sorted_classes] + y_values = [item[1] for item in sorted_classes] + + # Generate hover texts with line breaks for better readability + hover_texts = [] + for bgc_class in x_values: + # Get unique GCF IDs for this class + unique_gcf_ids = sorted(list(set(class_bgcs[bgc_class]))) + + # Format GCF IDs with line breaks every 10 items + formatted_gcf_ids = "" + for i, gcf_id in enumerate(unique_gcf_ids): + formatted_gcf_ids += gcf_id + # Add comma if not the last item + if i < len(unique_gcf_ids) - 1: + formatted_gcf_ids += ", " + # Add line break after every 10 items (but not for the last group) + if (i + 1) % 10 == 0 and i < len(unique_gcf_ids) - 1: + formatted_gcf_ids += "
" + + hover_text = f"Class: {bgc_class}
GCF IDs: {formatted_gcf_ids}" + hover_texts.append(hover_text) + + # 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="BGC Classes", + yaxis_title="# GCFs", + xaxis=dict( + type="category", + # Add more space for longer class names + tickangle=-45 if len(x_values) > 5 else 0, + ), + ) + + return fig, {"display": "block"}, {"display": "block"} # ------------------ Common Filter and Table Functions ------------------ # diff --git a/app/layouts.py b/app/layouts.py index 5ba37ca..699cc49 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -553,10 +553,41 @@ def create_tab_content(prefix, filter_title, checkl_options, no_sort_columns): # Add graph component only for GM tab components = [] if prefix == "gm": - graph = dcc.Graph(id="gm-graph", className="mt-5 mb-3", style={"display": "none"}) + # Add x-axis selector dropdown above the graph + graph_with_selector = html.Div( + [ + dbc.Row( + [ + dbc.Col( + html.Div( + [ + html.Label("Select X-axis: ", className="me-2"), + dcc.Dropdown( + id="gm-graph-x-axis-selector", + options=[ + {"label": "# BGCs", "value": "n_bgcs"}, + {"label": "BGC Classes", "value": "class_bgcs"}, + ], + value="n_bgcs", # Default value + clearable=False, + style={"width": "200px"}, + ), + ], + className="d-flex align-items-center", + ), + width=12, + ) + ], + id="gm-graph-selector-container", + ), + dcc.Graph(id="gm-graph"), + ], + className="mt-5 mb-3", + ) + components = [ dbc.Col(filter_accordion, width=10, className="mx-auto dbc"), - dbc.Col(graph, width=10, className="mx-auto"), + dbc.Col(graph_with_selector, width=10, className="mx-auto dbc"), dbc.Col(data_table, width=10, className="mx-auto"), dbc.Col(scoring_accordion, width=10, className="mx-auto dbc"), ] diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index cb30466..24f55f3 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -193,9 +193,9 @@ def test_process_uploaded_data_structure(): # Check that all gm_data lists have the same length gm_list_lengths = [len(processed_links["gm_data"][key]) for key in expected_gm_keys] - assert all( - length == gm_list_lengths[0] for length in gm_list_lengths - ), "GM link data lists have inconsistent lengths" + assert all(length == gm_list_lengths[0] for length in gm_list_lengths), ( + "GM link data lists have inconsistent lengths" + ) # Check spectrum structure in gm_data if gm_list_lengths[0] > 0: # Only if there are any GM links @@ -224,9 +224,9 @@ def test_process_uploaded_data_structure(): # Check that all mg_data lists have the same length mg_list_lengths = [len(processed_links["mg_data"][key]) for key in expected_mg_keys] - assert all( - length == mg_list_lengths[0] for length in mg_list_lengths - ), "MG link data lists have inconsistent lengths" + assert all(length == mg_list_lengths[0] for length in mg_list_lengths), ( + "MG link data lists have inconsistent lengths" + ) # Check gcf structure in mg_data if mg_list_lengths[0] > 0: # Only if there are any MG links @@ -273,6 +273,7 @@ def test_disable_tabs(mock_uuid): [], [], default_gm_column_value, + "n_bgcs", # MG tab - disabled True, True, @@ -312,6 +313,7 @@ def test_disable_tabs(mock_uuid): gm_filter_accordion_value, gm_scoring_accordion_value, gm_results_table_column_toggle, + gm_graph_dropdown, # MG tab outputs mg_tab_disabled, mg_filter_accordion_disabled, @@ -348,6 +350,7 @@ def test_disable_tabs(mock_uuid): assert gm_filter_accordion_value == [] assert gm_scoring_accordion_value == [] assert gm_results_table_column_toggle == default_gm_column_value + assert gm_graph_dropdown == "n_bgcs" # Assert MG tab outputs assert mg_tab_disabled is False From 2f3b901df268a43ba7d67501d01a8ed29e051819 Mon Sep 17 00:00:00 2001 From: Giulia Crocioni <55382553+gcroci2@users.noreply.github.com> Date: Thu, 22 May 2025 16:33:57 +0200 Subject: [PATCH 08/12] feat: make the cutoff filter for the scoring datatable clearer, and add filtering functionality to all columns (#57) * make cutoff meaning clearer * add filtering to the candidate links table * remove unused confusing Aa box * fix filtering row visualization * add docs for filters in candidate links tables * remove wrong operator * add link to all filtering operators * align placeholder and user input to the left --- README.md | 17 +++++++++++++++++ app/callbacks.py | 8 ++++---- app/layouts.py | 28 +++++++++++++++++++++++++--- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a22091a..2136e10 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,23 @@ The dashboard accepts data generated by NPLinker and saved as described in the [ Please note that links between genomic and metabolomic data must currently be computed using the NPLinker API separately, as this functionality is not yet implemented in the webapp (see [issue #19](https://github.com/NPLinker/nplinker-webapp/issues/19)). If no links are present in your data, the scoring table will be disabled. +### Filtering Table Data + +The "Candidate Links" tables support data filtering to help you focus on relevant results. You can enter filter criteria directly into each column’s filter cell by hovering over the cell. + +For numeric columns like "Average Score" or "# Links": +- `34.6` or `= 34.6` (exact match) +- `> 30` (greater than) +- `<= 50` (less than or equal to) + +For text columns like "BGC Classes" or "MiBIG IDs": +- `Polyketide` or `contains Polyketide` (contains text) +- `= Polyketide` (exact match) + +Multiple filters can be applied simultaneously across different columns to narrow down results. + +For a full list of supported filter operators, see the [official Plotly documentation](https://dash.plotly.com/datatable/filtering#filtering-operators). + ## Contributing If you want to contribute to the development of NPLinker, have a look at the [contribution guidelines](CONTRIBUTING.md) and [README for developers](README.dev.md). \ No newline at end of file diff --git a/app/callbacks.py b/app/callbacks.py index f627265..48a3aec 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -1522,8 +1522,8 @@ def scoring_create_initial_block(block_id: str, tab_prefix: str = "gm") -> dmc.G "type": f"{tab_prefix}-scoring-dropdown-ids-cutoff-met", "index": block_id, }, - label="Cutoff", - placeholder="Insert cutoff value as a number", + label="Scoring method's cutoff (>=)", + placeholder="Insert the minimum cutoff value to be considered", value="0.05", className="custom-textinput", ) @@ -1642,8 +1642,8 @@ def scoring_display_blocks( "type": f"{tab_prefix}-scoring-dropdown-ids-cutoff-met", "index": new_block_id, }, - label="Cutoff", - placeholder="Insert cutoff value as a number", + label="Scoring method's cutoff (>=)", + placeholder="Insert the minimum cutoff value to be considered", value="0.05", className="custom-textinput", ), diff --git a/app/layouts.py b/app/layouts.py index 699cc49..b8a859c 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -103,10 +103,14 @@ def create_results_table(table_id, no_sort_columns): columns=[], data=[], editable=False, - filter_action="none", + filter_action="native", + filter_options={"placeholder_text": " filter data..."}, + style_filter={ + "backgroundColor": "#f8f9fa", + }, sort_action="native", virtualization=True, - fixed_rows={"headers": True}, # Keep headers visible when scrolling + fixed_rows={"headers": True}, sort_mode="single", sort_as_null=["None", ""], sort_by=[], @@ -158,7 +162,25 @@ def create_results_table(table_id, no_sort_columns): border: 1px solid #FF6E42; box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); """, - } + }, + { + "selector": ".dash-filter input::placeholder", + "rule": "opacity: 1 !important; text-align: left !important;", + }, + { + "selector": ".dash-filter input", + "rule": "text-align: left !important; width: 100% !important;", + }, + # Hide the filter type indicators completely + { + "selector": ".dash-filter--case", + "rule": "display: none !important;", + }, + # Adjust padding to fill the space where indicators were + { + "selector": ".dash-filter", + "rule": "padding-left: 0 !important;", + }, ] + [ { From 5c1a3ec5ec946152d4b1fc48fce31299ffd49d71 Mon Sep 17 00:00:00 2001 From: Giulia Crocioni <55382553+gcroci2@users.noreply.github.com> Date: Fri, 23 May 2025 12:03:51 +0200 Subject: [PATCH 09/12] feat: add load demo data button for the online demo webapp (#60) * add demo data button * add test for load demo data * add docs for online demo * add unique id to loaded demo data * improve sections order * add cleanup flag, defaulted to True * add test for cleanup flag * change default cutoff to 0 * add readme suggestions * Update README.md Co-authored-by: Cunliang Geng * Update README.md Co-authored-by: Cunliang Geng * Update README.md Co-authored-by: Cunliang Geng * Update README.md Co-authored-by: Cunliang Geng * Update README.md Co-authored-by: Cunliang Geng * Update README.md Co-authored-by: Cunliang Geng * Update README.md Co-authored-by: Cunliang Geng * use conda instead of venv * wrap subsections --------- Co-authored-by: Cunliang Geng --- README.md | 97 ++++++++++++++++++++++++----------------- app/callbacks.py | 69 +++++++++++++++++++++++++++-- app/layouts.py | 15 +++++++ tests/test_callbacks.py | 58 ++++++++++++++++++++++-- 4 files changed, 190 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 2136e10..23f39a6 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,55 @@ # NPLinker Web Application -This is the [NPLinker](https://nplinker.github.io/nplinker/latest/) web application, developed with [Plotly Dash](https://dash.plotly.com/), which enables you to visualize NPLinker predictions in an interactive way. +👉 **[Webapp Live Demo](https://nplinker-webapp.onrender.com)** -

- Dashboard Screenshot 1 -

+This is the [NPLinker](https://nplinker.github.io/nplinker/latest/) web application (webapp), developed with [Plotly Dash](https://dash.plotly.com/), which enables you to visualize NPLinker predictions in an interactive way. -

- Dashboard Screenshot 2 -

+NPLinker is a Python framework for data mining microbial natural products by integrating genomics and metabolomics data. For a deep understanding of NPLinker, please refer to the [original paper](https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1008920). -

- Dashboard Screenshot 3 -

+## Online Demo -NPLinker is a Python framework for data mining microbial natural products by integrating genomics and metabolomics data. +A live demo of the NPLinker webapp is automatically deployed to [Render](https://render.com/) from `main` branch. +You can try out the webapp directly in your browser [here](https://nplinker-webapp.onrender.com). -For a deep understanding of NPLinker, please refer to the [original paper](https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1008920). +### Getting Started with Demo Data -## Prerequisites +The webapp includes a convenient **"Load Demo Data"** button that automatically loads some sample data for you to try. Simply: +1. Open the [live demo link](https://nplinker-webapp.onrender.com/) +2. Click the **"Load Demo Data"** button below the file uploader +3. The app will automatically download and process the sample dataset from [`tests/data/mock_obj_data.pkl`](https://github.com/NPLinker/nplinker-webapp/blob/main/tests/data/mock_obj_data.pkl) +4. Start exploring natural product linking features! -Before installing NPLinker Web Application, ensure you have: +This demo web server is intended only for lightweight demo purposes. For full functionality, including large-scale data processing and persistent storage, please install the application locally or via Docker as described below. -- [Python 3.10](https://www.python.org/downloads/) -- [Git](https://git-scm.com/downloads) +
+⚠️ Demo Server Limitations +Please note the following limitations of the hosted demo: + +* **Cold start delay**: Free-tier apps on Render sleep after 15 minutes of inactivity and may take 20–30 seconds to wake up. +* **Performance**: This is a minimal deployment on a free tier and is not optimized for large datasets or concurrent users. +* **File size limits**: The demo data button loads a small sample dataset suitable for testing. Uploading large datasets via the file uploader may lead to errors or timeouts. +* **No persistent storage**: Uploaded files are not saved between sessions. +
+ +## Using the webapp + +### Input Data + +The webapp accepts data generated by NPLinker and saved as described in the [NPLinker quickstart section](https://nplinker.github.io/nplinker/latest/quickstart/). For testing purposes, a small sample dataset is provided in [`tests/data/mock_obj_data.pkl`](https://github.com/NPLinker/nplinker-webapp/blob/main/tests/data/mock_obj_data.pkl) that can be used to try out the webapp. + +Please note that links between genomic and metabolomic data must currently be computed using the NPLinker API separately, as this functionality is not yet implemented in the webapp (see [issue #19](https://github.com/NPLinker/nplinker-webapp/issues/19)). If no links are present in your data, the scoring table will be disabled. + +## Installation -## Installation Options +Before installing NPLinker webapp, ensure you have: -You can install and run the NPLinker dashboard in two ways: directly on your local machine or using Docker. +- [Python ≥3.10](https://www.python.org/downloads/) +- [Git](https://git-scm.com/downloads) +- [Conda](https://docs.conda.io/en/latest/miniconda.html) + +You can install and run the NPLinker webapp in two ways: directly on your local machine or using Docker. -### Option 1: Local Installation +### Option 1: Local Installation (using Conda) Follow these steps to install the application directly on your system: @@ -39,16 +59,13 @@ Follow these steps to install the application directly on your system: cd nplinker-webapp ``` -2. **Set up a virtual environment** +2. **Set up a conda environment** ```bash - # Create a virtual environment - python3.10 -m venv venv - - # Activate the virtual environment - # For Windows: - venv\Scripts\activate - # For macOS/Linux: - source venv/bin/activate + # Create a new conda environment with Python 3.10 + conda create -n nplinker-webapp python=3.10 + + # Activate the environment + conda activate nplinker-webapp ``` 3. **Install dependencies** @@ -61,22 +78,25 @@ Follow these steps to install the application directly on your system: python app/main.py ``` -5. **Access the dashboard** +5. **Access the webapp** Open your web browser and navigate to `http://0.0.0.0:8050/` -#### Troubleshooting Local Installation +
+Troubleshooting Local Installation -Common issues and solutions: +#### Common issues and solutions - **Port already in use**: If port 8050 is already in use, modify the port in `app/main.py` by changing `app.run_server(debug=True, port=8050)` - **Package installation errors**: Make sure you're using Python 3.10 and that your pip is up-to-date If you encounter other problems, please check the [Issues](https://github.com/NPLinker/nplinker-webapp/issues) page or create a new issue. +
+ ### Option 2: Docker Installation -Using Docker is the quickest way to get started with NPLinker Web Application. Make sure you have [Docker](https://www.docker.com/) installed on your system before proceeding: +Using Docker is the quickest way to get started with NPLinker webapp. Make sure you have [Docker](https://www.docker.com/) installed on your system before proceeding: 1. **Pull the Docker image** ```bash @@ -88,11 +108,12 @@ Using Docker is the quickest way to get started with NPLinker Web Application. M docker run -p 8050:8050 ghcr.io/nplinker/nplinker-webapp:latest ``` -3. **Access the dashboard** +3. **Access the webapp** Open your web browser and navigate to `http://0.0.0.0:8050/` -#### Docker Image Information +
+Docker Image Information - **Available Tags**: - `latest`: The most recent build @@ -102,13 +123,7 @@ Using Docker is the quickest way to get started with NPLinker Web Application. M - **More Details**: For additional information about the Docker image, see its [GitHub Container Registry page](https://github.com/NPLinker/nplinker-webapp/pkgs/container/nplinker-webapp). -## Using the Dashboard - -### Input Data - -The dashboard accepts data generated by NPLinker and saved as described in the [NPLinker quickstart section](https://nplinker.github.io/nplinker/latest/quickstart/). For testing purposes, a small sample dataset is provided in [`tests/data/mock_obj_data.pkl`](https://github.com/NPLinker/nplinker-webapp/blob/main/tests/data/mock_obj_data.pkl) that can be used to try out the webapp. - -Please note that links between genomic and metabolomic data must currently be computed using the NPLinker API separately, as this functionality is not yet implemented in the webapp (see [issue #19](https://github.com/NPLinker/nplinker-webapp/issues/19)). If no links are present in your data, the scoring table will be disabled. +
### Filtering Table Data diff --git a/app/callbacks.py b/app/callbacks.py index 48a3aec..a48483c 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -12,6 +12,7 @@ import dash_uploader as du import pandas as pd import plotly.graph_objects as go +import requests from config import GM_FILTER_DROPDOWN_BGC_CLASS_OPTIONS_PRE_V4 from config import GM_FILTER_DROPDOWN_BGC_CLASS_OPTIONS_V4 from config import GM_FILTER_DROPDOWN_MENU_OPTIONS @@ -46,6 +47,10 @@ TEMP_DIR = tempfile.mkdtemp() du.configure_upload(app, TEMP_DIR) +DEMO_DATA_URL = ( + "https://github.com/NPLinker/nplinker-webapp/blob/main/tests/data/mock_obj_data.pkl?raw=true" +) + # ------------------ Upload and Process Data ------------------ # @du.callback( @@ -83,6 +88,55 @@ def upload_data(status: du.UploadStatus) -> tuple[str, str | None, None]: return "No file uploaded", None, None +@app.callback( + Output("dash-uploader-output", "children", allow_duplicate=True), + Output("file-store", "data", allow_duplicate=True), + Output("loading-spinner-container", "children", allow_duplicate=True), + Input("demo-data-button", "n_clicks"), + prevent_initial_call=True, +) +def load_demo_data(n_clicks): + """Load demo data from GitHub repository. + + Args: + n_clicks: Number of times the demo data button has been clicked. + + Returns: + A tuple containing a message string and the file path (if successful). + """ + if n_clicks is None: + raise dash.exceptions.PreventUpdate + + try: + # Download the demo data + response = requests.get(DEMO_DATA_URL, timeout=30) + response.raise_for_status() + + # Save to temporary file + demo_file_path = os.path.join(TEMP_DIR, f"demo_data_{uuid.uuid4()}.pkl") + with open(demo_file_path, "wb") as f: + f.write(response.content) + + # Validate the pickle file + with open(demo_file_path, "rb") as f: + pickle.load(f) + + file_size_mb = len(response.content) / (1024 * 1024) + + return ( + f"Successfully loaded demo data: demo_data.pkl [{round(file_size_mb, 2)} MB]", + str(demo_file_path), + None, + ) + + except requests.exceptions.RequestException as e: + return f"Error downloading demo data: Network error - {str(e)}", None, None + except (pickle.UnpicklingError, EOFError, AttributeError): + return "Error: Downloaded file is not a valid pickle file.", None, None + except Exception as e: + return f"Error loading demo data: {str(e)}", None, None + + @app.callback( Output("processed-data-store", "data"), Output("processed-links-store", "data"), @@ -91,12 +145,13 @@ def upload_data(status: du.UploadStatus) -> tuple[str, str | None, None]: prevent_initial_call=True, ) def process_uploaded_data( - file_path: Path | str | None, + file_path: Path | str | None, cleanup: bool = True ) -> tuple[str | None, str | None, str | None]: """Process the uploaded pickle file and store the processed data. Args: file_path: Path to the uploaded pickle file. + cleanup: Flag to indicate whether to clean up the file after processing. Returns: JSON string of processed data or None if processing fails. @@ -245,6 +300,12 @@ def process_mg_link(mf, gcf, methods_data): except Exception as e: print(f"Error processing file: {str(e)}") return None, None, None + finally: + try: + if cleanup and file_path and os.path.exists(file_path): + os.remove(file_path) + except Exception as e: + print(f"Cleanup failed for {file_path}: {e}") @app.callback( @@ -1524,7 +1585,7 @@ def scoring_create_initial_block(block_id: str, tab_prefix: str = "gm") -> dmc.G }, label="Scoring method's cutoff (>=)", placeholder="Insert the minimum cutoff value to be considered", - value="0.05", + value="0", className="custom-textinput", ) ], @@ -1644,7 +1705,7 @@ def scoring_display_blocks( }, label="Scoring method's cutoff (>=)", placeholder="Insert the minimum cutoff value to be considered", - value="0.05", + value="0", className="custom-textinput", ), ], @@ -1686,7 +1747,7 @@ def scoring_update_placeholder( # Callback was not triggered by user interaction, don't change anything raise dash.exceptions.PreventUpdate if selected_value == "METCALF": - return ({"display": "block"}, "Cutoff", "0.05") + return ({"display": "block"}, "Cutoff", "0") else: # This case should never occur due to the Literal type, but it satisfies mypy return ({"display": "none"}, "", "") diff --git a/app/layouts.py b/app/layouts.py index b8a859c..84b3cba 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -668,6 +668,21 @@ def create_tab_content(prefix, filter_title, checkl_options, no_sort_columns): className="d-flex justify-content-center", ) ), + # Demo data button + dbc.Row( + dbc.Col( + html.Div( + dbc.Button( + "Load Demo Data", + id="demo-data-button", + color="primary", + className="mt-3", + ), + className="d-flex justify-content-center", + ), + className="d-flex justify-content-center", + ) + ), dcc.Store(id="file-store"), # Store to keep the file contents dcc.Store(id="processed-data-store"), # Store to keep the processed data dcc.Store(id="processed-links-store"), # Store to keep the processed links diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 24f55f3..f9a3179 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -1,4 +1,5 @@ import json +import pickle import uuid from pathlib import Path from unittest.mock import patch @@ -15,6 +16,7 @@ from app.callbacks import gm_table_toggle_selection from app.callbacks import gm_table_update_datatable from app.callbacks import gm_toggle_download_button +from app.callbacks import load_demo_data from app.callbacks import mg_filter_add_block from app.callbacks import mg_filter_apply from app.callbacks import mg_generate_excel @@ -44,7 +46,7 @@ def mock_uuid4(): @pytest.fixture def processed_data(): # Use the actual process_uploaded_data function to get the processed data - return process_uploaded_data(MOCK_FILE_PATH) + return process_uploaded_data(MOCK_FILE_PATH, cleanup=False) @pytest.fixture @@ -104,17 +106,46 @@ def test_upload_data(): assert path_string == str(MOCK_FILE_PATH) +def test_load_demo_data(): + """Test the load_demo_data callback function.""" + + # Test with no clicks - should prevent update + with pytest.raises(dash.exceptions.PreventUpdate): + load_demo_data(None) + + # Test with actual click - should load demo data + result = load_demo_data(1) + message, file_path, spinner = result + + # Check that the function returns expected format + assert isinstance(message, str) + assert isinstance(file_path, (str, type(None))) + assert spinner is None + + # If successful, should contain success message and valid file path + if file_path is not None: + assert "Successfully loaded demo data" in message + assert "demo_data_" in file_path + # Verify the file actually exists and is valid + with open(file_path, "rb") as f: + data = pickle.load(f) + assert data is not None + else: + # If failed, should contain error message + assert "Error" in message + + @pytest.mark.parametrize("input_path", [None, Path("non_existent_file.pkl")]) def test_process_uploaded_data_invalid_input(input_path): - processed_data, processed_links, _ = process_uploaded_data(input_path) + processed_data, processed_links, _ = process_uploaded_data(input_path, cleanup=False) assert processed_data is None assert processed_links is None def test_process_uploaded_data_structure(): - processed_data, processed_links, _ = process_uploaded_data(MOCK_FILE_PATH) + processed_data, processed_links, _ = process_uploaded_data(MOCK_FILE_PATH, cleanup=False) processed_data_no_links, processed_links_no_links, _ = process_uploaded_data( - MOCK_FILE_PATH_NO_LINKS + MOCK_FILE_PATH_NO_LINKS, cleanup=False ) assert processed_data is not None @@ -242,6 +273,25 @@ def test_process_uploaded_data_structure(): assert isinstance(gcf["BGC Classes"], list) +def test_process_uploaded_data_cleanup(tmp_path): + """Ensure that temporary file is deleted when cleanup=True.""" + temp_file = tmp_path / "temp_data.pkl" + + dummy_data = (None, [], None, [], None, None) + with open(temp_file, "wb") as f: + pickle.dump(dummy_data, f) + + # Confirm file exists + assert temp_file.exists() + + # Call the function with cleanup=True (default) + processed_data, _, _ = process_uploaded_data(temp_file, cleanup=True) + + # File should be deleted after processing + assert not temp_file.exists() + assert processed_data is not None # Sanity check: function still processed the file + + def test_disable_tabs(mock_uuid): default_gm_column_value = ( [GM_RESULTS_TABLE_CHECKL_OPTIONAL_COLUMNS[0]] From 09928e82c87493d1de56bed58e787c0254643413 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Fri, 23 May 2025 12:15:49 +0200 Subject: [PATCH 10/12] fix linter issue --- app/callbacks.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/app/callbacks.py b/app/callbacks.py index 0b9a905..d955d67 100644 --- a/app/callbacks.py +++ b/app/callbacks.py @@ -13,18 +13,6 @@ import pandas as pd import plotly.graph_objects as go import requests -from config import GM_FILTER_DROPDOWN_BGC_CLASS_OPTIONS_PRE_V4 -from config import GM_FILTER_DROPDOWN_BGC_CLASS_OPTIONS_V4 -from config import GM_FILTER_DROPDOWN_MENU_OPTIONS -from config import GM_RESULTS_TABLE_CHECKL_OPTIONAL_COLUMNS -from config import GM_RESULTS_TABLE_MANDATORY_COLUMNS -from config import GM_RESULTS_TABLE_OPTIONAL_COLUMNS -from config import MAX_TOOLTIP_ROWS -from config import MG_FILTER_DROPDOWN_MENU_OPTIONS -from config import MG_RESULTS_TABLE_CHECKL_OPTIONAL_COLUMNS -from config import MG_RESULTS_TABLE_MANDATORY_COLUMNS -from config import MG_RESULTS_TABLE_OPTIONAL_COLUMNS -from config import SCORING_DROPDOWN_MENU_OPTIONS from dash import ALL from dash import MATCH from dash import Dash From 53edb87bd317680bd4cb1057591cb0f699ac66e0 Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Fri, 23 May 2025 12:19:13 +0200 Subject: [PATCH 11/12] fix mypy issue --- mypy.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy.ini b/mypy.ini index 202ac4e..50e304d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,4 +2,4 @@ python_version = 3.10 warn_return_any = true warn_unused_configs = true -ignore_missing_imports = true \ No newline at end of file +disable_error_code = import-untyped \ No newline at end of file From 13810ec3f0b1fa0f5b826df99297d7d3b49adbcf Mon Sep 17 00:00:00 2001 From: gcroci2 Date: Fri, 23 May 2025 12:20:18 +0200 Subject: [PATCH 12/12] add back ignore missing imports --- mypy.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/mypy.ini b/mypy.ini index 50e304d..227993a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,4 +2,5 @@ python_version = 3.10 warn_return_any = true warn_unused_configs = true +ignore_missing_imports = true disable_error_code = import-untyped \ No newline at end of file