Move tag management forms into dialogs (#1253)

* Move tag management forms into dialogs

* add e2e tests
This commit is contained in:
Sascha Ißbrücker
2025-12-31 21:38:46 +01:00
committed by GitHub
parent fc15363349
commit b82d07c588
14 changed files with 440 additions and 147 deletions

View File

@@ -12,6 +12,15 @@ document.addEventListener("turbo:render", () => {
isTopFrameVisit = false; isTopFrameVisit = false;
}); });
document.addEventListener("turbo:before-morph-element", (event) => {
if (event.target instanceof TurboLitElement) {
// Prevent Turbo from morphing Lit elements, which would remove rendered
// contents. For now this means that any Lit element / widget can not be
// updated from the server when using morphing.
event.preventDefault();
}
});
export class TurboLitElement extends LitElement { export class TurboLitElement extends LitElement {
constructor() { constructor() {
super(); super();

View File

@@ -9,7 +9,7 @@
h2 { h2 {
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
margin-bottom: var(--unit-3); margin-bottom: var(--unit-4);
} }
} }

View File

@@ -4,3 +4,9 @@
margin: 0 auto; margin: 0 auto;
} }
} }
.tag-edit-modal {
.modal-container {
max-width: 400px;
}
}

View File

@@ -20,9 +20,6 @@
} }
.form-group { .form-group {
&:first-of-type {
margin-top: var(--unit-4);
}
&:not(:last-child) { &:not(:last-child) {
margin-bottom: var(--unit-4); margin-bottom: var(--unit-4);
} }

View File

@@ -21,7 +21,7 @@
</div> </div>
</div> </div>
<div class="modal-footer d-flex justify-between"> <div class="modal-footer d-flex justify-between">
<a class="btn btn-wide" data-close-modal>Cancel</a> <button type="button" class="btn btn-wide" data-close-modal>Cancel</button>
<button type="submit" class="btn btn-primary">Create Token</button> <button type="submit" class="btn btn-primary">Create Token</button>
</div> </div>
</div> </div>

View File

@@ -1,23 +1,19 @@
{% extends "bookmarks/layout.html" %} <turbo-frame id="tag-modal">
{% load shared %} <form method="post" action="{% url 'linkding:tags.edit' tag.id %}" data-turbo-frame="_top" novalidate>
{% csrf_token %}
{% block head %} <ld-modal class="modal tag-edit-modal active" data-close-url="{% url 'linkding:tags.index' %}"
{% with page_title="Edit tag - Linkding" %} data-turbo-frame="tag-modal">
{{ block.super }} <div class="modal-overlay" data-close-modal></div>
{% endwith %} <div class="modal-container" role="dialog" aria-modal="true">
{% endblock %} {% include 'shared/modal_header.html' with title="Edit Tag" %}
<div class="modal-body">
{% block content %} {% include 'tags/form.html' %}
<div class="tags-editor-page"> </div>
<main aria-labelledby="main-heading"> <div class="modal-footer d-flex justify-between">
<div class="section-header"> <button type="button" class="btn btn-wide" data-close-modal>Cancel</button>
<h1 id="main-heading">Edit tag</h1> <button type="submit" class="btn btn-primary btn-wide">Save</button>
</div>
</div> </div>
</ld-modal>
<form method="post" novalidate> </form>
{% csrf_token %} </turbo-frame>
{% include 'tags/form.html' %}
</form>
</main>
</div>
{% endblock %}

View File

@@ -2,18 +2,13 @@
<div class="form-group {% if form.name.errors %}has-error{% endif %}"> <div class="form-group {% if form.name.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.name.id_for_label }}">Name</label> <label class="form-label" for="{{ form.name.id_for_label }}">Name</label>
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }} {{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: "|attr:"autofocus" }}
<div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).</div> <div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with
hyphens).
</div>
{% if form.name.errors %} {% if form.name.errors %}
<div class="form-input-hint"> <div class="form-input-hint">
{{ form.name.errors }} {{ form.name.errors }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="divider"></div>
<div class="form-group d-flex justify-between">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
</div>

View File

@@ -14,8 +14,8 @@
<div class="crud-header"> <div class="crud-header">
<h1 id="main-heading">Tags</h1> <h1 id="main-heading">Tags</h1>
<div class="d-flex gap-2 ml-auto"> <div class="d-flex gap-2 ml-auto">
<a href="{% url 'linkding:tags.new' %}" class="btn">Add Tag</a> <a href="{% url 'linkding:tags.new' %}" data-turbo-frame="tag-modal" class="btn">Create Tag</a>
<a href="{% url 'linkding:tags.merge' %}" class="btn">Merge Tags</a> <a href="{% url 'linkding:tags.merge' %}" data-turbo-frame="tag-modal" class="btn">Merge Tags</a>
</div> </div>
</div> </div>
@@ -96,7 +96,8 @@
</a> </a>
</td> </td>
<td class="actions"> <td class="actions">
<a class="btn btn-link" href="{% url 'linkding:tags.edit' tag.id %}">Edit</a> <a class="btn btn-link" href="{% url 'linkding:tags.edit' tag.id %}"
data-turbo-frame="tag-modal">Edit</a>
<button type="submit" name="delete_tag" value="{{ tag.id }}" class="btn btn-link text-error" <button type="submit" name="delete_tag" value="{{ tag.id }}" class="btn btn-link text-error"
data-confirm> data-confirm>
Remove Remove
@@ -124,4 +125,5 @@
{% endif %} {% endif %}
</main> </main>
</div> </div>
<turbo-frame id="tag-modal"></turbo-frame>
{% endblock %} {% endblock %}

View File

@@ -1,72 +1,60 @@
{% extends "bookmarks/layout.html" %}
{% load shared %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block head %} <turbo-frame id="tag-modal">
{% with page_title="Merge tags - Linkding" %} <form method="post" action="{% url 'linkding:tags.merge' %}" data-turbo-frame="_top" novalidate>
{{ block.super }} {% csrf_token %}
{% endwith %} <ld-modal class="modal active" data-close-url="{% url 'linkding:tags.index' %}" data-turbo-frame="tag-modal">
{% endblock %} <div class="modal-overlay" data-close-modal></div>
<div class="modal-container" role="dialog" aria-modal="true">
{% include 'shared/modal_header.html' with title="Merge Tags" %}
<div class="modal-body">
<details class="mb-4">
<summary>
<span class="text-bold mb-1">How to merge tags</span>
</summary>
<ol>
<li>Enter the name of the tag you want to keep</li>
<li>Enter the names of tags to merge into the target tag</li>
<li>The target tag is added to all bookmarks that have any of the merge tags</li>
<li>The merged tags are deleted</li>
</ol>
</details>
{% block content %} <div class="form-group {% if form.target_tag.errors %}has-error{% endif %}">
<div class="tags-editor-page"> <label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
<main aria-labelledby="main-heading"> <ld-tag-autocomplete input-id="{{ form.target_tag.auto_id }}" input-name="{{ form.target_tag.html_name }}"
<div class="section-header"> input-value="{{ form.target_tag.value|default_if_none:'' }}">
<h1 id="main-heading">Merge tags</h1> </ld-tag-autocomplete>
<div class="form-input-hint">
Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
</div>
{% if form.target_tag.errors %}
<div class="form-input-hint">
{{ form.target_tag.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}">
<label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
<ld-tag-autocomplete input-id="{{ form.merge_tags.auto_id }}" input-name="{{ form.merge_tags.html_name }}"
input-value="{{ form.merge_tags.value|default_if_none:'' }}">
</ld-tag-autocomplete>
<div class="form-input-hint">Enter the names of tags to merge into the target tag, separated by spaces.
These tags will be deleted after merging.
</div>
{% if form.merge_tags.errors %}
<div class="form-input-hint">
{{ form.merge_tags.errors }}
</div>
{% endif %}
</div>
</div>
<div class="modal-footer d-flex justify-between">
<button type="button" class="btn btn-wide" data-close-modal>Cancel</button>
<button type="submit" class="btn btn-primary btn-wide">Merge Tags</button>
</div>
</div> </div>
</ld-modal>
<details class="mb-4"> </form>
<summary> </turbo-frame>
<span class="text-bold mb-1">How to merge tags</span>
</summary>
<ol>
<li>Enter the name of the tag you want to keep</li>
<li>Enter the names of tags to merge into the target tag</li>
<li>The target tag is added to all bookmarks that have any of the merge tags</li>
<li>The merged tags are deleted</li>
</ol>
</details>
<form method="post">
{% csrf_token %}
<div class="form-group {% if form.target_tag.errors %}has-error{% endif %}">
<label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
<ld-tag-autocomplete input-id="{{ form.target_tag.auto_id }}" input-name="{{ form.target_tag.html_name }}"
input-value="{{ form.target_tag.value|default_if_none:'' }}">
</ld-tag-autocomplete>
<div class="form-input-hint">
Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
</div>
{% if form.target_tag.errors %}
<div class="form-input-hint">
{{ form.target_tag.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}">
<label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
<ld-tag-autocomplete input-id="{{ form.merge_tags.auto_id }}" input-name="{{ form.merge_tags.html_name }}"
input-value="{{ form.merge_tags.value|default_if_none:'' }}">
</ld-tag-autocomplete>
<div class="form-input-hint">Enter the names of tags to merge into the target tag, separated by spaces. These
tags will be deleted after merging.
</div>
{% if form.merge_tags.errors %}
<div class="form-input-hint">
{{ form.merge_tags.errors }}
</div>
{% endif %}
</div>
<div class="divider"></div>
<div class="form-group d-flex justify-between">
<button type="submit" class="btn btn-primary">Merge Tags</button>
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
</div>
</form>
</main>
</div>
{% endblock %}

View File

@@ -1,23 +1,19 @@
{% extends "bookmarks/layout.html" %} <turbo-frame id="tag-modal">
{% load shared %} <form method="post" action="{% url 'linkding:tags.new' %}" data-turbo-frame="_top" novalidate>
{% csrf_token %}
{% block head %} <ld-modal class="modal tag-edit-modal active" data-close-url="{% url 'linkding:tags.index' %}"
{% with page_title="Add tag - Linkding" %} data-turbo-frame="tag-modal">
{{ block.super }} <div class="modal-overlay" data-close-modal></div>
{% endwith %} <div class="modal-container" role="dialog" aria-modal="true">
{% endblock %} {% include 'shared/modal_header.html' with title="Create Tag" %}
<div class="modal-body">
{% block content %} {% include 'tags/form.html' %}
<div class="tags-editor-page"> </div>
<main aria-labelledby="main-heading"> <div class="modal-footer d-flex justify-between">
<div class="section-header"> <button type="button" class="btn btn-wide" data-close-modal>Cancel</button>
<h1 id="main-heading">New tag</h1> <button type="submit" class="btn btn-primary btn-wide">Save</button>
</div>
</div> </div>
</ld-modal>
<form method="post" novalidate> </form>
{% csrf_token %} </turbo-frame>
{% include 'tags/form.html' %}
</form>
</main>
</div>
{% endblock %}

View File

@@ -1,3 +1,5 @@
from bs4 import TemplateString
from bs4.element import NavigableString, CData
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
@@ -10,6 +12,11 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.user = self.get_or_create_test_user() self.user = self.get_or_create_test_user()
self.client.force_login(self.user) self.client.force_login(self.user)
def get_text(self, element):
# Invalid form responses are wrapped in <template> 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): def get_form_group(self, response, input_name):
soup = self.make_soup(response.content.decode()) soup = self.make_soup(response.content.decode())
input_element = soup.find("input", {"name": input_name}) input_element = soup.find("input", {"name": input_name})
@@ -109,10 +116,12 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag") 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") 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): def test_validate_missing_target_tag(self):
merge_tag = self.setup_tag(name="merge_tag") merge_tag = self.setup_tag(name="merge_tag")
@@ -125,7 +134,7 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag") 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()) self.assertTrue(Tag.objects.filter(id=merge_tag.id).exists())
def test_validate_missing_merge_tags(self): def test_validate_missing_merge_tags(self):
@@ -138,7 +147,7 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags") 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): def test_validate_nonexistent_target_tag(self):
merge_tag = self.setup_tag(name="merge_tag") 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") target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn( 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): def test_validate_nonexistent_merge_tag(self):
@@ -167,7 +176,7 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags") merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn( 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): 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") target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn( self.assertIn(
"Please enter only one tag name for the target tag", "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): 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") merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn( 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): def test_merge_shows_success_message(self):

View File

@@ -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
)

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.views import turbo
@login_required @login_required
@@ -76,9 +77,12 @@ def tag_new(request: HttpRequest):
tag = form.save() tag = form.save()
messages.success(request, f'Tag "{tag.name}" created successfully.') messages.success(request, f'Tag "{tag.name}" created successfully.')
return HttpResponseRedirect(reverse("linkding:tags.index")) 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})
return render(request, "tags/new.html", {"form": form}, status=status)
@login_required @login_required
@@ -92,13 +96,16 @@ def tag_edit(request: HttpRequest, tag_id: int):
form.save() form.save()
messages.success(request, f'Tag "{tag.name}" updated successfully.') messages.success(request, f'Tag "{tag.name}" updated successfully.')
return HttpResponseRedirect(reverse("linkding:tags.index")) 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 return render(request, "tags/edit.html", {"tag": tag, "form": form})
context = {
"tag": tag,
"form": form,
}
return render(request, "tags/edit.html", context, status=status)
@login_required @login_required
@@ -146,6 +153,13 @@ def tag_merge(request: HttpRequest):
) )
return HttpResponseRedirect(reverse("linkding:tags.index")) 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})
return render(request, "tags/merge.html", {"form": form}, status=status)

View File

@@ -1,5 +1,6 @@
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render as django_render from django.shortcuts import render as django_render
from django.template import loader
def accept(request: HttpRequest): 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 = django_render(request, template_name, context)
response["Content-Type"] = "text/vnd.turbo-stream.html" response["Content-Type"] = "text/vnd.turbo-stream.html"
return response 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'<turbo-stream action="replace" method="morph" target="{target_id}"><template>{content}</template></turbo-stream>'
response = HttpResponse(stream_content, status=status)
response["Content-Type"] = "text/vnd.turbo-stream.html"
return response