Repository: sissbruecker/linkding
Pull request #666 (2024-03-29 22:02:49)

Make bookmark list actions configurable
Adds settings for configuring which actions are shown below each bookmark in the bookmark list. This allows to hide actions that are never or rarely used.

![Bildschirmfoto 2024-03-29 um 23 02 14](https://github.com/sissbruecker/linkding/assets/357820/c4c15de9-f11e-4488-a1a7-d81c9c252d5f)



Patch:
diff --git a/bookmarks/migrations/0028_userprofile_display_archive_bookmark_action_and_more.py b/bookmarks/migrations/0028_userprofile_display_archive_bookmark_action_and_more.py
new file mode 100644
index 0000000..6af3d30
--- /dev/null
+++ b/bookmarks/migrations/0028_userprofile_display_archive_bookmark_action_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.0.2 on 2024-03-29 20:05
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookmarks", "0027_userprofile_bookmark_description_display_and_more"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="userprofile",
+            name="display_archive_bookmark_action",
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name="userprofile",
+            name="display_edit_bookmark_action",
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name="userprofile",
+            name="display_remove_bookmark_action",
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name="userprofile",
+            name="display_view_bookmark_action",
+            field=models.BooleanField(default=True),
+        ),
+    ]
diff --git a/bookmarks/migrations/0029_bookmark_list_actions_toast.py b/bookmarks/migrations/0029_bookmark_list_actions_toast.py
new file mode 100644
index 0000000..05891b4
--- /dev/null
+++ b/bookmarks/migrations/0029_bookmark_list_actions_toast.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.0.2 on 2024-03-29 21:25
+
+from django.db import migrations
+from django.contrib.auth import get_user_model
+
+from bookmarks.models import Toast
+
+User = get_user_model()
+
+
+def forwards(apps, schema_editor):
+
+    for user in User.objects.all():
+        toast = Toast(
+            key="bookmark_list_actions_hint",
+            message="This version adds a new link to each bookmark to view details in a dialog. If you feel there is too much clutter you can now hide individual links in the settings.",
+            owner=user,
+        )
+        toast.save()
+
+
+def reverse(apps, schema_editor):
+    Toast.objects.filter(key="bookmark_list_actions_hint").delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookmarks", "0028_userprofile_display_archive_bookmark_action_and_more"),
+    ]
+
+    operations = [
+        migrations.RunPython(forwards, reverse),
+    ]
diff --git a/bookmarks/models.py b/bookmarks/models.py
index 126353e..c321dad 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -346,6 +346,10 @@ class UserProfile(models.Model):
     enable_public_sharing = models.BooleanField(default=False, null=False)
     enable_favicons = models.BooleanField(default=False, null=False)
     display_url = models.BooleanField(default=False, null=False)
+    display_view_bookmark_action = models.BooleanField(default=True, null=False)
+    display_edit_bookmark_action = models.BooleanField(default=True, null=False)
+    display_archive_bookmark_action = models.BooleanField(default=True, null=False)
+    display_remove_bookmark_action = models.BooleanField(default=True, null=False)
     permanent_notes = models.BooleanField(default=False, null=False)
     custom_css = models.TextField(blank=True, null=False)
     search_preferences = models.JSONField(default=dict, null=False)
@@ -366,6 +370,10 @@ class UserProfileForm(forms.ModelForm):
             "enable_public_sharing",
             "enable_favicons",
             "display_url",
+            "display_view_bookmark_action",
+            "display_edit_bookmark_action",
+            "display_archive_bookmark_action",
+            "display_remove_bookmark_action",
             "permanent_notes",
             "custom_css",
         ]
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index 3c2c3c8..bc1808c 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -79,25 +79,33 @@
             {% endif %}
             <span>|</span>
           {% endif %}
