diff --git a/protzilla/run.py b/protzilla/run.py index 978ef740..0b437bb4 100644 --- a/protzilla/run.py +++ b/protzilla/run.py @@ -187,6 +187,10 @@ def current_plots(self) -> Plots | None: @property def current_outputs(self) -> Output: return self.steps.current_step.output + + @property + def current_filtered_data(self) -> dict: + return self.steps.current_step.filtered_datatable @property def current_step(self) -> Step | None: diff --git a/protzilla/steps.py b/protzilla/steps.py index 9448d26e..166a19fa 100644 --- a/protzilla/steps.py +++ b/protzilla/steps.py @@ -36,6 +36,7 @@ def __init__(self, instance_identifier: str | None = None): self.form_inputs: dict = {} self.inputs: dict = {} self.output: Output = Output() + self.filtered_datatable: dict = {} self.plots: Plots = Plots() self.messages: Messages = Messages([]) self.instance_identifier = instance_identifier diff --git a/ui/runs/templates/runs/tables.html b/ui/runs/templates/runs/tables.html index 4d8ecd4e..c4365904 100644 --- a/ui/runs/templates/runs/tables.html +++ b/ui/runs/templates/runs/tables.html @@ -9,42 +9,11 @@ {% block js %} + + {% endblock %} @@ -52,22 +21,46 @@

Index {{ index|add:1 }}, Section {{ section }}, step {{ step }}, method {{ method }} -

