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;
|
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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,9 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-edit-modal {
|
||||||
|
.modal-container {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
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.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)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user