-          {# View link is always visible #}
-          <a ld-modal
-             modal-url="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
-             href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
+          {# View link is visible for both owned and shared bookmarks #}
+          {% if bookmark_list.show_view_action %}
+            <a ld-modal
+               modal-url="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
+               href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
+          {% endif %}
           {% if bookmark_item.is_editable %}
             {# Bookmark owner actions #}
-            <a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
-            {% if bookmark_item.is_archived %}
-              <button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
-                      class="btn btn-link btn-sm">Unarchive
-              </button>
-            {% else %}
-              <button type="submit" name="archive" value="{{ bookmark_item.id }}"
-                      class="btn btn-link btn-sm">Archive
+            {% if bookmark_list.show_edit_action %}
+              <a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
+            {% endif %}
+            {% if bookmark_list.show_archive_action %}
+              {% if bookmark_item.is_archived %}
+                <button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
+                        class="btn btn-link btn-sm">Unarchive
+                </button>
+              {% else %}
+                <button type="submit" name="archive" value="{{ bookmark_item.id }}"
+                        class="btn btn-link btn-sm">Archive
+                </button>
+              {% endif %}
+            {% endif %}
+            {% if bookmark_list.show_remove_action %}
+              <button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
+                      class="btn btn-link btn-sm">Remove
               </button>
             {% endif %}
-            <button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
-                    class="btn btn-link btn-sm">Remove
-            </button>
           {% else %}
             {# Shared bookmark actions #}
             <span>Shared by
diff --git a/bookmarks/templates/bookmarks/layout.html b/bookmarks/templates/bookmarks/layout.html
index fca6a9a..b75959d 100644
--- a/bookmarks/templates/bookmarks/layout.html
+++ b/bookmarks/templates/bookmarks/layout.html
@@ -105,9 +105,9 @@
       <form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
         {% csrf_token %}
         {% for toast in toast_messages %}
-          <div class="toast">
+          <div class="toast d-flex">
             {{ toast.message }}
-            <button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear float-right"></button>
+            <button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear"></button>
           </div>
         {% endfor %}
       </form>
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index a8ff541..c71696b 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -39,7 +39,8 @@
         </div>
         <div
             class="form-group {% if request.user_profile.bookmark_description_display == 'inline' %}d-hide{% endif %}">
-          <label for="{{ form.bookmark_description_max_lines.id_for_label }}" class="form-label">Bookmark description max lines</label>
+          <label for="{{ form.bookmark_description_max_lines.id_for_label }}" class="form-label">Bookmark description
+            max lines</label>
           {{ form.bookmark_description_max_lines|add_class:"form-input width-25 width-sm-100" }}
           <div class="form-input-hint">
             Limits the number of lines that are displayed for the bookmark description.
@@ -64,6 +65,28 @@
             Alternatively the keyboard shortcut <code>e</code> can be used to temporarily show all notes.
           </div>
         </div>
+        <div class="form-group">
+          <label>Bookmark actions</label>
+          <label for="{{ form.display_view_bookmark_action.id_for_label }}" class="form-checkbox">
+            {{ form.display_view_bookmark_action }}
+            <i class="form-icon"></i> View
+          </label>
+          <label for="{{ form.display_edit_bookmark_action.id_for_label }}" class="form-checkbox">
+            {{ form.display_edit_bookmark_action }}
+            <i class="form-icon"></i> Edit
+          </label>
+          <label for="{{ form.display_archive_bookmark_action.id_for_label }}" class="form-checkbox">
+            {{ form.display_archive_bookmark_action }}
+            <i class="form-icon"></i> Archive
+          </label>
+          <label for="{{ form.display_remove_bookmark_action.id_for_label }}" class="form-checkbox">
+            {{ form.display_remove_bookmark_action }}
+            <i class="form-icon"></i> Remove
+          </label>
+          <div class="form-input-hint">
+            Which actions to display for each bookmark.
+          </div>
+        </div>
         <div class="form-group">
           <label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
           {{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }}
diff --git a/bookmarks/views/partials/contexts.py b/bookmarks/views/partials/contexts.py
index 8a6a79c..2320268 100644
--- a/bookmarks/views/partials/contexts.py
+++ b/bookmarks/views/partials/contexts.py
@@ -99,6 +99,10 @@ class BookmarkListContext:
         self.description_display = user_profile.bookmark_description_display
         self.description_max_lines = user_profile.bookmark_description_max_lines
         self.show_url = user_profile.display_url
+        self.show_view_action = user_profile.display_view_bookmark_action
+        self.show_edit_action = user_profile.display_edit_bookmark_action
+        self.show_archive_action = user_profile.display_archive_bookmark_action
+        self.show_remove_action = user_profile.display_remove_bookmark_action
         self.show_favicons = user_profile.enable_favicons
         self.show_notes = user_profile.permanent_notes
 


Test patch:
diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py
index e7e8311..e76bc7c 100644
--- a/bookmarks/tests/test_bookmarks_list_template.py
+++ b/bookmarks/tests/test_bookmarks_list_template.py
@@ -61,6 +61,20 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
 
     def assertViewLink(
         self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
+    ):
+        self.assertViewLinkCount(html, bookmark, return_url=return_url)
+
+    def assertNoViewLink(
+        self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
+    ):
+        self.assertViewLinkCount(html, bookmark, count=0, return_url=return_url)
+
+    def assertViewLinkCount(
+        self,
+        html: str,
+        bookmark: Bookmark,
+        count=1,
+        return_url=reverse("bookmarks:index"),
     ):
         details_url = reverse("bookmarks:details", args=[bookmark.id])
         details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id])
@@ -69,17 +83,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
                 <a ld-modal modal-url="{details_modal_url}?return_url={return_url}" href="{details_url}">View</a>
             """,
             html,
-            count=1,
+            count=count,
         )
 
-    def assertBookmarkActions(self, html: str, bookmark: Bookmark):
-        self.assertBookmarkActionsCount(html, bookmark, count=1)
-
-    def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):
-        self.assertBookmarkActionsCount(html, bookmark, count=0)
-
-    def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
-        # Edit link
+    def assertEditLinkCount(self, html: str, bookmark: Bookmark, count=1):
         edit_url = reverse("bookmarks:edit", args=[bookmark.id])
         self.assertInHTML(
             f"""
@@ -88,7 +95,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
             html,
             count=count,
         )
-        # Archive link
+
+    def assertArchiveLinkCount(self, html: str, bookmark: Bookmark, count=1):
         self.assertInHTML(
             f"""
             <button type="submit" name="archive" value="{bookmark.id}"
@@ -97,7 +105,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
             html,
             count=count,
         )
-        # Delete link
+
+    def assertDeleteLinkCount(self, html: str, bookmark: Bookmark, count=1):
         self.assertInHTML(
             f"""
             <button ld-confirm-button type="submit" name="remove" value="{bookmark.id}"
@@ -107,6 +116,17 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
             count=count,
         )
 
+    def assertBookmarkActions(self, html: str, bookmark: Bookmark):
+        self.assertBookmarkActionsCount(html, bookmark, count=1)
+
+    def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):
+        self.assertBookmarkActionsCount(html, bookmark, count=0)
+
+    def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
+        self.assertEditLinkCount(html, bookmark, count=count)
+        self.assertArchiveLinkCount(html, bookmark, count=count)
+        self.assertDeleteLinkCount(html, bookmark, count=count)
+
     def assertShareInfo(self, html: str, bookmark: Bookmark):
         self.assertShareInfoCount(html, bookmark, 1)
 