+
Choose table {% include 'runs/field_select_with_label.html' with key="tables_dropdown" categories=options only %}
- {% if clean_ids %} - Enable Isoforms - {% else %} - Disable Isoforms - {% endif %} - Download Table + {% if clean_ids %} + Enable Isoforms + {% else %} + Disable Isoforms + {% endif %} + Download Table +
+
+ +
+ + +
+ +
+
+
+ +
+
+
+ Rows per page + +
+
+
-
-
+ {% endblock %} diff --git a/ui/runs/views.py b/ui/runs/views.py index e21b7b2d..1ab7e87f 100644 --- a/ui/runs/views.py +++ b/ui/runs/views.py @@ -17,6 +17,9 @@ ) from django.shortcuts import render from django.urls import reverse +from django.conf import settings +from django.http import JsonResponse + from protzilla.constants.paths import WORKFLOWS_PATH from protzilla.run import Run, get_available_run_names @@ -39,7 +42,8 @@ make_name_field, make_sidebar, ) -from ui.runs.views_helper import display_message, display_messages + +from ui.runs.views_helper import display_message, display_messages, parameters_from_post, get_filtered_data, set_filtered_data from .form_mapping import ( get_filled_form_by_method, @@ -495,29 +499,68 @@ def navigate(request, run_name: str): run.step_goto(index, section_name) return HttpResponseRedirect(reverse("runs:detail", args=(run_name,))) - def tables_content(request, run_name, index, key): + """ + Handles the content of a table during a run, including filtering, searching, sorting, and pagination. + + :param request: the request object + :param run_name: the name of the run + :param index: the index of the current step + :param key: the key of the datatable + + :return: a JSON response containing the table data + """ + if run_name not in active_runs: active_runs[run_name] = Run(run_name) run = active_runs[run_name] - # TODO this will change with df_mode implementation - if index < len(run.steps.previous_steps): - outputs = run.steps.previous_steps[index].output[key] - else: - outputs = run.current_outputs[key] - out = outputs.replace(np.nan, None) + filtered_data = get_filtered_data(run, index, key) + + if request.GET.get("is_new_search", "false").lower() == "true": + filtered_data = get_filtered_data(run, index, key, reset=True) + search_query = request.GET.get("search_query", "").lower() + if search_query: + mask = filtered_data.astype(str).stack().str.contains(search_query, case=False, na=False).unstack() + filtered_data = filtered_data [mask.any(axis=1)] + set_filtered_data(run, index, key, filtered_data ) + + if request.GET.get("is_new_sorting", "false").lower() == "true": + sorting_column_idx = int(request.GET.get("sorting_column_index", "0")) + is_sort_ascending = request.GET.get("is_sort_ascending", "true").lower() == "true" + primary_column = filtered_data.columns[sorting_column_idx] + secondary_column = filtered_data.columns[0] + filtered_data = filtered_data.sort_values(by=[primary_column, secondary_column], ascending=[is_sort_ascending, True]) + set_filtered_data(run, index, key, filtered_data ) + if "clean-ids" in request.GET: - for column in out.columns: + for column in filtered_data.columns: if "protein" in column.lower(): - out[column] = out[column].map( + filtered_data[column] = filtered_data[column].map( lambda group: ";".join( unique_justseen(map(clean_uniprot_id, group.split(";"))) ) ) - return JsonResponse( - dict(columns=out.to_dict("split")["columns"], data=out.to_dict("split")["data"]) - ) + + current_page = int(request.GET.get("current_page", 1)) + rows_per_page = int(request.GET.get("rows_per_page", 10)) + + total_items = len(filtered_data ) + total_pages = (total_items + rows_per_page - 1) // rows_per_page + start_id_y = (current_page - 1) * rows_per_page + end_id_y = start_id_y + rows_per_page + paginated_data = filtered_data .iloc[start_id_y:end_id_y] + + response_data = { + "columns": paginated_data.to_dict("split")["columns"], + "data": paginated_data.to_dict("split")["data"], + "page": current_page, + "total_pages": total_pages, + "total_items": total_items, + "start_item": start_id_y + 1, + "end_item": min(end_id_y, total_items) + } + return JsonResponse(response_data) def change_method(request, run_name): diff --git a/ui/runs/views_helper.py b/ui/runs/views_helper.py index 181b52bd..eb311375 100644 --- a/ui/runs/views_helper.py +++ b/ui/runs/views_helper.py @@ -1,6 +1,7 @@ import re from django.contrib import messages +import numpy as np import ui.runs.form_mapping as form_map from protzilla.steps import StepManager @@ -118,3 +119,49 @@ def clear_messages(request): for message in messages.get_messages(request): pass storage.used = True + + +def get_filtered_data(run, index, key, reset=False): + """ + Retrieves the corresponding output data and creates a copy for the filtered data in the data table + + :param run: the corresponding run + :param index: the index of the current step + :param key: the key of the datatable + :param reset: the option to reload the real output data + + :return: a dict with the filtered data for the table + """ + if index < len(run.steps.previous_steps): + if key not in run.steps.previous_steps[index].datatable_filtered_output or reset: + outputs = run.steps.previous_steps[index].output[key] + filtered_data = outputs.copy() + filtered_data = filtered_data.replace(np.nan, None) + run.steps.previous_steps[index].datatable_filtered_output[key] = filtered_data + else: + filtered_data = run.steps.previous_steps[index].datatable_filtered_output[key] + + else: + if key not in run.current_filtered_data or reset: + outputs = run.current_outputs[key] + filtered_data = outputs.copy() + filtered_data = filtered_data.replace(np.nan, None) + run.current_filtered_data[key] = filtered_data + else: + filtered_data = run.current_filtered_data[key] + + return filtered_data + +def set_filtered_data(run, index, key, filtered_data): + """ + Saves the filtered data from the table + + :param run: the corresponding run + :param index: the index of the current step + :param key: the key of the datatable + :param filtered_data: the filtered data from the table + """ + if index < len(run.steps.previous_steps): + run.steps.previous_steps[index].datatable_filtered_output[key] = filtered_data + else: + run.current_filtered_data[key] = filtered_data \ No newline at end of file diff --git a/ui/static/js/datatables-controls.js b/ui/static/js/datatables-controls.js new file mode 100644 index 00000000..3d3e086b --- /dev/null +++ b/ui/static/js/datatables-controls.js @@ -0,0 +1,147 @@ +$(document).ready(function () { + let currentPageNumber = 1; + let rowsPerPageLimit = 10; + let searchQuery = ""; + let sortColumnIndex = 0; + let isSortAscending = true; + const sortUpIcon = ` + + + + `; + const sortDownIcon = ` + + + + `; + const defaultSortIcon = ` + + + + `; + + function loadTableData({isNewSearch = false, isNewSorting = false} = {}) { + $.ajax({ + url: RUNS_TABLES_CONTENT_URL, + data: { + clean_ids: CLEAN_IDS, + current_page: currentPageNumber, + rows_per_page: rowsPerPageLimit, + is_new_search : isNewSearch, + search_query: searchQuery, + is_new_sorting : isNewSorting, + sorting_column_index : sortColumnIndex, + is_sort_ascending : isSortAscending, + }, + type: "GET", + success: function (response) { + const columns = response.columns; + const data = response.data; + const thead = $("").append( + $("").append( + columns.map(col => $("").text(col).append(defaultSortIcon)) + ) + ); + const tbody = $("").append( + data.map(row => { + return $("").append( + row.map(cell => { + const formattedCell = (typeof cell === "number") ? cell.toFixed(10) : cell || ''; + return $("").text(formattedCell); + }) + ); + }) + ); + + $("#datatable").empty().append(thead).append(tbody); + initializeDataTable(rowsPerPageLimit); + + updatePagination(response); + updatePageInfo(response); + + $("#search-btn").html("Search"); + } + }); + } + + function initializeDataTable(rowsPerPage) { + $('#datatable').DataTable({ + destroy: true, + paging: false, + searching: false, + info: false, + lengthChange: false, + ordering: false, + pageLength: rowsPerPage, + }); + } + + function updatePagination(response) { + const pagination = $("#pagination").empty(); + if (response.page > 1) { + pagination.append(''); + } + if (response.page < response.total_pages) { + pagination.append(''); + } + } + + function updatePageInfo(response) { + const { start_item, end_item, total_items } = response; + $("#page-info").html(`Page ${response.page} of ${response.total_pages}, Showing ${start_item} to ${end_item} of ${total_items} items`); + } + + loadTableData(); + + $(document).on("click", "#prev-page", function () { + if (currentPageNumber > 1) { + currentPageNumber--; + loadTableData(); + } + }); + + $(document).on("click", "#next-page", function () { + currentPageNumber++; + loadTableData(); + }); + + $(document).on('change', '#rowsPerPage', function() { + rowsPerPageLimit = $(this).val(); + currentPageNumber = Math.ceil(((currentPageNumber - 1) * rowsPerPageLimit + 1) / rowsPerPageLimit); + loadTableData(); + }); + + $(document).on("click", "#search-btn", function () { + searchQuery = $("#searchInput").val(); + currentPageNumber = 1; + + $(this).html(` + + Searching... + `); + loadTableData({isNewSearch: true}); + }); + + $(document).on("click", "#datatable thead th", function () { + const currentTh = $(this); + sortColumnIndex = $(this).index(); + + + currentTh.siblings().removeClass("sorted_asc sorted_dec").find(".sort-icon").remove().end().append(defaultSortIcon); + currentTh.find(".sort-icon").remove(); + + if (currentTh.hasClass("sorted_asc")) { + currentTh.removeClass("sorted_asc").addClass("sorted_dec").append(sortDownIcon); + isSortAscending = false; + } else { + currentTh.removeClass("sorted_dec").addClass("sorted_asc").append(sortUpIcon); + isSortAscending = true; + } + + loadTableData({isNewSorting: true}); + }); + + $(document).on('change', '#tables_dropdown', function() { + window.location.href = $(this).val(); + }) +}); \ No newline at end of file