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;
});
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();

View File

@@ -9,7 +9,7 @@
h2 {
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;
}
}
.tag-edit-modal {
.modal-container {
max-width: 400px;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 %}

View File

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

View File

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

View File

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

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.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})

View File

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