@@ -535,6 +555,54 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         self.assertBookmarkActions(html, bookmark)
         self.assertNoShareInfo(html, bookmark)
 
+    def test_hide_view_link(self):
+        bookmark = self.setup_bookmark()
+        profile = self.get_or_create_test_user().profile
+        profile.display_view_bookmark_action = False
+        profile.save()
+
+        html = self.render_template()
+        self.assertViewLinkCount(html, bookmark, count=0)
+        self.assertEditLinkCount(html, bookmark, count=1)
+        self.assertArchiveLinkCount(html, bookmark, count=1)
+        self.assertDeleteLinkCount(html, bookmark, count=1)
+
+    def test_hide_edit_link(self):
+        bookmark = self.setup_bookmark()
+        profile = self.get_or_create_test_user().profile
+        profile.display_edit_bookmark_action = False
+        profile.save()
+
+        html = self.render_template()
+        self.assertViewLinkCount(html, bookmark, count=1)
+        self.assertEditLinkCount(html, bookmark, count=0)
+        self.assertArchiveLinkCount(html, bookmark, count=1)
+        self.assertDeleteLinkCount(html, bookmark, count=1)
+
+    def test_hide_archive_link(self):
+        bookmark = self.setup_bookmark()
+        profile = self.get_or_create_test_user().profile
+        profile.display_archive_bookmark_action = False
+        profile.save()
+
+        html = self.render_template()
+        self.assertViewLinkCount(html, bookmark, count=1)
+        self.assertEditLinkCount(html, bookmark, count=1)
+        self.assertArchiveLinkCount(html, bookmark, count=0)
+        self.assertDeleteLinkCount(html, bookmark, count=1)
+
+    def test_hide_remove_link(self):
+        bookmark = self.setup_bookmark()
+        profile = self.get_or_create_test_user().profile
+        profile.display_remove_bookmark_action = False
+        profile.save()
+
+        html = self.render_template()
+        self.assertViewLinkCount(html, bookmark, count=1)
+        self.assertEditLinkCount(html, bookmark, count=1)
+        self.assertArchiveLinkCount(html, bookmark, count=1)
+        self.assertDeleteLinkCount(html, bookmark, count=0)
+
     def test_show_share_info_for_non_owned_bookmarks(self):
         other_user = User.objects.create_user(
             "otheruser", "otheruser@example.com", "password123"
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index 61058e9..7f8e7c8 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -33,6 +33,10 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
             "enable_favicons": False,
             "tag_search": UserProfile.TAG_SEARCH_STRICT,
             "display_url": False,
+            "display_view_bookmark_action": True,
+            "display_edit_bookmark_action": True,
+            "display_archive_bookmark_action": True,
+            "display_remove_bookmark_action": True,
             "permanent_notes": False,
             "custom_css": "",
         }
