Repository: sissbruecker/linkding
Pull request #835 (2024-09-18 21:09:55)

Add configuration options for pagination
Allows to configure the number of bookmarks per page, and to make the pagination controls stick to the bottom of the screen so that they are always visible.

Closes https://github.com/sissbruecker/linkding/issues/545
Closes https://github.com/sissbruecker/linkding/issues/831


Patch:
diff --git a/bookmarks/migrations/0040_userprofile_items_per_page_and_more.py b/bookmarks/migrations/0040_userprofile_items_per_page_and_more.py
new file mode 100644
index 0000000..0b11247
--- /dev/null
+++ b/bookmarks/migrations/0040_userprofile_items_per_page_and_more.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.0.8 on 2024-09-18 20:11
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookmarks", "0039_globalsettings_enable_link_prefetch"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="userprofile",
+            name="items_per_page",
+            field=models.IntegerField(
+                default=30, validators=[django.core.validators.MinValueValidator(10)]
+            ),
+        ),
+        migrations.AddField(
+            model_name="userprofile",
+            name="sticky_pagination",
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/bookmarks/models.py b/bookmarks/models.py
index 6a4d112..7d8bc5a 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -1,12 +1,13 @@
+import binascii
 import logging
 import os
 from typing import List
 
-import binascii
 from django import forms
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import User
+from django.core.validators import MinValueValidator
 from django.db import models
 from django.db.models.signals import post_save, post_delete
 from django.dispatch import receiver
@@ -422,6 +423,10 @@ class UserProfile(models.Model):
     search_preferences = models.JSONField(default=dict, null=False)
     enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
     default_mark_unread = models.BooleanField(default=False, null=False)
+    items_per_page = models.IntegerField(
+        null=False, default=30, validators=[MinValueValidator(10)]
+    )
+    sticky_pagination = models.BooleanField(default=False, null=False)
 
 
 class UserProfileForm(forms.ModelForm):
@@ -450,6 +455,8 @@ class UserProfileForm(forms.ModelForm):
             "default_mark_unread",
             "custom_css",
             "auto_tagging_rules",
+            "items_per_page",
+            "sticky_pagination",
         ]
 
 
