diff --git a/bookmarks/forms.py b/bookmarks/forms.py index da58d0a..d5dd032 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.models import ( 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 @@ class BookmarkForm(forms.ModelForm): 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 @@ class TagForm(forms.ModelForm): 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 @@ class TagMergeForm(forms.Form): ) 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 a167587..4d9d6fa 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 e2bcb72..94a1104 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 9dd93de..745806b 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -3,7 +3,6 @@ import hashlib 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 @@ class BookmarkBundle(models.Model): 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 @@ class BookmarkSearch: ) -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 @@ class UserProfile(models.Model): 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 @@ class GlobalSettings(models.Model): 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 563337d..4640ab6 100644 --- a/bookmarks/settings/base.py +++ b/bookmarks/settings/base.py @@ -39,7 +39,6 @@ INSTALLED_APPS = [ "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 f8a74b0..df06279 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 10567a0..af9a6e3 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 125fc40..22b14b6 100644 --- a/bookmarks/templates/bookmarks/form.html +++ b/bookmarks/templates/bookmarks/form.html @@ -1,38 +1,32 @@ -{% load widget_tweaks %} {% load static %} {% load shared %}