@@ -67,6 +71,10 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
             "enable_favicons": True,
             "tag_search": UserProfile.TAG_SEARCH_LAX,
             "display_url": True,
+            "display_view_bookmark_action": False,
+            "display_edit_bookmark_action": False,
+            "display_archive_bookmark_action": False,
+            "display_remove_bookmark_action": False,
             "permanent_notes": True,
             "custom_css": "body { background-color: #000; }",
         }
@@ -104,6 +112,22 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
         )
         self.assertEqual(self.user.profile.tag_search, form_data["tag_search"])
         self.assertEqual(self.user.profile.display_url, form_data["display_url"])
+        self.assertEqual(
+            self.user.profile.display_view_bookmark_action,
+            form_data["display_view_bookmark_action"],
+        )
+        self.assertEqual(
+            self.user.profile.display_edit_bookmark_action,
+            form_data["display_edit_bookmark_action"],
+        )
+        self.assertEqual(
+            self.user.profile.display_archive_bookmark_action,
+            form_data["display_archive_bookmark_action"],
+        )
+        self.assertEqual(
+            self.user.profile.display_remove_bookmark_action,
+            form_data["display_remove_bookmark_action"],
+        )
         self.assertEqual(
             self.user.profile.permanent_notes, form_data["permanent_notes"]
         )
diff --git a/bookmarks/tests/test_toasts_view.py b/bookmarks/tests/test_toasts_view.py
index 451288b..23bb426 100644
--- a/bookmarks/tests/test_toasts_view.py
+++ b/bookmarks/tests/test_toasts_view.py
@@ -40,7 +40,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
         # Should render toasts container
         self.assertContains(response, '<div class="toasts">')
         # Should render two toasts
-        self.assertContains(response, '<div class="toast">', count=2)
+        self.assertContains(response, '<div class="toast d-flex">', count=2)
 
     def test_should_not_render_acknowledged_toasts(self):
         self.create_toast(acknowledged=True)
@@ -81,9 +81,9 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
     def test_toast_content(self):
         toast = self.create_toast()
         expected_toast = f"""
-            <div class="toast">
+            <div class="toast d-flex">
                 {toast.message}
-                <button type="submit" name="toast" value="{toast.id}" class="btn btn-clear float-right"></button>
+                <button type="submit" name="toast" value="{toast.id}" class="btn btn-clear"></button>
             </div>        
         """