diff --git a/bookmarks/styles/bookmark-page.css b/bookmarks/styles/bookmark-page.css
index 42a823a..8adc88d 100644
--- a/bookmarks/styles/bookmark-page.css
+++ b/bookmarks/styles/bookmark-page.css
@@ -300,6 +300,28 @@ li[ld-bookmark-item] {
     & .page-item:first-child a {
         padding-left: 0;
     }
+
+    &.sticky {
+        position: sticky;
+        bottom: 0;
+        border-top: solid 1px var(--secondary-border-color);
+        background: var(--body-color);
+        padding-bottom: var(--unit-h);
+
+        &:before {
+            content: '';
+            position: absolute;
+            top: 0;
+            bottom: 0;
+            left: calc(-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)));
+            width: calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset));
+            background: var(--body-color);
+        }
+    }
+
+    & .pagination {
+        overflow: hidden;
+    }
 }
 
 .tag-cloud {
@@ -379,6 +401,7 @@ ul.bookmark-list {
     }
 
     /* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */
+
     &.active section:first-of-type .content-area-header {
         border-bottom-color: transparent;
     }
@@ -389,6 +412,19 @@ ul.bookmark-list {
         overflow: visible;
     }
 
+    /* make sticky pagination expand to cover checkboxes to the left */
+
+    &.active .bookmark-pagination.sticky:before {
+        content: '';
+        position: absolute;
+        top: -1px;
+        bottom: 0;
+        left: calc(-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)));
+        width: calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset));
+        background: var(--body-color);
+        border-top: solid 1px var(--secondary-border-color);
+    }
+
     /* All checkbox */
 
     & .form-checkbox.bulk-edit-checkbox.all {
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index def80ae..d68bdc1 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -149,7 +149,7 @@
     {% endfor %}
   </ul>
 
-  <div class="bookmark-pagination">
+  <div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
     {% pagination bookmark_list.bookmarks_page %}
   </div>
 {% endif %}
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index ec01aea..0cd051d 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -101,6 +101,29 @@
             Whether to open bookmarks a new page or in the same page.
           </div>
         </div>
+        <div class="form-group{% if form.items_per_page.errors %} has-error{% endif %}">
+          <label for="{{ form.items_per_page.id_for_label }}" class="form-label">Items per page</label>
+          {{ form.items_per_page|add_class:"form-input width-25 width-sm-100"|attr:"min:10" }}
+          {% if form.items_per_page.errors %}
+            <div class="form-input-hint is-error">
+              {{ form.items_per_page.errors }}
+            </div>
+          {% else %}
+          {% endif %}
+          <div class="form-input-hint">
+            The number of bookmarks to display per page.
+          </div>
+        </div>
+        <div class="form-group">
+          <label for="{{ form.sticky_pagination.id_for_label }}" class="form-checkbox">
+            {{ form.sticky_pagination }}
+            <i class="form-icon"></i> Sticky pagination
+          </label>
+          <div class="form-input-hint">
+            When enabled, the pagination controls will stick to the bottom of the screen, so that they are always
+            visible without having to scroll to the end of the page first.
+          </div>
+        </div>
         <div class="form-group">
           <label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
           {{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py
index 730741b..f8abdc9 100644
--- a/bookmarks/views/bookmarks.py
+++ b/bookmarks/views/bookmarks.py
@@ -38,8 +38,6 @@ from bookmarks.services.bookmarks import (
 from bookmarks.utils import get_safe_return_url
 from bookmarks.views import contexts, partials, turbo
 
-_default_page_size = 30
-
 
 @login_required
 def index(request):
diff --git a/bookmarks/views/contexts.py b/bookmarks/views/contexts.py
index 8593451..7b50e17 100644
--- a/bookmarks/views/contexts.py
+++ b/bookmarks/views/contexts.py
@@ -21,7 +21,6 @@ from bookmarks.models import (
 )
 from bookmarks.services.wayback import generate_fallback_webarchive_url
 
-DEFAULT_PAGE_SIZE = 30
 CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
 
 
@@ -181,7 +180,7 @@ class BookmarkListContext:
 
         query_set = request_context.get_bookmark_query_set(self.search)
         page_number = request.GET.get("page")
-        paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
+        paginator = Paginator(query_set, user_profile.items_per_page)
         bookmarks_page = paginator.get_page(page_number)
         # Prefetch related objects, this avoids n+1 queries when accessing fields in templates
         models.prefetch_related_objects(bookmarks_page.object_list, "owner", "tags")
diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py
index 3566d85..2f5f88e 100644
--- a/bookmarks/views/settings.py
+++ b/bookmarks/views/settings.py
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
 
 
 @login_required
-def general(request):
+def general(request, status=200, context_overrides=None):
     enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
     has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
     success_message = _find_message_with_tag(
@@ -44,6 +44,9 @@ def general(request):
     if request.user.is_superuser:
         global_settings_form = GlobalSettingsForm(instance=GlobalSettings.get())
 
+    if context_overrides is None:
+        context_overrides = {}
+
     return render(
         request,
         "settings/general.html",
@@ -55,7 +58,9 @@ def general(request):
             "success_message": success_message,
             "error_message": error_message,
             "version_info": version_info,
+            **context_overrides,
         },
+        status=status,
     )
 
 
@@ -63,8 +68,7 @@ def general(request):
 def update(request):
     if request.method == "POST":
         if "update_profile" in request.POST:
-            update_profile(request)
-            messages.success(request, "Profile updated", "settings_success_message")
+            return update_profile(request)
         if "update_global_settings" in request.POST:
             update_global_settings(request)
             messages.success(
@@ -101,13 +105,22 @@ def update_profile(request):
     form = UserProfileForm(request.POST, instance=profile)
     if form.is_valid():
         form.save()
+        messages.success(request, "Profile updated", "settings_success_message")
         # Load missing favicons if the feature was just enabled
         if profile.enable_favicons and not favicons_were_enabled:
             tasks.schedule_bookmarks_without_favicons(request.user)
         # Load missing preview images if the feature was just enabled
         if profile.enable_preview_images and not previews_were_enabled:
             tasks.schedule_bookmarks_without_previews(request.user)
-    return form
+
+        return HttpResponseRedirect(reverse("bookmarks:settings.general"))
+
+    messages.error(
+        request,
+        "Profile update failed, check the form below for errors",
+        "settings_error_message",
+    )
+    return general(request, 422, {"form": form})
 
 
 def update_global_settings(request):


Test patch:
diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py
index 8a6f5d0..4cc6bdc 100644
--- a/bookmarks/tests/test_bookmarks_list_template.py
+++ b/bookmarks/tests/test_bookmarks_list_template.py
@@ -955,3 +955,37 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         self.assertInHTML(
             '<p class="empty-title h5">You have no bookmarks yet</p>', html
         )
+
+    def test_pagination_is_not_sticky_by_default(self):
+        self.setup_bookmark()
+        html = self.render_template()
+
+        self.assertIn('<div class="bookmark-pagination">', html)
+
+    def test_pagination_is_sticky_when_enabled_in_profile(self):
+        self.setup_bookmark()
+        profile = self.get_or_create_test_user().profile
+        profile.sticky_pagination = True
+        profile.save()
+        html = self.render_template()
+
+        self.assertIn('<div class="bookmark-pagination sticky">', html)
+
+    def test_items_per_page_is_30_by_default(self):
+        self.setup_numbered_bookmarks(50)
+        html = self.render_template()
+
+        soup = self.make_soup(html)
+        bookmarks = soup.select("li[ld-bookmark-item]")
+        self.assertEqual(30, len(bookmarks))
+
+    def test_items_per_page_is_configurable(self):
+        self.setup_numbered_bookmarks(50)
+        profile = self.get_or_create_test_user().profile
+        profile.items_per_page = 10
+        profile.save()
+        html = self.render_template()
+
+        soup = self.make_soup(html)
+        bookmarks = soup.select("li[ld-bookmark-item]")
+        self.assertEqual(10, len(bookmarks))
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index c9c2bd9..3aaab13 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -43,6 +43,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
             "permanent_notes": False,
             "custom_css": "",
             "auto_tagging_rules": "",
+            "items_per_page": "30",
+            "sticky_pagination": False,
         }
 
         return {**form_data, **overrides}
@@ -111,6 +113,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
             "default_mark_unread": True,
             "custom_css": "body { background-color: #000; }",
             "auto_tagging_rules": "example.com tag",
+            "items_per_page": "10",
+            "sticky_pagination": True,
         }
         response = self.client.post(
             reverse("bookmarks:settings.update"), form_data, follow=True
@@ -182,6 +186,13 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
         self.assertEqual(
             self.user.profile.auto_tagging_rules, form_data["auto_tagging_rules"]
         )
+        self.assertEqual(
+            self.user.profile.items_per_page, int(form_data["items_per_page"])
+        )
+        self.assertEqual(
+            self.user.profile.sticky_pagination, form_data["sticky_pagination"]
+        )
+
         self.assertSuccessMessage(html, "Profile updated")
 
     def test_update_profile_should_not_be_called_without_respective_form_action(self):