From 65f375944406438b10af281668902ceec412cac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Mon, 5 Jan 2026 05:33:59 +0100 Subject: [PATCH 1/5] Align form usages in templates (#1267) --- bookmarks/forms.py | 200 +++++++++++- .../frontend/components/tag-autocomplete.js | 3 +- bookmarks/frontend/utils/element.js | 8 +- bookmarks/models.py | 110 ------- bookmarks/settings/base.py | 1 - bookmarks/styles/theme/autocomplete.css | 9 + bookmarks/styles/theme/forms.css | 4 + bookmarks/templates/bookmarks/form.html | 61 ++-- bookmarks/templates/bookmarks/search.html | 4 +- .../templates/bookmarks/user_section.html | 4 +- bookmarks/templates/bundles/edit.html | 1 - bookmarks/templates/bundles/form.html | 55 ++-- bookmarks/templates/bundles/new.html | 1 - .../registration_complete.html | 1 - .../registration_form.html | 9 +- bookmarks/templates/registration/login.html | 14 +- .../registration/password_change_done.html | 1 - .../registration/password_change_form.html | 26 +- bookmarks/templates/settings/general.html | 293 +++++++----------- bookmarks/templates/shared/error_list.html | 2 +- bookmarks/templates/tags/form.html | 14 +- bookmarks/templates/tags/merge.html | 32 +- bookmarks/templatetags/bookmarks.py | 6 +- bookmarks/templatetags/shared.py | 67 +++- bookmarks/tests/test_bookmark_edit_view.py | 2 +- bookmarks/tests/test_bookmark_new_view.py | 2 +- bookmarks/tests/test_bookmark_search_form.py | 3 +- bookmarks/tests/test_bundles_edit_view.py | 14 +- bookmarks/tests/test_settings_general_view.py | 8 +- bookmarks/tests/test_tags_merge_view.py | 10 + bookmarks/urls.py | 10 +- bookmarks/views/auth.py | 12 + bookmarks/views/bundles.py | 3 +- bookmarks/views/contexts.py | 2 +- bookmarks/views/settings.py | 10 +- bookmarks/widgets.py | 83 +++++ pyproject.toml | 2 +- uv.lock | 11 - 38 files changed, 608 insertions(+), 490 deletions(-) create mode 100644 bookmarks/widgets.py diff --git a/bookmarks/forms.py b/bookmarks/forms.py index da58d0adf..d5dd0320d 100644 --- a/bookmarks/forms.py +++ b/bookmarks/forms.py @@ -1,10 +1,15 @@ from django import forms -from django.forms.utils import ErrorList +from django.contrib.auth.models import User +from django.db import models from django.utils import timezone from bookmarks.models import ( Bookmark, + BookmarkBundle, + BookmarkSearch, + GlobalSettings, Tag, + UserProfile, build_tag_string, parse_tag_string, sanitize_tag_name, @@ -12,23 +17,29 @@ from bookmarks.services.bookmarks import create_bookmark, update_bookmark from bookmarks.type_defs import HttpRequest from bookmarks.validators import BookmarkURLValidator - - -class CustomErrorList(ErrorList): - template_name = "shared/error_list.html" +from bookmarks.widgets import ( + FormCheckbox, + FormErrorList, + FormInput, + FormNumberInput, + FormSelect, + FormTextarea, + TagAutocomplete, +) class BookmarkForm(forms.ModelForm): # Use URLField for URL - url = forms.CharField(validators=[BookmarkURLValidator()]) - tag_string = forms.CharField(required=False) + url = forms.CharField(validators=[BookmarkURLValidator()], widget=FormInput) + tag_string = forms.CharField(required=False, widget=TagAutocomplete) # Do not require title and description as they may be empty - title = forms.CharField(max_length=512, required=False) - description = forms.CharField(required=False, widget=forms.Textarea()) - unread = forms.BooleanField(required=False) - shared = forms.BooleanField(required=False) + title = forms.CharField(max_length=512, required=False, widget=FormInput) + description = forms.CharField(required=False, widget=FormTextarea) + notes = forms.CharField(required=False, widget=FormTextarea) + unread = forms.BooleanField(required=False, widget=FormCheckbox) + shared = forms.BooleanField(required=False, widget=FormCheckbox) # Hidden field that determines whether to close window/tab after saving the bookmark - auto_close = forms.CharField(required=False) + auto_close = forms.CharField(required=False, widget=forms.HiddenInput) class Meta: model = Bookmark @@ -62,7 +73,7 @@ def __init__(self, request: HttpRequest, instance: Bookmark = None): initial = {"tag_string": build_tag_string(instance.tag_names, " ")} data = request.POST if request.method == "POST" else None super().__init__( - data, instance=instance, initial=initial, error_class=CustomErrorList + data, instance=instance, initial=initial, error_class=FormErrorList ) @property @@ -111,12 +122,14 @@ def convert_tag_string(tag_string: str): class TagForm(forms.ModelForm): + name = forms.CharField(widget=FormInput) + class Meta: model = Tag fields = ["name"] def __init__(self, user, *args, **kwargs): - super().__init__(*args, **kwargs, error_class=CustomErrorList) + super().__init__(*args, **kwargs, error_class=FormErrorList) self.user = user def clean_name(self): @@ -146,11 +159,11 @@ def save(self, commit=True): class TagMergeForm(forms.Form): - target_tag = forms.CharField() - merge_tags = forms.CharField() + target_tag = forms.CharField(widget=TagAutocomplete) + merge_tags = forms.CharField(widget=TagAutocomplete) def __init__(self, user, *args, **kwargs): - super().__init__(*args, **kwargs, error_class=CustomErrorList) + super().__init__(*args, **kwargs, error_class=FormErrorList) self.user = user def clean_target_tag(self): @@ -197,3 +210,156 @@ def clean_merge_tags(self): ) return merge_tags + + +class BookmarkBundleForm(forms.ModelForm): + name = forms.CharField(max_length=256, widget=FormInput) + search = forms.CharField(max_length=256, required=False, widget=FormInput) + any_tags = forms.CharField(required=False, widget=TagAutocomplete) + all_tags = forms.CharField(required=False, widget=TagAutocomplete) + excluded_tags = forms.CharField(required=False, widget=TagAutocomplete) + + class Meta: + model = BookmarkBundle + fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs, error_class=FormErrorList) + + +class BookmarkSearchForm(forms.Form): + SORT_CHOICES = [ + (BookmarkSearch.SORT_ADDED_ASC, "Added ↑"), + (BookmarkSearch.SORT_ADDED_DESC, "Added ↓"), + (BookmarkSearch.SORT_TITLE_ASC, "Title ↑"), + (BookmarkSearch.SORT_TITLE_DESC, "Title ↓"), + ] + FILTER_SHARED_CHOICES = [ + (BookmarkSearch.FILTER_SHARED_OFF, "Off"), + (BookmarkSearch.FILTER_SHARED_SHARED, "Shared"), + (BookmarkSearch.FILTER_SHARED_UNSHARED, "Unshared"), + ] + FILTER_UNREAD_CHOICES = [ + (BookmarkSearch.FILTER_UNREAD_OFF, "Off"), + (BookmarkSearch.FILTER_UNREAD_YES, "Unread"), + (BookmarkSearch.FILTER_UNREAD_NO, "Read"), + ] + + q = forms.CharField() + user = forms.ChoiceField(required=False, widget=FormSelect) + bundle = forms.CharField(required=False) + sort = forms.ChoiceField(choices=SORT_CHOICES, widget=FormSelect) + shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect) + unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect) + modified_since = forms.CharField(required=False) + added_since = forms.CharField(required=False) + + def __init__( + self, + search: BookmarkSearch, + editable_fields: list[str] = None, + users: list[User] = None, + ): + super().__init__() + editable_fields = editable_fields or [] + self.editable_fields = editable_fields + + # set choices for user field if users are provided + if users: + user_choices = [(user.username, user.username) for user in users] + user_choices.insert(0, ("", "Everyone")) + self.fields["user"].choices = user_choices + + for param in search.params: + # set initial values for modified params + value = search.__dict__.get(param) + if isinstance(value, models.Model): + self.fields[param].initial = value.id + else: + self.fields[param].initial = value + + # Mark non-editable modified fields as hidden. That way, templates + # rendering a form can just loop over hidden_fields to ensure that + # all necessary search options are kept when submitting the form. + if search.is_modified(param) and param not in editable_fields: + self.fields[param].widget = forms.HiddenInput() + + +class UserProfileForm(forms.ModelForm): + class Meta: + model = UserProfile + fields = [ + "theme", + "bookmark_date_display", + "bookmark_description_display", + "bookmark_description_max_lines", + "bookmark_link_target", + "web_archive_integration", + "tag_search", + "tag_grouping", + "enable_sharing", + "enable_public_sharing", + "enable_favicons", + "enable_preview_images", + "enable_automatic_html_snapshots", + "display_url", + "display_view_bookmark_action", + "display_edit_bookmark_action", + "display_archive_bookmark_action", + "display_remove_bookmark_action", + "permanent_notes", + "default_mark_unread", + "default_mark_shared", + "custom_css", + "auto_tagging_rules", + "items_per_page", + "sticky_pagination", + "collapse_side_panel", + "hide_bundles", + "legacy_search", + ] + widgets = { + "theme": FormSelect, + "bookmark_date_display": FormSelect, + "bookmark_description_display": FormSelect, + "bookmark_description_max_lines": FormNumberInput, + "bookmark_link_target": FormSelect, + "web_archive_integration": FormSelect, + "tag_search": FormSelect, + "tag_grouping": FormSelect, + "auto_tagging_rules": FormTextarea, + "custom_css": FormTextarea, + "items_per_page": FormNumberInput, + "display_url": FormCheckbox, + "permanent_notes": FormCheckbox, + "display_view_bookmark_action": FormCheckbox, + "display_edit_bookmark_action": FormCheckbox, + "display_archive_bookmark_action": FormCheckbox, + "display_remove_bookmark_action": FormCheckbox, + "sticky_pagination": FormCheckbox, + "collapse_side_panel": FormCheckbox, + "hide_bundles": FormCheckbox, + "legacy_search": FormCheckbox, + "enable_favicons": FormCheckbox, + "enable_preview_images": FormCheckbox, + "enable_sharing": FormCheckbox, + "enable_public_sharing": FormCheckbox, + "enable_automatic_html_snapshots": FormCheckbox, + "default_mark_unread": FormCheckbox, + "default_mark_shared": FormCheckbox, + } + + +class GlobalSettingsForm(forms.ModelForm): + class Meta: + model = GlobalSettings + fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"] + widgets = { + "landing_page": FormSelect, + "guest_profile_user": FormSelect, + "enable_link_prefetch": FormCheckbox, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["guest_profile_user"].empty_label = "Standard profile" diff --git a/bookmarks/frontend/components/tag-autocomplete.js b/bookmarks/frontend/components/tag-autocomplete.js index a1675879e..4d9d6fac0 100644 --- a/bookmarks/frontend/components/tag-autocomplete.js +++ b/bookmarks/frontend/components/tag-autocomplete.js @@ -9,6 +9,7 @@ export class TagAutocomplete extends TurboLitElement { inputId: { type: String, attribute: "input-id" }, inputName: { type: String, attribute: "input-name" }, inputValue: { type: String, attribute: "input-value" }, + inputClass: { type: String, attribute: "input-class" }, inputPlaceholder: { type: String, attribute: "input-placeholder" }, inputAriaDescribedBy: { type: String, attribute: "input-aria-describedby" }, variant: { type: String }, @@ -160,7 +161,7 @@ export class TagAutocomplete extends TurboLitElement { name="${this.inputName || nothing}" .value="${this.inputValue || ""}" placeholder="${this.inputPlaceholder || " "}" - class="form-input" + class="form-input ${this.inputClass || ""}" type="text" autocomplete="off" autocapitalize="off" diff --git a/bookmarks/frontend/utils/element.js b/bookmarks/frontend/utils/element.js index e2bcb72cd..94a110413 100644 --- a/bookmarks/frontend/utils/element.js +++ b/bookmarks/frontend/utils/element.js @@ -42,10 +42,10 @@ document.addEventListener("turbo:render", () => { }); 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. + const parent = event.target?.parentElement; + if (parent instanceof TurboLitElement) { + // Prevent Turbo from morphing Lit elements contents, which would remove + // elements rendered on the client side. event.preventDefault(); } }); diff --git a/bookmarks/models.py b/bookmarks/models.py index 9dd93dea9..745806bff 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -3,7 +3,6 @@ import logging import os -from django import forms from django.conf import settings from django.contrib.auth.models import User from django.core.validators import MinValueValidator @@ -195,12 +194,6 @@ def __str__(self): return self.name -class BookmarkBundleForm(forms.ModelForm): - class Meta: - model = BookmarkBundle - fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"] - - class BookmarkSearch: SORT_ADDED_ASC = "added_asc" SORT_ADDED_DESC = "added_desc" @@ -323,64 +316,6 @@ def from_request(request: any, query_dict: QueryDict, preferences: dict = None): ) -class BookmarkSearchForm(forms.Form): - SORT_CHOICES = [ - (BookmarkSearch.SORT_ADDED_ASC, "Added ↑"), - (BookmarkSearch.SORT_ADDED_DESC, "Added ↓"), - (BookmarkSearch.SORT_TITLE_ASC, "Title ↑"), - (BookmarkSearch.SORT_TITLE_DESC, "Title ↓"), - ] - FILTER_SHARED_CHOICES = [ - (BookmarkSearch.FILTER_SHARED_OFF, "Off"), - (BookmarkSearch.FILTER_SHARED_SHARED, "Shared"), - (BookmarkSearch.FILTER_SHARED_UNSHARED, "Unshared"), - ] - FILTER_UNREAD_CHOICES = [ - (BookmarkSearch.FILTER_UNREAD_OFF, "Off"), - (BookmarkSearch.FILTER_UNREAD_YES, "Unread"), - (BookmarkSearch.FILTER_UNREAD_NO, "Read"), - ] - - q = forms.CharField() - user = forms.ChoiceField(required=False) - bundle = forms.CharField(required=False) - sort = forms.ChoiceField(choices=SORT_CHOICES) - shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect) - unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect) - modified_since = forms.CharField(required=False) - added_since = forms.CharField(required=False) - - def __init__( - self, - search: BookmarkSearch, - editable_fields: list[str] = None, - users: list[User] = None, - ): - super().__init__() - editable_fields = editable_fields or [] - self.editable_fields = editable_fields - - # set choices for user field if users are provided - if users: - user_choices = [(user.username, user.username) for user in users] - user_choices.insert(0, ("", "Everyone")) - self.fields["user"].choices = user_choices - - for param in search.params: - # set initial values for modified params - value = search.__dict__.get(param) - if isinstance(value, models.Model): - self.fields[param].initial = value.id - else: - self.fields[param].initial = value - - # Mark non-editable modified fields as hidden. That way, templates - # rendering a form can just loop over hidden_fields to ensure that - # all necessary search options are kept when submitting the form. - if search.is_modified(param) and param not in editable_fields: - self.fields[param].widget = forms.HiddenInput() - - class UserProfile(models.Model): THEME_AUTO = "auto" THEME_LIGHT = "light" @@ -507,41 +442,6 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) -class UserProfileForm(forms.ModelForm): - class Meta: - model = UserProfile - fields = [ - "theme", - "bookmark_date_display", - "bookmark_description_display", - "bookmark_description_max_lines", - "bookmark_link_target", - "web_archive_integration", - "tag_search", - "tag_grouping", - "enable_sharing", - "enable_public_sharing", - "enable_favicons", - "enable_preview_images", - "enable_automatic_html_snapshots", - "display_url", - "display_view_bookmark_action", - "display_edit_bookmark_action", - "display_archive_bookmark_action", - "display_remove_bookmark_action", - "permanent_notes", - "default_mark_unread", - "default_mark_shared", - "custom_css", - "auto_tagging_rules", - "items_per_page", - "sticky_pagination", - "collapse_side_panel", - "hide_bundles", - "legacy_search", - ] - - @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): if created: @@ -640,13 +540,3 @@ def save(self, *args, **kwargs): if not self.pk and GlobalSettings.objects.exists(): raise Exception("There is already one instance of GlobalSettings") return super().save(*args, **kwargs) - - -class GlobalSettingsForm(forms.ModelForm): - class Meta: - model = GlobalSettings - fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["guest_profile_user"].empty_label = "Standard profile" diff --git a/bookmarks/settings/base.py b/bookmarks/settings/base.py index 563337d17..4640ab6a9 100644 --- a/bookmarks/settings/base.py +++ b/bookmarks/settings/base.py @@ -39,7 +39,6 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "widget_tweaks", "rest_framework", "rest_framework.authtoken", "huey.contrib.djhuey", diff --git a/bookmarks/styles/theme/autocomplete.css b/bookmarks/styles/theme/autocomplete.css index f8a74b094..df062797d 100644 --- a/bookmarks/styles/theme/autocomplete.css +++ b/bookmarks/styles/theme/autocomplete.css @@ -31,6 +31,15 @@ outline: none; } } + + &:has(.is-error) { + background: var(--error-color-shade); + border-color: var(--error-color); + + &.is-focused { + outline-color: var(--error-color); + } + } } &.small { diff --git a/bookmarks/styles/theme/forms.css b/bookmarks/styles/theme/forms.css index 10567a037..af9a6e3ba 100644 --- a/bookmarks/styles/theme/forms.css +++ b/bookmarks/styles/theme/forms.css @@ -135,6 +135,10 @@ textarea.form-input { .is-error + & { color: var(--error-color); } + + &.is-error { + color: var(--error-color); + } } /* Form element: Select */ diff --git a/bookmarks/templates/bookmarks/form.html b/bookmarks/templates/bookmarks/form.html index 125fc40d5..22b14b66a 100644 --- a/bookmarks/templates/bookmarks/form.html +++ b/bookmarks/templates/bookmarks/form.html @@ -1,38 +1,32 @@ -{% load widget_tweaks %} {% load static %} {% load shared %}
{% csrf_token %} - {{ form.auto_close|attr:"type:hidden" }} -
- + {{ form.auto_close }} +
+ {% formlabel form.url "URL" %}
- {{ form.url|form_field:"validation"|add_class:"form-input"|attr:"autofocus" }} + {% formfield form.url autofocus=True %}
- {% if form.url.errors %}
{{ form.url.errors }}
{% endif %} + {{ form.url.errors }}
This URL is already bookmarked. The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.
- - - -
+ {% formlabel form.tag_string "Tags" %} + {% formfield form.tag_string has_help=True %} + {% formhelp form.tag_string %} Enter any number of tags separated by space and without the hash (#). If a tag does not exist it will be automatically created. -
+ {% endformhelp %}
- {{ form.tag_string.errors }}
- + {% formlabel form.title "Title" %}
@@ -40,18 +34,16 @@
- {{ form.title|add_class:"form-input"|attr:"autocomplete:off" }} - {{ form.title.errors }} + {% formfield form.title %}
- + {% formlabel form.description "Description" %}
- {{ form.description|add_class:"form-input"|attr:"rows:3" }} - {{ form.description.errors }} + {% formfield form.description rows="3" %}
@@ -59,35 +51,28 @@ Notes - {{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }} -
Additional notes, supports Markdown.
+ {% formfield form.notes rows="8" has_help=True %} + {% formhelp form.notes %} + Additional notes, supports Markdown. + {% endformhelp %}
- {{ form.notes.errors }}
-
- {{ form.unread|form_field:"help" }} - - -
-
+ {% formfield form.unread label="Mark as unread" has_help=True %} + {% formhelp form.unread %} Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them. -
+ {% endformhelp %}
{% if request.user_profile.enable_sharing %}
-
- {{ form.shared|form_field:"help" }} - - -
-
+ {% formfield form.shared label="Share" has_help=True %} + {% formhelp form.shared %} {% if request.user_profile.enable_public_sharing %} Share this bookmark with other registered users and anonymous users. {% else %} Share this bookmark with other registered users. {% endif %} -
+ {% endformhelp %}
{% endif %}
diff --git a/bookmarks/templates/bookmarks/search.html b/bookmarks/templates/bookmarks/search.html index 08f4a13e1..ffee8c3c4 100644 --- a/bookmarks/templates/bookmarks/search.html +++ b/bookmarks/templates/bookmarks/search.html @@ -1,4 +1,4 @@ -{% load static widget_tweaks %} +{% load static shared %}
{% endif %} {% if 'shared' in preferences_form.editable_fields %} diff --git a/bookmarks/templates/bookmarks/user_section.html b/bookmarks/templates/bookmarks/user_section.html index ac16679b3..360e9e208 100644 --- a/bookmarks/templates/bookmarks/user_section.html +++ b/bookmarks/templates/bookmarks/user_section.html @@ -1,4 +1,4 @@ -{% load widget_tweaks %} +{% load shared %}

User

@@ -9,7 +9,7 @@

User

{% for hidden_field in user_list.form.hidden_fields %}{{ hidden_field }}{% endfor %}
- {% render_field user_list.form.user class+="form-select" data-submit-on-change="" %} + {% formfield user_list.form.user data_submit_on_change="" %} diff --git a/bookmarks/templates/bundles/edit.html b/bookmarks/templates/bundles/edit.html index c4b0df82c..291e71ebf 100644 --- a/bookmarks/templates/bundles/edit.html +++ b/bookmarks/templates/bundles/edit.html @@ -1,5 +1,4 @@ {% extends 'shared/layout.html' %} -{% load widget_tweaks %} {% block head %} {% with page_title="Edit bundle - Linkding" %}{{ block.super }}{% endwith %} {% endblock %} diff --git a/bookmarks/templates/bundles/form.html b/bookmarks/templates/bundles/form.html index b081817db..713540756 100644 --- a/bookmarks/templates/bundles/form.html +++ b/bookmarks/templates/bundles/form.html @@ -1,38 +1,37 @@ -{% load widget_tweaks %} -
- - {{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }} - {% if form.name.errors %}
{{ form.name.errors }}
{% endif %} +{% load shared %} +
+ {% formlabel form.name "Name" %} + {% formfield form.name %} + {{ form.name.errors }}
-
- - {{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }} - {% if form.search.errors %}
{{ form.search.errors }}
{% endif %} -
All of these search terms must be present in a bookmark to match.
+
+ {% formlabel form.search "Search terms" %} + {% formfield form.search has_help=True %} + {{ form.search.errors }} + {% formhelp form.search %} + All of these search terms must be present in a bookmark to match. + {% endformhelp %}
- - - -
At least one of these tags must be present in a bookmark to match.
+ {% formlabel form.any_tags "Tags" %} + {% formfield form.any_tags has_help=True %} + {% formhelp form.any_tags %} + At least one of these tags must be present in a bookmark to match. + {% endformhelp %}
- - - -
All of these tags must be present in a bookmark to match.
+ {% formlabel form.all_tags "Required tags" %} + {% formfield form.all_tags has_help=True %} + {% formhelp form.all_tags %} + All of these tags must be present in a bookmark to match. + {% endformhelp %}
- - - -
None of these tags must be present in a bookmark to match.
+ {% formlabel form.excluded_tags "Excluded tags" %} + {% formfield form.excluded_tags has_help=True %} + {% formhelp form.excluded_tags %} + None of these tags must be present in a bookmark to match. + {% endformhelp %}