+ {% include 'shared/modal_header.html' with title="Merge Tags" %}
+
+
+
+ How to merge tags
+
+
+ - Enter the name of the tag you want to keep
+ - Enter the names of tags to merge into the target tag
+ - The target tag is added to all bookmarks that have any of the merge tags
+ - The merged tags are deleted
+
+
-{% block content %}
-
-
-
+
-
-
-
- How to merge tags
-
-
- - Enter the name of the tag you want to keep
- - Enter the names of tags to merge into the target tag
- - The target tag is added to all bookmarks that have any of the merge tags
- - The merged tags are deleted
-
-
-
-
-
-
-{% endblock %}
+
+
+
diff --git a/bookmarks/templates/tags/new.html b/bookmarks/templates/tags/new.html
index 8b6abbb..a2ac700 100644
--- a/bookmarks/templates/tags/new.html
+++ b/bookmarks/templates/tags/new.html
@@ -1,23 +1,19 @@
-{% extends "bookmarks/layout.html" %}
-{% load shared %}
-
-{% block head %}
- {% with page_title="Add tag - Linkding" %}
- {{ block.super }}
- {% endwith %}
-{% endblock %}
-
-{% block content %}
-
-
-
-{% endblock %}
+
+
+
diff --git a/bookmarks/tests/test_tags_merge_view.py b/bookmarks/tests/test_tags_merge_view.py
index 50e70f1..7d67d07 100644
--- a/bookmarks/tests/test_tags_merge_view.py
+++ b/bookmarks/tests/test_tags_merge_view.py
@@ -1,3 +1,5 @@
+from bs4 import TemplateString
+from bs4.element import NavigableString, CData
from django.test import TestCase
from django.urls import reverse
@@ -10,6 +12,11 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.user = self.get_or_create_test_user()
self.client.force_login(self.user)
+ def get_text(self, element):
+ # Invalid form responses are wrapped in tags, which BeautifulSoup
+ # treats as TemplateString objects. Include those when extracting text.
+ return element.get_text(types=(NavigableString, CData, TemplateString))
+
def get_form_group(self, response, input_name):
soup = self.make_soup(response.content.decode())
input_element = soup.find("input", {"name": input_name})
@@ -109,10 +116,12 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag")
- self.assertIn('Tag "target_tag" does not exist', target_tag_group.get_text())
+ self.assertIn(
+ 'Tag "target_tag" does not exist', self.get_text(target_tag_group)
+ )
merge_tags_group = self.get_form_group(response, "merge_tags")
- self.assertIn('Tag "merge_tag" does not exist', merge_tags_group.get_text())
+ self.assertIn('Tag "merge_tag" does not exist', self.get_text(merge_tags_group))
def test_validate_missing_target_tag(self):
merge_tag = self.setup_tag(name="merge_tag")
@@ -125,7 +134,7 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag")
- self.assertIn("This field is required", target_tag_group.get_text())
+ self.assertIn("This field is required", self.get_text(target_tag_group))
self.assertTrue(Tag.objects.filter(id=merge_tag.id).exists())
def test_validate_missing_merge_tags(self):
@@ -138,7 +147,7 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags")
- self.assertIn("This field is required", merge_tags_group.get_text())
+ self.assertIn("This field is required", self.get_text(merge_tags_group))
def test_validate_nonexistent_target_tag(self):
merge_tag = self.setup_tag(name="merge_tag")
@@ -152,7 +161,7 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn(
- 'Tag "nonexistent_tag" does not exist', target_tag_group.get_text()
+ 'Tag "nonexistent_tag" does not exist', self.get_text(target_tag_group)
)
def test_validate_nonexistent_merge_tag(self):
@@ -167,7 +176,7 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn(
- 'Tag "nonexistent_tag" does not exist', merge_tags_group.get_text()
+ 'Tag "nonexistent_tag" does not exist', self.get_text(merge_tags_group)
)
def test_validate_multiple_target_tags(self):
@@ -184,7 +193,7 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn(
"Please enter only one tag name for the target tag",
- target_tag_group.get_text(),
+ self.get_text(target_tag_group),
)
def test_validate_target_tag_in_merge_list(self):
@@ -200,7 +209,8 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn(
- "The target tag cannot be selected for merging", merge_tags_group.get_text()
+ "The target tag cannot be selected for merging",
+ self.get_text(merge_tags_group),
)
def test_merge_shows_success_message(self):
diff --git a/bookmarks/tests_e2e/e2e_test_tag_management.py b/bookmarks/tests_e2e/e2e_test_tag_management.py
new file mode 100644
index 0000000..628fb8c
--- /dev/null
+++ b/bookmarks/tests_e2e/e2e_test_tag_management.py
@@ -0,0 +1,262 @@
+from django.urls import reverse
+from playwright.sync_api import sync_playwright, expect
+
+from bookmarks.models import Tag
+from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
+
+
+class TagManagementE2ETestCase(LinkdingE2ETestCase):
+ def locate_tag_modal(self):
+ modal = self.page.locator("ld-modal.tag-edit-modal")
+ expect(modal).to_be_visible()
+ return modal
+
+ def locate_merge_modal(self):
+ modal = self.page.locator("ld-modal").filter(has_text="Merge Tags")
+ expect(modal).to_be_visible()
+ return modal
+
+ def locate_tag_row(self, name: str):
+ return self.page.locator("table.crud-table tbody tr").filter(has_text=name)
+
+ def verify_success_message(self, text: str):
+ success_message = self.page.locator(".toast.toast-success")
+ expect(success_message).to_be_visible()
+ expect(success_message).to_contain_text(text)
+
+ def test_create_tag(self):
+ with sync_playwright() as p:
+ self.open(reverse("linkding:tags.index"), p)
+
+ # Click the Create Tag button to open the modal
+ self.page.get_by_text("Create Tag").click()
+
+ modal = self.locate_tag_modal()
+
+ # Fill in a tag name
+ name_input = modal.get_by_label("Name")
+ name_input.fill("test-tag")
+
+ # Submit the form
+ modal.get_by_text("Save").click()
+
+ # Verify modal is closed and we're back on the tags page
+ expect(modal).not_to_be_visible()
+
+ # Verify the success message is shown
+ self.verify_success_message('Tag "test-tag" created successfully.')
+
+ # Verify the new tag is shown in the list
+ tag_row = self.locate_tag_row("test-tag")
+ expect(tag_row).to_be_visible()
+
+ # Verify the tag was actually created in the database
+ self.assertEqual(
+ Tag.objects.filter(owner=self.get_or_create_test_user()).count(), 1
+ )
+ tag = Tag.objects.get(owner=self.get_or_create_test_user())
+ self.assertEqual(tag.name, "test-tag")
+
+ def test_create_tag_validation_error(self):
+ existing_tag = self.setup_tag(name="existing-tag")
+
+ with sync_playwright() as p:
+ self.open(reverse("linkding:tags.index"), p)
+
+ # Click the Create Tag button to open the modal
+ self.page.get_by_text("Create Tag").click()
+
+ modal = self.locate_tag_modal()
+
+ # Submit with empty value
+ modal.get_by_text("Save").click()
+
+ # Verify the error is shown (field is required)
+ error_hint = modal.get_by_text("This field is required")
+ expect(error_hint).to_be_visible()
+
+ # Fill in the name of an existing tag
+ name_input = modal.get_by_label("Name")
+ name_input.fill(existing_tag.name)
+
+ # Submit the form
+ modal.get_by_text("Save").click()
+
+ # Verify the error is shown (tag already exists)
+ error_hint = modal.get_by_text('Tag "existing-tag" already exists')
+ expect(error_hint).to_be_visible()
+
+ # Verify no additional tag was created
+ self.assertEqual(
+ Tag.objects.filter(owner=self.get_or_create_test_user()).count(), 1
+ )
+
+ def test_edit_tag(self):
+ tag = self.setup_tag(name="old-name")
+
+ with sync_playwright() as p:
+ self.open(reverse("linkding:tags.index"), p)
+
+ # Click the Edit button for the tag
+ tag_row = self.locate_tag_row(tag.name)
+ tag_row.get_by_role("link", name="Edit").click()
+
+ modal = self.locate_tag_modal()
+
+ # Verify the form is pre-filled with the tag name
+ name_input = modal.get_by_label("Name")
+ expect(name_input).to_have_value(tag.name)
+
+ # Change the tag name
+ name_input.fill("new-name")
+
+ # Submit the form
+ modal.get_by_text("Save").click()
+
+ # Verify modal is closed
+ 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
+ expect(self.locate_tag_row("new-name")).to_be_visible()
+ expect(self.locate_tag_row("old-name")).not_to_be_visible()
+
+ # Verify the tag was updated in the database
+ tag.refresh_from_db()
+ self.assertEqual(tag.name, "new-name")
+
+ def test_edit_tag_validation_error(self):
+ tag = self.setup_tag(name="tag-to-edit")
+ other_tag = self.setup_tag(name="other-tag")
+
+ with sync_playwright() as p:
+ self.open(reverse("linkding:tags.index"), p)
+
+ # Click the Edit button for the tag
+ tag_row = self.locate_tag_row(tag.name)
+ tag_row.get_by_role("link", name="Edit").click()
+
+ modal = self.locate_tag_modal()
+
+ # Clear the name and submit
+ name_input = modal.get_by_label("Name")
+ name_input.fill("")
+ modal.get_by_text("Save").click()
+
+ # Verify the error is shown (field is required)
+ error_hint = modal.get_by_text("This field is required")
+ expect(error_hint).to_be_visible()
+
+ # Fill in the name of another existing tag
+ name_input.fill(other_tag.name)
+ modal.get_by_text("Save").click()
+
+ # Verify the error is shown (tag already exists)
+ error_hint = modal.get_by_text('Tag "other-tag" already exists')
+ expect(error_hint).to_be_visible()
+
+ # Verify the tag was not modified
+ tag.refresh_from_db()
+ self.assertEqual(tag.name, "tag-to-edit")
+
+ def test_merge_tags(self):
+ target_tag = self.setup_tag(name="target-tag")
+ merge_tag1 = self.setup_tag(name="merge-tag1")
+ merge_tag2 = self.setup_tag(name="merge-tag2")
+
+ # Create bookmarks with the merge tags
+ bookmark1 = self.setup_bookmark(tags=[merge_tag1])
+ bookmark2 = self.setup_bookmark(tags=[merge_tag2])
+
+ with sync_playwright() as p:
+ self.open(reverse("linkding:tags.index"), p)
+
+ # Click the Merge Tags button to open the modal
+ self.page.get_by_text("Merge Tags", exact=True).click()
+
+ modal = self.locate_merge_modal()
+
+ # Fill in the target tag
+ target_input = modal.get_by_label("Target tag")
+ target_input.fill(target_tag.name)
+
+ # Fill in the tags to merge
+ merge_input = modal.get_by_label("Tags to merge")
+ merge_input.fill(f"{merge_tag1.name} {merge_tag2.name}")
+
+ # Submit the form
+ modal.get_by_role("button", name="Merge Tags").click()
+
+ # Verify modal is closed
+ expect(modal).not_to_be_visible()
+
+ # Verify the success message is shown
+ self.verify_success_message(
+ 'Successfully merged 2 tags (merge-tag1, merge-tag2) into "target-tag".'
+ )
+
+ # Verify the merged tags are no longer in the list
+ expect(self.locate_tag_row("target-tag")).to_be_visible()
+ expect(self.locate_tag_row("merge-tag1")).not_to_be_visible()
+ expect(self.locate_tag_row("merge-tag2")).not_to_be_visible()
+
+ # Verify the merge tags were deleted
+ self.assertEqual(
+ Tag.objects.filter(owner=self.get_or_create_test_user()).count(), 1
+ )
+
+ # Verify bookmarks only have the target tag
+ bookmark1.refresh_from_db()
+ bookmark2.refresh_from_db()
+ self.assertCountEqual([target_tag], bookmark1.tags.all())
+ self.assertCountEqual([target_tag], bookmark2.tags.all())
+
+ def test_merge_tags_validation_error(self):
+ target_tag = self.setup_tag(name="target-tag")
+ merge_tag = self.setup_tag(name="merge-tag")
+
+ with sync_playwright() as p:
+ self.open(reverse("linkding:tags.index"), p)
+
+ # Click the Merge Tags button to open the modal
+ self.page.get_by_text("Merge Tags", exact=True).click()
+
+ modal = self.locate_merge_modal()
+
+ # Submit with empty values
+ modal.get_by_role("button", name="Merge Tags").click()
+
+ # Verify the errors are shown
+ expect(modal.get_by_text("This field is required").first).to_be_visible()
+
+ # Fill in non-existent target tag
+ target_input = modal.get_by_label("Target tag")
+ target_input.fill("nonexistent-tag")
+
+ merge_input = modal.get_by_label("Tags to merge")
+ merge_input.fill(merge_tag.name)
+
+ modal.get_by_role("button", name="Merge Tags").click()
+
+ # Verify error for non-existent target tag
+ expect(
+ modal.get_by_text('Tag "nonexistent-tag" does not exist')
+ ).to_be_visible()
+
+ # Fill in valid target but target tag in merge tags
+ target_input.fill(target_tag.name)
+ merge_input.fill(target_tag.name)
+
+ modal.get_by_role("button", name="Merge Tags").click()
+
+ # Verify error for target tag in merge tags
+ expect(
+ modal.get_by_text("The target tag cannot be selected for merging")
+ ).to_be_visible()
+
+ # Verify no tags were deleted
+ self.assertEqual(
+ Tag.objects.filter(owner=self.get_or_create_test_user()).count(), 2
+ )
diff --git a/bookmarks/views/tags.py b/bookmarks/views/tags.py
index 8645198..3d45f56 100644
--- a/bookmarks/views/tags.py
+++ b/bookmarks/views/tags.py
@@ -10,6 +10,7 @@ from django.urls import reverse
from bookmarks.forms import TagForm, TagMergeForm
from bookmarks.models import Bookmark, Tag
from bookmarks.type_defs import HttpRequest
+from bookmarks.views import turbo
@login_required
@@ -76,9 +77,12 @@ def tag_new(request: HttpRequest):
tag = form.save()
messages.success(request, f'Tag "{tag.name}" created successfully.')
return HttpResponseRedirect(reverse("linkding:tags.index"))
+ else:
+ return turbo.replace(
+ request, "tag-modal", "tags/new.html", {"form": form}, status=422
+ )
- status = 422 if request.method == "POST" and not form.is_valid() else 200
- return render(request, "tags/new.html", {"form": form}, status=status)
+ return render(request, "tags/new.html", {"form": form})
@login_required
@@ -92,13 +96,16 @@ def tag_edit(request: HttpRequest, tag_id: int):
form.save()
messages.success(request, f'Tag "{tag.name}" updated successfully.')
return HttpResponseRedirect(reverse("linkding:tags.index"))
+ else:
+ return turbo.replace(
+ request,
+ "tag-modal",
+ "tags/edit.html",
+ {"tag": tag, "form": form},
+ status=422,
+ )
- status = 422 if request.method == "POST" and not form.is_valid() else 200
- context = {
- "tag": tag,
- "form": form,
- }
- return render(request, "tags/edit.html", context, status=status)
+ return render(request, "tags/edit.html", {"tag": tag, "form": form})
@login_required
@@ -146,6 +153,13 @@ def tag_merge(request: HttpRequest):
)
return HttpResponseRedirect(reverse("linkding:tags.index"))
+ else:
+ return turbo.replace(
+ request,
+ "tag-modal",
+ "tags/merge.html",
+ {"form": form},
+ status=422,
+ )
- status = 422 if request.method == "POST" and not form.is_valid() else 200
- return render(request, "tags/merge.html", {"form": form}, status=status)
+ return render(request, "tags/merge.html", {"form": form})
diff --git a/bookmarks/views/turbo.py b/bookmarks/views/turbo.py
index 3ac7dbc..833de6a 100644
--- a/bookmarks/views/turbo.py
+++ b/bookmarks/views/turbo.py
@@ -1,5 +1,6 @@
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render as django_render
+from django.template import loader
def accept(request: HttpRequest):
@@ -17,3 +18,20 @@ def stream(request: HttpRequest, template_name: str, context: dict) -> HttpRespo
response = django_render(request, template_name, context)
response["Content-Type"] = "text/vnd.turbo-stream.html"
return response
+
+
+def replace(
+ request: HttpRequest, target_id: str, template_name: str, context: dict, status=None
+) -> HttpResponse:
+ """
+ Returns a Turbo steam for replacing a specific target with the rendered
+ template. Mostly useful for updating forms in place after failed submissions,
+ without having to create a separate template.
+ """
+ if status is None:
+ status = 200
+ content = loader.render_to_string(template_name, context, request)
+ stream_content = f'{content}'
+ response = HttpResponse(stream_content, status=status)
+ response["Content-Type"] = "text/vnd.turbo-stream.html"
+ return response