Preserve page and scroll position when editing tags (#1291)

This commit is contained in:
Sascha Ißbrücker
2026-01-25 11:07:30 +01:00
committed by GitHub
parent d52caefe2c
commit 1f0a2201ba
10 changed files with 142 additions and 59 deletions

View File

@@ -3,6 +3,8 @@
<div>No bookmarks match the current bundle.</div> <div>No bookmarks match the current bundle.</div>
{% else %} {% else %}
<div class="mb-4">Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.</div> <div class="mb-4">Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.</div>
{% include 'bookmarks/bookmark_list.html' %} {% with pagination_frame="preview" %}
{% include 'bookmarks/bookmark_list.html' %}
{% endwith %}
{% endif %} {% endif %}
</turbo-frame> </turbo-frame>

View File

@@ -2,7 +2,9 @@
<ul class="pagination"> <ul class="pagination">
{% if prev_link %} {% if prev_link %}
<li class="page-item"> <li class="page-item">
<a href="{{ prev_link }}" tabindex="-1">Previous</a> <a href="{{ prev_link }}"
tabindex="-1"
data-turbo-frame="{{ pagination_frame }}">Previous</a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -12,7 +14,7 @@
{% for page_link in page_links %} {% for page_link in page_links %}
{% if page_link %} {% if page_link %}
<li class="page-item {% if page_link.active %}active{% endif %}"> <li class="page-item {% if page_link.active %}active{% endif %}">
<a href="{{ page_link.link }}">{{ page_link.number }}</a> <a href="{{ page_link.link }}" data-turbo-frame="{{ pagination_frame }}">{{ page_link.number }}</a>
</li> </li>
{% else %} {% else %}
<li class="page-item"> <li class="page-item">
@@ -22,7 +24,9 @@
{% endfor %} {% endfor %}
{% if next_link %} {% if next_link %}
<li class="page-item"> <li class="page-item">
<a href="{{ next_link }}" tabindex="-1">Next</a> <a href="{{ next_link }}"
tabindex="-1"
data-turbo-frame="{{ pagination_frame }}">Next</a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

@@ -1,11 +1,11 @@
<turbo-frame id="tag-modal"> <turbo-frame id="tag-modal">
<form method="post" <form method="post"
action="{% url 'linkding:tags.edit' tag.id %}" action="{% url 'linkding:tags.edit' tag.id %}?{{ request.GET.urlencode }}"
data-turbo-frame="_top" data-turbo-frame="tag-main"
novalidate> novalidate>
{% csrf_token %} {% csrf_token %}
<ld-modal class="modal tag-edit-modal active" <ld-modal class="modal tag-edit-modal active"
data-close-url="{% url 'linkding:tags.index' %}" data-close-url="{% url 'linkding:tags.index' %}?{{ request.GET.urlencode }}"
data-turbo-frame="tag-modal"> data-turbo-frame="tag-modal">
<div class="modal-overlay" data-close-modal></div> <div class="modal-overlay" data-close-modal></div>
<div class="modal-container" role="dialog" aria-modal="true"> <div class="modal-container" role="dialog" aria-modal="true">

View File

@@ -5,6 +5,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="tags-page crud-page"> <div class="tags-page crud-page">
<turbo-frame id="tag-main">
<main aria-labelledby="main-heading"> <main aria-labelledby="main-heading">
<div class="crud-header"> <div class="crud-header">
<h1 id="main-heading">Tags</h1> <h1 id="main-heading">Tags</h1>
@@ -97,7 +98,7 @@
</td> </td>
<td class="actions"> <td class="actions">
<a class="btn btn-link" <a class="btn btn-link"
href="{% url 'linkding:tags.edit' tag.id %}" href="{% url 'linkding:tags.edit' tag.id %}?{{ request.GET.urlencode }}"
data-turbo-frame="tag-modal">Edit</a> data-turbo-frame="tag-modal">Edit</a>
<button type="submit" <button type="submit"
name="delete_tag" name="delete_tag"
@@ -123,6 +124,7 @@
</div> </div>
{% endif %} {% endif %}
</main> </main>
<turbo-frame id="tag-modal"></turbo-frame>
</turbo-frame>
</div> </div>
<turbo-frame id="tag-modal"></turbo-frame>
{% endblock %} {% endblock %}

View File

@@ -12,6 +12,7 @@ register = template.Library()
@register.inclusion_tag("shared/pagination.html", name="pagination", takes_context=True) @register.inclusion_tag("shared/pagination.html", name="pagination", takes_context=True)
def pagination(context, page: Page): def pagination(context, page: Page):
request = context["request"] request = context["request"]
pagination_frame = context.get("pagination_frame", "_top")
base_url = request.path base_url = request.path
# remove page number and details from query parameters # remove page number and details from query parameters
@@ -51,6 +52,7 @@ def pagination(context, page: Page):
"prev_link": prev_link, "prev_link": prev_link,
"next_link": next_link, "next_link": next_link,
"page_links": page_links, "page_links": page_links,
"pagination_frame": pagination_frame,
} }

