Skip to content

Commit 0f996e0

Browse files
committed
feat: add HTMX support for related packages loading
1 parent 107fcde commit 0f996e0

8 files changed

Lines changed: 216 additions & 5 deletions

File tree

ckanext/relationship/config_declaration.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
version: 1
22
groups:
3-
- annotation: ckanext-mbie-erms-shared
3+
- annotation: ckanext-relationship
44
options:
55
- key: ckanext.relationship.views_without_relationships_in_package_show
66
type: list

ckanext/relationship/helpers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
import ckan.plugins.toolkit as tk
77
from ckan import authz
8-
from ckan.lib.search.query import solr_literal
8+
9+
from ckanext.relationship import utils
910

1011

1112
def get_helpers():
@@ -104,7 +105,7 @@ def relationship_get_selected_json(selected_ids: list[str] | None = None) -> str
104105
search = tk.get_action("package_search")
105106
rows = 100
106107
start = 0
107-
fq = "id:({})".format(" OR ".join(map(solr_literal, selected_ids)))
108+
fq = utils.build_fq_for_object_ids(selected_ids)
108109
while True:
109110
result = search(
110111
{},
@@ -114,6 +115,7 @@ def relationship_get_selected_json(selected_ids: list[str] | None = None) -> str
114115
"rows": rows,
115116
"start": start,
116117
"fl": "id,name,title",
118+
"sort": "title_string asc, name asc, id asc",
117119
},
118120
)
119121
selected_pkgs.extend(
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{#
2+
related_packages_batch.html
3+
Purpose: HTMX response that appends <li> items into an existing
4+
<ul class="dataset-list"> and replaces the sentinel <li>
5+
to enable infinite scrolling.
6+
7+
Required vars:
8+
- packages: list[dict] # CKAN package dicts to render
9+
- has_more: bool # more pages available?
10+
- next_start: int # next offset
11+
- page_size: int # rows per page
12+
- pkg_id: str # subject package id (for DOM ids)
13+
- object_type: str # relation object type (for DOM ids)
14+
- relation_type: str # relation type (for DOM ids)
15+
16+
Optional vars (passed to package_item.html if present):
17+
- item_class: str
18+
- hide_resources: bool
19+
- banner, truncate, truncate_title, search_page: any
20+
#}
21+
22+
23+
{% set ns = pkg_id ~ '-' ~ object_type ~ '-' ~ relation_type %}
24+
{% set spinner_id = 'spinner-' ~ ns %}
25+
{% set sentinel_id = 'rel-sentinel-' ~ ns %}
26+
27+
{% if packages and packages|length %}
28+
{% for package in packages %}
29+
{% snippet 'snippets/package_item.html', package=package %}
30+
{% endfor %}
31+
{% endif %}
32+
33+
{% if has_more %}
34+
<li id="{{ sentinel_id }}"
35+
style="height:1px; width:100%;"
36+
hx-get="{{ h.url_for('relationships.related_batch') }}?pkg_id={{ pkg_id }}&object_type={{ object_type }}&relation_type={{ relation_type }}&start={{ next_start }}&size={{ page_size }}"
37+
hx-trigger="revealed"
38+
hx-target="this"
39+
hx-swap="outerHTML"
40+
hx-indicator="#{{ spinner_id }}">
41+
</li>
42+
{% endif %}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{#
2+
related_packages_list.html
3+
Purpose: Standalone shell that renders the target <ul> and spinner,
4+
then kicks off the first HTMX request.
5+
6+
Required vars:
7+
- pkg_id: str
8+
- object_type: str
9+
- relation_type: str
10+
11+
Optional vars:
12+
- page_size: int = 20 # initial batch size
13+
- trigger: str = "load" # e.g. "load" or "revealed once"
14+
#}
15+
16+
17+
{% set ns = pkg_id ~ '-' ~ object_type ~ '-' ~ relation_type %}
18+
19+
<ul id="rel-list-{{ ns }}"
20+
class="dataset-list list-unstyled"
21+
hx-get="{{ h.url_for('relationships.related_batch') }}?pkg_id={{ pkg_id }}&object_type={{ object_type }}&relation_type={{ relation_type }}&start=0&size={{ page_size|default(20) }}"
22+
hx-trigger="load"
23+
hx-swap="innerHTML"
24+
hx-target="this"
25+
hx-indicator="#spinner-{{ ns }}">
26+
</ul>
27+
28+
<div id="spinner-{{ ns }}" class="htmx-indicator">
29+
{% snippet 'snippets/relationship_spinner.html' %}
30+
</div>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<style>
2+
.htmx-indicator {
3+
display: none;
4+
}
5+
6+
.htmx-request .htmx-indicator,
7+
.htmx-indicator.htmx-request {
8+
display: flex;
9+
align-items: center;
10+
justify-content: center;
11+
gap: .5rem;
12+
width: 100%;
13+
margin-top: 0.5rem;
14+
}
15+
16+
.relationship-spin {
17+
width: 1.6rem;
18+
height: 1.6rem;
19+
border-radius: 50%;
20+
border: 3px solid rgba(0, 0, 0, .2);
21+
border-top-color: rgba(0, 0, 0, .6);
22+
animation: erm-rot .6s linear infinite;
23+
}
24+
25+
@keyframes erm-rot {
26+
to {
27+
transform: rotate(360deg);
28+
}
29+
}
30+
31+
.sr-only {
32+
position: absolute;
33+
width: 1px;
34+
height: 1px;
35+
padding: 0;
36+
margin: -1px;
37+
overflow: hidden;
38+
clip: rect(0, 0, 0, 0);
39+
white-space: nowrap;
40+
border: 0;
41+
}
42+
</style>
43+
44+
<span class="relationship-spin" aria-hidden="true"></span>
45+
<span class="sr-only">Loading…</span>

ckanext/relationship/utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

3+
import uuid
34
from typing import Any, cast
45

56
import ckan.plugins.toolkit as tk
7+
from ckan.lib.search.query import solr_literal
68
from ckan.logic import NotFound
79

810
import ckanext.scheming.helpers as sch
@@ -67,3 +69,27 @@ def entity_name_by_id(entity_id: str) -> str | None:
6769
except NotFound:
6870
pass
6971
return None
72+
73+
74+
def _is_uuid(v: str) -> bool:
75+
"""Check if a string is a valid UUID."""
76+
try:
77+
uuid.UUID(v)
78+
except ValueError:
79+
return False
80+
else:
81+
return True
82+
83+
84+
def build_fq_for_object_ids(objs: list[str]) -> str:
85+
"""Builds a Solr filter query (fq) for a list of object IDs or names."""
86+
if not objs:
87+
return "id:__none__"
88+
ids = [obj for obj in objs if _is_uuid(obj)]
89+
names = [obj.lower() for obj in objs if not _is_uuid(obj)]
90+
parts = []
91+
if ids:
92+
parts.append("id:(" + " OR ".join(map(solr_literal, ids)) + ")")
93+
if names:
94+
parts.append("name:(" + " OR ".join(map(solr_literal, names)) + ")")
95+
return "(" + " OR ".join(parts) + ")" if parts else "id:__none__"

ckanext/relationship/views.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1-
from flask import Blueprint
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from flask import Blueprint, make_response
26

37
import ckan.plugins.toolkit as tk
48

9+
from ckanext.relationship import utils
10+
11+
BATCH_SIZE_DEFAULT = 20
12+
513

614
def get_blueprints():
715
return [
@@ -29,3 +37,61 @@ def relationships_autocomplete():
2937
),
3038
},
3139
)
40+
41+
42+
def _search_packages_page(
43+
context: dict[str, Any], fq: str, start: int, rows: int
44+
) -> tuple[list[dict[str, Any]], int]:
45+
"""Searches for packages using the package_search action."""
46+
data = {
47+
"q": "*:*",
48+
"fq": fq,
49+
"start": start,
50+
"rows": rows,
51+
"include_private": True,
52+
"sort": "title_string asc, name asc, id asc",
53+
}
54+
out = tk.get_action("package_search")({}, data) or {}
55+
return out.get("results", []), out.get("count", 0)
56+
57+
58+
@relationships.route("/relationship/section")
59+
def related_batch():
60+
"""Renders a section of related packages."""
61+
pkg_id = tk.request.args["pkg_id"]
62+
object_type = tk.request.args["object_type"]
63+
relation_type = tk.request.args["relation_type"]
64+
start = int(tk.request.args.get("start", 0))
65+
page_size = int(tk.request.args.get("size", BATCH_SIZE_DEFAULT))
66+
67+
object_ids = tk.get_action("relationship_relations_ids_list")(
68+
{},
69+
{
70+
"subject_id": pkg_id,
71+
"object_entity": "package",
72+
"object_type": object_type,
73+
"relation_type": relation_type,
74+
},
75+
)
76+
77+
if not object_ids:
78+
return ("", 204)
79+
80+
fq = utils.build_fq_for_object_ids(object_ids)
81+
packages, total = _search_packages_page({}, fq, start=start, rows=page_size)
82+
if not packages and start == 0:
83+
return ("", 204)
84+
85+
html = tk.render(
86+
"snippets/relationship_related_batch.html",
87+
extra_vars={
88+
"packages": packages,
89+
"has_more": (start + page_size) < total,
90+
"next_start": start + page_size,
91+
"page_size": page_size,
92+
"pkg_id": pkg_id,
93+
"object_type": object_type,
94+
"relation_type": relation_type,
95+
},
96+
)
97+
return make_response(html)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "ckanext-relationship"
7-
version = "0.2.10"
7+
version = "0.2.11"
88
description = ""
99
classifiers = [ "Development Status :: 4 - Beta", "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10",]
1010
keywords = [ "CKAN",]

0 commit comments

Comments
 (0)