[Feature] Server pagination implementation (#243)

## Major Changes
- Implement a REST style API app in epapi
- Currently implements a GET method for all entity types in the browse menu (both package level and global)
- Provides paginated results per default with query style filtering for reviewed vs unreviewed.
- Provides new paginated templates with thin wrappers per entity types for easier maintainability
- Implements e2e tests for the API

## Minor changes
- Added more comprehensive gitignore to cover coverage reports and other test/node.js etc. data.
- Add additional CI file for API tests that only gets triggered on API relevant changes.

## ⚠️ Currently only works with session-based authentication. Token based will be added in new PR.

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Co-authored-by: jebus <lorsbach@envipath.com>
Reviewed-on: enviPath/enviPy#243
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
This commit is contained in:
2025-12-15 11:34:53 +13:00
committed by jebus
parent d2d475b990
commit 8adb93012a
59 changed files with 3101 additions and 620 deletions

View File

@ -0,0 +1,98 @@
{# Partial for paginated list content - expects to be inside a remotePaginatedList Alpine.js context #}
{# Variables: empty_text (string), show_review_badge (bool), always_show_badge (bool) #}
{# Loading state #}
<div x-show="isLoading">{% include "components/loading-spinner.html" %}</div>
{# Error state #}
<div
x-show="!isLoading && error"
class="alert alert-error/50 text-sm"
x-text="error"
></div>
{# Content #}
<template x-if="!isLoading && !error">
<div>
{# Empty state #}
<div
x-show="totalItems === 0"
class="text-base-content/70 py-8 text-center"
>
<p>No {{ empty_text|default:"items" }} found.</p>
</div>
{# Items list #}
<ul class="menu bg-base-100 rounded-box w-full" x-show="totalItems > 0">
<template x-for="obj in paginatedItems" :key="obj.url">
<li>
<a :href="obj.url" class="hover:bg-base-200">
<span x-text="obj.name"></span>
{% if show_review_badge %}
<span
class="tooltip tooltip-left ml-auto"
data-tip="Reviewed"
{% if not always_show_badge %}
x-show="obj.review_status === 'reviewed'"
{% endif %}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-check-icon lucide-check"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
{% endif %}
</a>
</li>
</template>
</ul>
{# Pagination controls #}
<div
x-show="totalPages > 1"
class="mt-4 flex items-center justify-between px-2"
>
<span class="text-base-content/70 text-sm">
Showing <span x-text="showingStart"></span>-<span
x-text="showingEnd"
></span>
of <span x-text="totalItems"></span>
</span>
<div class="join">
<button
class="join-item btn btn-sm"
:disabled="currentPage === 1"
@click="prevPage()"
>
«
</button>
<template x-for="item in pageNumbers" :key="item.key">
<button
class="join-item btn btn-sm"
:class="{ 'btn-active': item.page === currentPage }"
:disabled="item.isEllipsis"
@click="!item.isEllipsis && goToPage(item.page)"
x-text="item.page"
></button>
</template>
<button
class="join-item btn btn-sm"
:disabled="currentPage === totalPages"
@click="nextPage()"
>
»
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,33 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Compounds{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_compound_modal').showModal(); return false;"
>
New Compound
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_compound_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>
A compound stores the structure of a molecule and can include
meta-information.
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/compounds"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,32 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Models{% endblock %}
{% block action_button %}
{% if meta.can_edit and meta.enabled_features.MODEL_BUILDING %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_model_modal').showModal(); return false;"
>
New Model
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% if meta.enabled_features.MODEL_BUILDING %}
{% include "modals/collections/new_model_modal.html" %}
{% endif %}
{% endblock action_modals %}
{% block description %}
<p>A model applies machine learning to limit the combinatorial explosion.</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/relative_reasoning"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -4,7 +4,8 @@
{# Serialize objects data for Alpine pagination #}
{# prettier-ignore-start #}
{# FIXME: This is a hack to get the objects data into the JavaScript code. #}
{# FIXME: This is a hack to get the objects data into the JavaScript code. #}
{% if object_type != 'scenario' %}
<script>
window.reviewedObjects = [
{% for obj in reviewed_objects %}
@ -17,46 +18,23 @@
{% endfor %}
];
</script>
{# prettier-ignore-end #}
{% endif %}
{# prettier-ignore-end #}
{% if object_type != 'package' %}
<div class="px-8 py-4">
<input
type="text"
id="object-search"
class="input input-bordered hidden w-full max-w-xs"
placeholder="Search by name"
/>
</div>
{% endif %}
<div class="px-8 py-4">
<input
type="text"
id="object-search"
class="input input-bordered hidden w-full max-w-xs"
placeholder="Search by name"
/>
</div>
{% block action_modals %}
{% if object_type == 'package' %}
{% include "modals/collections/new_package_modal.html" %}
{% include "modals/collections/import_package_modal.html" %}
{% include "modals/collections/import_legacy_package_modal.html" %}
{% elif object_type == 'compound' %}
{% include "modals/collections/new_compound_modal.html" %}
{% elif object_type == 'rule' %}
{% include "modals/collections/new_rule_modal.html" %}
{% elif object_type == 'reaction' %}
{% include "modals/collections/new_reaction_modal.html" %}
{% elif object_type == 'pathway' %}
{# {% include "modals/collections/new_pathway_modal.html" %} #}
{% elif object_type == 'node' %}
{% if object_type == 'node' %}
{% include "modals/collections/new_node_modal.html" %}
{% elif object_type == 'edge' %}
{% include "modals/collections/new_edge_modal.html" %}
{% elif object_type == 'scenario' %}
{% include "modals/collections/new_scenario_modal.html" %}
{% elif object_type == 'model' %}
{% include "modals/collections/new_model_modal.html" %}
{% elif object_type == 'setting' %}
{#{% include "modals/collections/new_setting_modal.html" %}#}
{% elif object_type == 'user' %}
<div></div>
{% elif object_type == 'group' %}
{% include "modals/collections/new_group_modal.html" %}
{% endif %}
{% endblock action_modals %}
@ -66,32 +44,10 @@
<div class="card-body px-0 py-4">
<div class="flex items-center justify-between">
<h2 class="card-title text-2xl">
{% if object_type == 'package' %}
Packages
{% elif object_type == 'compound' %}
Compounds
{% elif object_type == 'structure' %}
Compound structures
{% elif object_type == 'rule' %}
Rules
{% elif object_type == 'reaction' %}
Reactions
{% elif object_type == 'pathway' %}
Pathways
{% elif object_type == 'node' %}
{% if object_type == 'node' %}
Nodes
{% elif object_type == 'edge' %}
Edges
{% elif object_type == 'scenario' %}
Scenarios
{% elif object_type == 'model' %}
Model
{% elif object_type == 'setting' %}
Settings
{% elif object_type == 'user' %}
Users
{% elif object_type == 'group' %}
Groups
{% endif %}
</h2>
<div id="actionsButton" class="dropdown dropdown-end hidden">
@ -119,103 +75,17 @@
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
>
{% block actions %}
{% if object_type == 'package' %}
{% include "actions/collections/package.html" %}
{% elif object_type == 'compound' %}
{% include "actions/collections/compound.html" %}
{% elif object_type == 'structure' %}
{% include "actions/collections/compound_structure.html" %}
{% elif object_type == 'rule' %}
{% include "actions/collections/rule.html" %}
{% elif object_type == 'reaction' %}
{% include "actions/collections/reaction.html" %}
{% elif object_type == 'setting' %}
{% include "actions/collections/setting.html" %}
{% elif object_type == 'scenario' %}
{% include "actions/collections/scenario.html" %}
{% elif object_type == 'model' %}
{% include "actions/collections/model.html" %}
{% elif object_type == 'pathway' %}
{% include "actions/collections/pathway.html" %}
{% elif object_type == 'node' %}
{% if object_type == 'node' %}
{% include "actions/collections/node.html" %}
{% elif object_type == 'edge' %}
{% include "actions/collections/edge.html" %}
{% elif object_type == 'group' %}
{% include "actions/collections/group.html" %}
{% endif %}
{% endblock %}
</ul>
</div>
</div>
<div class="mt-2">
<!-- Set Text above links -->
{% if object_type == 'package' %}
<p>
A package contains pathways, rules, etc. and can reflect specific
experimental conditions.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/packages"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'compound' %}
<p>
A compound stores the structure of a molecule and can include
meta-information.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/compounds"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'structure' %}
<p>
The structures stored in this compound
<a
target="_blank"
href="https://wiki.envipath.org/index.php/compounds"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'rule' %}
<p>
A rule describes a biotransformation reaction template that is
defined as SMIRKS.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/Rules"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'reaction' %}
<p>
A reaction is a specific biotransformation from educt compounds to
product compounds.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/reactions"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'pathway' %}
<p>
A pathway displays the (predicted) biodegradation of a compound as
graph.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/pathways"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'node' %}
{% if object_type == 'node' %}
<p>
Nodes represent the (predicted) compounds in a graph.
<a
@ -227,7 +97,7 @@
</p>
{% elif object_type == 'edge' %}
<p>
Edges represent the links between Nodes in a graph
Edges represent the links between nodes in a graph.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/edges"
@ -235,70 +105,15 @@
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'scenario' %}
<p>
A scenario contains meta-information that can be attached to other
data (compounds, rules, ..).
<a
target="_blank"
href="https://wiki.envipath.org/index.php/scenarios"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'model' %}
<p>
A model applies machine learning to limit the combinatorial
explosion.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/relative_reasoning"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'setting' %}
<p>
A setting includes configuration parameters for pathway
predictions.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/settings"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'user' %}
<p>
Register now to create own packages and to submit and manage your
data.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/users"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'group' %}
<p>
Users can team up in groups to share packages.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/groups"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% endif %}
<!-- If theres nothing to show extend the text above -->
{% if reviewed_objects and unreviewed_objects %}
{% if reviewed_objects|length == 0 and unreviewed_objects|length == 0 %}
<p class="mt-4">
Nothing found. There are two possible reasons: <br /><br />1.
There is no content yet.<br />2. You have no reading
permissions.<br /><br />Please be sure you have at least reading
permissions.
Nothing found. There are two possible reasons:<br /><br />
1. There is no content yet.<br />
2. You have no reading permissions.<br /><br />
Please ensure you have at least reading permissions.
</p>
{% endif %}
{% endif %}
@ -306,7 +121,7 @@
</div>
</div>
<!-- Lists Container - Full Width with Reviewed on Right -->
<!-- Lists Container -->
<div class="w-full">
{% if reviewed_objects %}
{% if reviewed_objects|length > 0 %}
@ -404,7 +219,7 @@
>
<input
type="checkbox"
{% if reviewed_objects|length == 0 or object_type == 'package' %}checked{% endif %}
{% if reviewed_objects|length == 0 %}checked{% endif %}
/>
<div class="collapse-title text-xl font-medium">
Unreviewed
@ -466,31 +281,6 @@
</div>
{% endif %}
</div>
{% if objects %}
<!-- Unreviewable objects such as User / Group / Setting -->
<div class="card bg-base-100">
<div class="card-body">
<ul class="menu bg-base-200 rounded-box w-full">
{% for obj in objects %}
{% if object_type == 'user' %}
<li>
<a href="{{ obj.url }}" class="hover:bg-base-300"
>{{ obj.username }}</a
>
</li>
{% else %}
<li>
<a href="{{ obj.url }}" class="hover:bg-base-300"
>{{ obj.name }}</a
>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</div>
<script>

View File

@ -0,0 +1,95 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Packages{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
id="new-package-button"
onclick="document.getElementById('new_package_modal').showModal(); return false;"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-folder-plus-icon lucide-folder-plus"
>
<path d="M12 10v6" />
<path d="M9 13h6" />
<path
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
/>
</svg>
</button>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm">
Import
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down ml-1"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-56 p-2"
>
<li>
<a
role="button"
onclick="document.getElementById('import_package_modal').showModal(); return false;"
>
Import Package from JSON
</a>
</li>
<li>
<a
role="button"
onclick="document.getElementById('import_legacy_package_modal').showModal(); return false;"
>
Import Package from legacy JSON
</a>
</li>
</ul>
</div>
</div>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_package_modal.html" %}
{% include "modals/collections/import_package_modal.html" %}
{% include "modals/collections/import_legacy_package_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>
A package contains pathways, rules, etc. and can reflect specific
experimental conditions.
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/packages"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,134 @@
{% extends "framework_modern.html" %}
{% load static %}
{# List title for empty text - defaults to "items", should be overridden by child templates #}
{% block list_title %}items{% endblock %}
{% block content %}
{% block action_modals %}
{% endblock action_modals %}
<div class="px-8 py-4">
<!-- Header Section -->
<div class="card bg-base-100">
<div class="card-body px-0 py-4">
<div class="flex items-center justify-between">
<h2 class="card-title text-2xl">
{% block page_title %}{{ page_title|default:"Items" }}{% endblock %}
</h2>
{% block action_button %}
{# Can be overridden by including action buttons for entity type #}
{% endblock %}
</div>
<div class="mt-2">
{% block description %}
{% endblock %}
</div>
</div>
</div>
{% if list_mode == "combined" %}
{# ===== COMBINED MODE: Single list without tabs ===== #}
<div
class="mt-6 w-full"
x-data="remotePaginatedList({
endpoint: '{{ api_endpoint }}',
instanceId: '{{ entity_type }}_combined',
perPage: {{ per_page|default:50 }}
})"
>
{% include "collections/_paginated_list_partial.html" with empty_text=list_title|default:"items" show_review_badge=True %}
</div>
{% else %}
{# ===== TABBED MODE: Reviewed/Unreviewed tabs (default) ===== #}
<div
class="mt-6 w-full"
x-data="{
activeTab: 'reviewed',
reviewedCount: 0,
unreviewedCount: 0,
reviewedLoaded: false,
unreviewedLoaded: false,
updateTabSelection() {
// Only auto-select unreviewed tab if both have loaded and there are no reviewed items
if (this.reviewedLoaded && this.unreviewedLoaded && this.reviewedCount === 0 && this.unreviewedCount > 0) {
this.activeTab = 'unreviewed';
}
}
}"
>
{# No items found message #}
<div
x-show="reviewedCount === 0 && unreviewedCount === 0"
class="text-base-content/70 py-8 text-center"
>
<p>No items found.</p>
</div>
{# Tabs Navigation #}
<div
role="tablist"
class="tabs tabs-border"
x-show="reviewedCount > 0 || unreviewedCount > 0"
>
<button
role="tab"
class="tab"
:class="{ 'tab-active': activeTab === 'reviewed' }"
@click="activeTab = 'reviewed'"
x-show="reviewedCount > 0"
>
Reviewed
<span
class="badge badge-xs badge-dash badge-info mb-2 ml-2"
x-text="reviewedCount"
></span>
</button>
<button
role="tab"
class="tab"
:class="{ 'tab-active': activeTab === 'unreviewed' }"
@click="activeTab = 'unreviewed'"
x-show="unreviewedCount > 0"
>
Unreviewed
<span
class="badge badge-xs badge-dash badge-info mb-2 ml-2"
x-text="unreviewedCount"
></span>
</button>
</div>
{# Reviewed Tab Content #}
<div
class="mt-6"
x-show="activeTab === 'reviewed' && (reviewedCount > 0 || unreviewedCount > 0)"
x-data="remotePaginatedList({
endpoint: '{{ api_endpoint }}?review_status=true',
instanceId: '{{ entity_type }}_reviewed',
isReviewed: true,
perPage: {{ per_page|default:50 }}
})"
@items-loaded="reviewedCount = totalItems; reviewedLoaded = true; updateTabSelection()"
>
{% include "collections/_paginated_list_partial.html" with empty_text="reviewed "|add:list_title|default:"items" show_review_badge=True always_show_badge=True %}
</div>
{# Unreviewed Tab Content #}
<div
class="mt-6"
x-show="activeTab === 'unreviewed' && (reviewedCount > 0 || unreviewedCount > 0)"
x-data="remotePaginatedList({
endpoint: '{{ api_endpoint }}?review_status=false',
instanceId: '{{ entity_type }}_unreviewed',
isReviewed: false,
perPage: {{ per_page|default:50 }}
})"
@items-loaded="unreviewedCount = totalItems; unreviewedLoaded = true; updateTabSelection()"
>
{% include "collections/_paginated_list_partial.html" with empty_text="unreviewed "|add:list_title|default:"items" %}
</div>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@ -0,0 +1,29 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Pathways{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<div class="flex items-center gap-2">
<a
class="btn btn-primary btn-sm"
href="{% if meta.current_package %}{{ meta.current_package.url }}/predict{% else %}/predict{% endif %}"
>
New Pathway
</a>
</div>
{% endif %}
{% endblock action_button %}
{% block description %}
<p>
A pathway displays the (predicted) biodegradation of a compound as graph.
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/pathways"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,33 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Reactions{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_reaction_modal').showModal(); return false;"
>
New Reaction
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_reaction_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>
A reaction is a specific biotransformation from educt compounds to product
compounds.
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/reactions"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,33 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Rules{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_rule_modal').showModal(); return false;"
>
New Rule
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_rule_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>
A rule describes a biotransformation reaction template that is defined as
SMIRKS.
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/Rules"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,33 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Scenarios{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_scenario_modal').showModal(); return false;"
>
New Scenario
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_scenario_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>
A scenario contains meta-information that can be attached to other data
(compounds, rules, ..).
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/scenarios"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,30 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}{{ page_title|default:"Structures" }}{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_compound_structure_modal').showModal(); return false;"
>
New Structure
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{# FIXME: New Compound Structure Modal #}
{% endblock action_modals %}
{% block description %}
<p>The structures stored in this compound.</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/compounds"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}