View File

@@ -7,7 +7,12 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
class PaginationTagTest(TestCase, BookmarkFactoryMixin): class PaginationTagTest(TestCase, BookmarkFactoryMixin):
def render_template( def render_template(
self, num_items: int, page_size: int, current_page: int, url: str = "/test" self,
num_items: int,
page_size: int,
current_page: int,
url: str = "/test",
frame: str = None,
) -> str: ) -> str:
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) request = rf.get(url)
@@ -16,7 +21,11 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
paginator = Paginator(range(0, num_items), page_size) paginator = Paginator(range(0, num_items), page_size)
page = paginator.page(current_page) page = paginator.page(current_page)
context = RequestContext(request, {"page": page}) context_dict = {"page": page}
if frame:
context_dict["pagination_frame"] = frame
context = RequestContext(request, context_dict)
template_to_render = Template("{% load pagination %}{% pagination page %}") template_to_render = Template("{% load pagination %}{% pagination page %}")
return template_to_render.render(context) return template_to_render.render(context)
@@ -30,12 +39,14 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
html, html,
) )
def assertPrevLink(self, html: str, page_number: int, href: str = None): def assertPrevLink(
self, html: str, page_number: int, href: str = None, frame: str = "_top"
):
href = href if href else f"/test?page={page_number}" href = href if href else f"/test?page={page_number}"
self.assertInHTML( self.assertInHTML(
f""" f"""
<li class="page-item"> <li class="page-item">
<a href="{href}" tabindex="-1">Previous</a> <a href="{href}" tabindex="-1" data-turbo-frame="{frame}">Previous</a>
</li> </li>
""", """,
html, html,
@@ -51,12 +62,14 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
html, html,
) )
def assertNextLink(self, html: str, page_number: int, href: str = None): def assertNextLink(
self, html: str, page_number: int, href: str = None, frame: str = "_top"
):
href = href if href else f"/test?page={page_number}" href = href if href else f"/test?page={page_number}"
self.assertInHTML( self.assertInHTML(
f""" f"""
<li class="page-item"> <li class="page-item">
<a href="{href}" tabindex="-1">Next</a> <a href="{href}" tabindex="-1" data-turbo-frame="{frame}">Next</a>
</li> </li>
""", """,
html, html,
@@ -69,13 +82,14 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
active: bool, active: bool,
count: int = 1, count: int = 1,
href: str = None, href: str = None,
frame: str = "_top",
): ):
active_class = "active" if active else "" active_class = "active" if active else ""
href = href if href else f"/test?page={page_number}" href = href if href else f"/test?page={page_number}"
self.assertInHTML( self.assertInHTML(
f""" f"""
<li class="page-item {active_class}"> <li class="page-item {active_class}">
<a href="{href}">{page_number}</a> <a href="{href}" data-turbo-frame="{frame}">{page_number}</a>
</li> </li>
""", """,
html, html,
@@ -188,3 +202,10 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
self.assertPageLink(rendered_template, 1, False, href="/test?page=1") self.assertPageLink(rendered_template, 1, False, href="/test?page=1")
self.assertPageLink(rendered_template, 2, True, href="/test?page=2") self.assertPageLink(rendered_template, 2, True, href="/test?page=2")
self.assertNextLink(rendered_template, 3, href="/test?page=3") self.assertNextLink(rendered_template, 3, href="/test?page=3")
def test_respects_pagination_frame(self):
rendered_template = self.render_template(100, 10, 2, frame="my_frame")
self.assertPrevLink(rendered_template, 1, frame="my_frame")
self.assertPageLink(rendered_template, 1, False, frame="my_frame")
self.assertPageLink(rendered_template, 2, True, frame="my_frame")
self.assertNextLink(rendered_template, 3, frame="my_frame")

View File

@@ -90,20 +90,17 @@ class TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):
tag2.refresh_from_db() tag2.refresh_from_db()
self.assertEqual(tag2.name, "tag1") self.assertEqual(tag2.name, "tag1")
def test_update_shows_success_message(self): def test_update_tag_preserves_query_parameters(self):
tag = self.setup_tag(name="old_name") tag = self.setup_tag(name="old_name")
response = self.client.post( url = (
reverse("linkding:tags.edit", args=[tag.id]), reverse("linkding:tags.edit", args=[tag.id])
{"name": "new_name"}, + "?search=search&unused=true&page=2&sort=name-desc"
follow=True,
) )
response = self.client.post(url, {"name": "new_name"})
self.assertInHTML( expected_redirect = (
""" reverse("linkding:tags.index")
<div class="toast toast-success" role="alert"> + "?search=search&unused=true&page=2&sort=name-desc"
Tag "new_name" updated successfully.
</div>
""",
response.content.decode(),
) )
self.assertRedirects(response, expected_redirect)

View File

@@ -153,24 +153,6 @@ class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertRedirects(response, reverse("linkding:tags.index")) self.assertRedirects(response, reverse("linkding:tags.index"))
self.assertFalse(Tag.objects.filter(id=tag.id).exists()) self.assertFalse(Tag.objects.filter(id=tag.id).exists())
def test_tag_delete_action_shows_success_message(self):
tag = self.setup_tag(name="tag_to_delete")
response = self.client.post(
reverse("linkding:tags.index"), {"delete_tag": tag.id}, follow=True
)
self.assertEqual(response.status_code, 200)
self.assertInHTML(
"""
<div class="toast toast-success" role="alert">
Tag "tag_to_delete" deleted successfully.
</div>
""",
response.content.decode(),
)
def test_tag_delete_action_preserves_query_parameters(self): def test_tag_delete_action_preserves_query_parameters(self):
tag = self.setup_tag(name="search_tag") tag = self.setup_tag(name="search_tag")

View File

@@ -113,9 +113,6 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
# Verify modal is closed # Verify modal is closed
expect(modal).not_to_be_visible() expect(modal).not_to_be_visible()
# Verify the success message is shown
self.verify_success_message('Tag "new-name" updated successfully.')
# Verify the updated tag is shown in the list # Verify the updated tag is shown in the list
expect(self.locate_tag_row("new-name")).to_be_visible() expect(self.locate_tag_row("new-name")).to_be_visible()
expect(self.locate_tag_row("old-name")).not_to_be_visible() expect(self.locate_tag_row("old-name")).not_to_be_visible()
@@ -157,6 +154,89 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
tag.refresh_from_db() tag.refresh_from_db()
self.assertEqual(tag.name, "tag-to-edit") self.assertEqual(tag.name, "tag-to-edit")
def test_edit_tag_preserves_query_and_scroll_position(self):
# Create enough tags to have multiple pages (50 per page)
for i in range(70):
self.setup_tag(name=f"test-tag-{i:02d}")
# Open tags page 2 with search query
url = reverse("linkding:tags.index") + "?search=test&page=2"
self.open(url)
# Verify we're on page 2
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
# Scroll down
self.page.evaluate("window.scrollTo(0, 300)")
initial_scroll = self.page.evaluate("window.scrollY")
self.assertGreater(initial_scroll, 0)
# Edit tag
tag_row = self.locate_tag_row("test-tag-55")
tag_row.get_by_role("link", name="Edit").click()
modal = self.locate_tag_modal()
name_input = modal.get_by_label("Name")
name_input.fill("test-tag-55-edited")
modal.get_by_text("Save").click()
expect(modal).not_to_be_visible()
# Verify query parameters and scroll position are preserved
current_url = self.page.url
self.assertIn("search=test", current_url)
self.assertIn("page=2", current_url)
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
expect(self.locate_tag_row("test-tag-55-edited")).to_be_visible()
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
final_scroll = self.page.evaluate("window.scrollY")
self.assertEqual(initial_scroll, final_scroll)
def test_delete_tag_preserves_query_and_scroll_position(self):
# Create enough tags to have multiple pages (50 per page)
for i in range(70):
self.setup_tag(name=f"test-tag-{i:02d}")
# Open tags page 2 with search query
url = reverse("linkding:tags.index") + "?search=test&page=2"
self.open(url)
# Verify we're on page 2
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
expect(self.locate_tag_row("test-tag-55")).to_be_visible()
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
# Scroll down
self.page.evaluate("window.scrollTo(0, 300)")
initial_scroll = self.page.evaluate("window.scrollY")
self.assertGreater(initial_scroll, 0)
# Delete tag
tag_row = self.locate_tag_row("test-tag-55")
tag_row.get_by_role("button", name="Remove").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
# Verify query parameters and scroll position are preserved
current_url = self.page.url
self.assertIn("search=test", current_url)
self.assertIn("page=2", current_url)
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
expect(self.locate_tag_row("test-tag-55")).not_to_be_visible()
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
final_scroll = self.page.evaluate("window.scrollY")
self.assertEqual(initial_scroll, final_scroll)
def test_merge_tags(self): def test_merge_tags(self):
target_tag = self.setup_tag(name="target-tag") target_tag = self.setup_tag(name="target-tag")
merge_tag1 = self.setup_tag(name="merge-tag1") merge_tag1 = self.setup_tag(name="merge-tag1")

View File

@@ -10,6 +10,7 @@ from django.urls import reverse
from bookmarks.forms import TagForm, TagMergeForm from bookmarks.forms import TagForm, TagMergeForm
from bookmarks.models import Bookmark, Tag from bookmarks.models import Bookmark, Tag
from bookmarks.type_defs import HttpRequest from bookmarks.type_defs import HttpRequest
from bookmarks.utils import redirect_with_query
from bookmarks.views import turbo from bookmarks.views import turbo
@@ -18,15 +19,8 @@ def tags_index(request: HttpRequest):
if request.method == "POST" and "delete_tag" in request.POST: if request.method == "POST" and "delete_tag" in request.POST:
tag_id = request.POST.get("delete_tag") tag_id = request.POST.get("delete_tag")
tag = get_object_or_404(Tag, id=tag_id, owner=request.user) tag = get_object_or_404(Tag, id=tag_id, owner=request.user)
tag_name = tag.name
tag.delete() tag.delete()
messages.success(request, f'Tag "{tag_name}" deleted successfully.') return redirect_with_query(request, reverse("linkding:tags.index"))
redirect_url = reverse("linkding:tags.index")
if request.GET:
redirect_url += "?" + request.GET.urlencode()
return HttpResponseRedirect(redirect_url)
search = request.GET.get("search", "").strip() search = request.GET.get("search", "").strip()
unused_only = request.GET.get("unused", "") == "true" unused_only = request.GET.get("unused", "") == "true"
@@ -100,8 +94,7 @@ def tag_edit(request: HttpRequest, tag_id: int):
if request.method == "POST": if request.method == "POST":
if form.is_valid(): if form.is_valid():
form.save() form.save()
messages.success(request, f'Tag "{tag.name}" updated successfully.') return redirect_with_query(request, reverse("linkding:tags.index"))
return HttpResponseRedirect(reverse("linkding:tags.index"))
else: else:
return turbo.stream( return turbo.stream(
turbo.replace( turbo.replace(