mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-02-27 22:43:15 +08:00
Move tag management forms into dialogs (#1253)
* Move tag management forms into dialogs * add e2e tests
This commit is contained in:
@@ -12,6 +12,15 @@ document.addEventListener("turbo:render", () => {
|
||||
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 {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--unit-3);
|
||||
margin-bottom: var(--unit-4);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,3 +4,9 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-edit-modal {
|
||||
.modal-container {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
}
|
||||
|
||||
.form-group {
|
||||
&:first-of-type {
|
||||
margin-top: var(--unit-4);
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--unit-4);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load shared %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Edit tag - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tags-editor-page">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Edit tag</h1>
|
||||
<turbo-frame id="tag-modal">
|
||||
<form method="post" action="{% url 'linkding:tags.edit' tag.id %}" data-turbo-frame="_top" novalidate>
|
||||
{% csrf_token %}
|
||||
<ld-modal class="modal tag-edit-modal active" data-close-url="{% url 'linkding:tags.index' %}"
|
||||
data-turbo-frame="tag-modal">
|
||||
<div class="modal-overlay" data-close-modal></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
{% include 'shared/modal_header.html' with title="Edit Tag" %}
|
||||
<div class="modal-body">
|
||||
{% include 'tags/form.html' %}
|
||||
</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">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'tags/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</ld-modal>
|
||||
</form>
|
||||
</turbo-frame>
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
|
||||
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.name.id_for_label }}">Name</label>
|
||||
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).</div>
|
||||
{{ 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>
|
||||
{% if form.name.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.name.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<div class="crud-header">
|
||||
<h1 id="main-heading">Tags</h1>
|
||||
<div class="d-flex gap-2 ml-auto">
|
||||
<a href="{% url 'linkding:tags.new' %}" class="btn">Add Tag</a>
|
||||
<a href="{% url 'linkding:tags.merge' %}" class="btn">Merge Tags</a>
|
||||
<a href="{% url 'linkding:tags.new' %}" data-turbo-frame="tag-modal" class="btn">Create Tag</a>
|
||||
<a href="{% url 'linkding:tags.merge' %}" data-turbo-frame="tag-modal" class="btn">Merge Tags</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,8 @@
|
||||
</a>
|
||||
</td>
|
||||
<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"
|
||||
data-confirm>
|
||||
Remove
|
||||
@@ -124,4 +125,5 @@
|
||||
{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
<turbo-frame id="tag-modal"></turbo-frame>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,72 +1,60 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load shared %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Merge tags - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
<turbo-frame id="tag-modal">
|
||||
<form method="post" action="{% url 'linkding:tags.merge' %}" data-turbo-frame="_top" novalidate>
|
||||
{% csrf_token %}
|
||||
<ld-modal class="modal active" data-close-url="{% url 'linkding:tags.index' %}" data-turbo-frame="tag-modal">
|
||||
<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="tags-editor-page">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Merge tags</h1>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 %}
|
||||
</ld-modal>
|
||||
</form>
|
||||
</turbo-frame>
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load shared %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Add tag - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tags-editor-page">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">New tag</h1>
|
||||
<turbo-frame id="tag-modal">
|
||||
<form method="post" action="{% url 'linkding:tags.new' %}" data-turbo-frame="_top" novalidate>
|
||||
{% csrf_token %}
|
||||
<ld-modal class="modal tag-edit-modal active" data-close-url="{% url 'linkding:tags.index' %}"
|
||||
data-turbo-frame="tag-modal">
|
||||
<div class="modal-overlay" data-close-modal></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
{% include 'shared/modal_header.html' with title="Create Tag" %}
|
||||
<div class="modal-body">
|
||||
{% include 'tags/form.html' %}
|
||||
</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">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'tags/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</ld-modal>
|
||||
</form>
|
||||
</turbo-frame>
|
||||
|
||||
@@ -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 <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):
|
||||
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):
|
||||
|
||||
262
bookmarks/tests_e2e/e2e_test_tag_management.py
Normal file
262
bookmarks/tests_e2e/e2e_test_tag_management.py
Normal 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
|
||||
)
|
||||
@@ -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})
|
||||
|
||||
@@ -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'<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
|
||||
|
||||
Reference in New Issue
Block a user