Add option for showing bookmark description as separate block (#663)

* Add option for showing bookmark description as separate block

* Use context
This commit is contained in:
Sascha Ißbrücker 2024-03-24 21:31:15 +01:00 committed by GitHub
parent ec34cc523f
commit 9df80e01de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 362 additions and 35 deletions

View file

@ -2,6 +2,7 @@ from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import UserProfile
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
@ -39,3 +40,49 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
expect(enable_sharing).not_to_be_checked()
expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_disabled()
def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_description_display = (
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
)
profile.save()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_hidden()
def test_should_show_bookmark_description_max_lines_when_display_separate(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_description_display = (
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
)
profile.save()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_visible()
def test_should_update_bookmark_description_max_lines_when_changing_display(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_hidden()
display = page.get_by_label("Bookmark description", exact=True)
display.select_option("separate")
expect(max_lines).to_be_visible()
display.select_option("inline")
expect(max_lines).to_be_hidden()

View file

@ -0,0 +1,27 @@
# Generated by Django 5.0.2 on 2024-03-23 21:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0026_userprofile_custom_css"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="bookmark_description_display",
field=models.CharField(
choices=[("inline", "Inline"), ("separate", "Separate")],
default="inline",
max_length=10,
),
),
migrations.AddField(
model_name="userprofile",
name="bookmark_description_max_lines",
field=models.IntegerField(default=1),
),
]

View file

@ -278,6 +278,12 @@ class UserProfile(models.Model):
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, "Absolute"),
(BOOKMARK_DATE_DISPLAY_HIDDEN, "Hidden"),
]
BOOKMARK_DESCRIPTION_DISPLAY_INLINE = "inline"
BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE = "separate"
BOOKMARK_DESCRIPTION_DISPLAY_CHOICES = [
(BOOKMARK_DESCRIPTION_DISPLAY_INLINE, "Inline"),
(BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE, "Separate"),
]
BOOKMARK_LINK_TARGET_BLANK = "_blank"
BOOKMARK_LINK_TARGET_SELF = "_self"
BOOKMARK_LINK_TARGET_CHOICES = [
@ -308,6 +314,16 @@ class UserProfile(models.Model):
blank=False,
default=BOOKMARK_DATE_DISPLAY_RELATIVE,
)
bookmark_description_display = models.CharField(
max_length=10,
choices=BOOKMARK_DESCRIPTION_DISPLAY_CHOICES,
blank=False,
default=BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
)
bookmark_description_max_lines = models.IntegerField(
null=False,
default=1,
)
bookmark_link_target = models.CharField(
max_length=10,
choices=BOOKMARK_LINK_TARGET_CHOICES,
@ -341,6 +357,8 @@ class UserProfileForm(forms.ModelForm):
fields = [
"theme",
"bookmark_date_display",
"bookmark_description_display",
"bookmark_description_max_lines",
"bookmark_link_target",
"web_archive_integration",
"tag_search",

View file

@ -175,7 +175,16 @@ li[ld-bookmark-item] {
.description {
color: $gray-color-dark;
}
.description.separate {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
overflow: hidden;
}
.tags {
a, a:visited:hover {
color: $alternative-color;
}

View file

@ -6,6 +6,7 @@
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
{% for bookmark_item in bookmark_list.items %}
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
@ -14,11 +15,11 @@
<i class="form-icon"></i>
</label>
<div class="title">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener" >
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
<img src="{% static bookmark_item.favicon_file %}" alt="">
{% endif %}
<span>{{ bookmark_item.title }}</span>
<span>{{ bookmark_item.title }}</span>
</a>
</div>
{% if bookmark_list.show_url %}
@ -29,19 +30,34 @@
</a>
</div>
{% endif %}
<div class="description truncate">
{% if bookmark_list.description_display == 'inline' %}
<div class="description inline truncate">
{% if bookmark_item.tag_names %}
<span class="tags">
{% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</span>
{% endif %}
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
{% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span>
{% endif %}
</div>
{% else %}
{% if bookmark_item.description %}
<div class="description separate">
{{ bookmark_item.description }}
</div>
{% endif %}
{% if bookmark_item.tag_names %}
<span>
<div class="tags">
{% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</span>
</div>
{% endif %}
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
{% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span>
{% endif %}
</div>
{% endif %}
{% if bookmark_item.notes %}
<div class="notes bg-gray text-gray-dark">
<div class="notes-content">

View file

@ -29,6 +29,22 @@
be hidden.
</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_description_display.id_for_label }}" class="form-label">Bookmark
description</label>
{{ form.bookmark_description_display|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
Whether to show bookmark descriptions and tags in the same line, or as separate blocks.
</div>
</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>
{{ 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.
</div>
</div>
<div class="form-group">
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
{{ form.display_url }}
@ -124,18 +140,18 @@
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
</div>
</div>
<div class="form-group">
<details {% if form.custom_css.value %}open{% endif %}>
<summary>Custom CSS</summary>
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
<div class="mt-2">
{{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
<div class="form-group">
<details {% if form.custom_css.value %}open{% endif %}>
<summary>Custom CSS</summary>
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
<div class="mt-2">
{{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
</div>
</details>
<div class="form-input-hint">
Allows to add custom CSS to the page.
</div>
</details>
<div class="form-input-hint">
Allows to add custom CSS to the page.
</div>
</div>
<div class="form-group">
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
{% if update_profile_success_message %}
@ -195,10 +211,6 @@
<section class="content-area">
<h2>Export</h2>
<p>Export all bookmarks in Netscape HTML format.</p>
<p>
Note that exporting bookmark notes is currently not supported due to limitations of the format.
For proper backups please use a database backup as described in the documentation.
</p>
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
{% if export_error %}
<div class="has-error">
@ -237,10 +249,12 @@
</div>
<script>
// Automatically disable public bookmark sharing if bookmark sharing is disabled
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
// Automatically disable public bookmark sharing if bookmark sharing is disabled
function updatePublicSharing() {
if (enableSharing.checked) {
enablePublicSharing.disabled = false;
@ -252,6 +266,18 @@
updatePublicSharing();
enableSharing.addEventListener("change", updatePublicSharing);
// Automatically hide the bookmark description max lines input if the description display is set to inline
function updateBookmarkDescriptionMaxLines() {
if (bookmarkDescriptionDisplay.value === "inline") {
bookmarkDescriptionMaxLines.closest(".form-group").classList.add("d-hide");
} else {
bookmarkDescriptionMaxLines.closest(".form-group").classList.remove("d-hide");
}
}
updateBookmarkDescriptionMaxLines();
bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines);
</script>
{% endblock %}

View file

@ -10,11 +10,11 @@ from django.utils import timezone, formats
from bookmarks.middlewares import UserProfileMiddleware
from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views.partials import contexts
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def assertBookmarksLink(
self, html: str, bookmark: Bookmark, link_target: str = "_blank"
@ -241,6 +241,172 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
user.profile.save()
return bookmark
def inline_bookmark_description_test(self, bookmark):
html = self.render_template()
soup = self.make_soup(html)
has_description = bool(bookmark.description)
has_tags = len(bookmark.tags.all()) > 0
# inline description block exists
description = soup.select_one(".description.inline.truncate")
self.assertIsNotNone(description)
# separate description block does not exist
separate_description = soup.select_one(".description.separate")
self.assertIsNone(separate_description)
# one direct child element per description or tags
children = description.find_all(recursive=False)
expected_child_count = (
0 + (1 if has_description else 0) + (1 if has_tags else 0)
)
self.assertEqual(len(children), expected_child_count)
# has separator between description and tags
if has_description and has_tags:
self.assertTrue("|" in description.text)
# contains description text
if has_description:
description_text = description.find("span", text=bookmark.description)
self.assertIsNotNone(description_text)
if not has_tags:
# no tags element
tags = soup.select_one(".tags")
self.assertIsNone(tags)
else:
# tags element exists
tags = soup.select_one(".tags")
self.assertIsNotNone(tags)
# one link for each tag
tag_links = tags.find_all("a")
self.assertEqual(len(tag_links), len(bookmark.tags.all()))
for tag in bookmark.tags.all():
tag_link = tags.find("a", text=f"#{tag.name}")
self.assertIsNotNone(tag_link)
self.assertEqual(tag_link["href"], f"?q=%23{tag.name}")
def test_inline_bookmark_description(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_description_display = (
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
)
profile.save()
# no description, no tags
bookmark = self.setup_bookmark(description="")
self.inline_bookmark_description_test(bookmark)
# with description, no tags
bookmark = self.setup_bookmark(description="Test description")
self.inline_bookmark_description_test(bookmark)
# no description, with tags
Bookmark.objects.all().delete()
bookmark = self.setup_bookmark(
description="", tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()]
)
self.inline_bookmark_description_test(bookmark)
# with description, with tags
Bookmark.objects.all().delete()
bookmark = self.setup_bookmark(
description="Test description",
tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()],
)
self.inline_bookmark_description_test(bookmark)
def separate_bookmark_description_test(self, bookmark):
html = self.render_template()
soup = self.make_soup(html)
has_description = bool(bookmark.description)
has_tags = len(bookmark.tags.all()) > 0
# inline description block does not exist
inline_description = soup.select_one(".description.inline")
self.assertIsNone(inline_description)
if not has_description:
# no description element
description = soup.select_one(".description")
self.assertIsNone(description)
else:
# contains description text
description = soup.select_one(".description.separate")
self.assertIsNotNone(description)
self.assertEqual(description.text.strip(), bookmark.description)
if not has_tags:
# no tags element
tags = soup.select_one(".tags")
self.assertIsNone(tags)
else:
# tags element exists
tags = soup.select_one(".tags")
self.assertIsNotNone(tags)
# one link for each tag
tag_links = tags.find_all("a")
self.assertEqual(len(tag_links), len(bookmark.tags.all()))
for tag in bookmark.tags.all():
tag_link = tags.find("a", text=f"#{tag.name}")
self.assertIsNotNone(tag_link)
self.assertEqual(tag_link["href"], f"?q=%23{tag.name}")
def test_separate_bookmark_description(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_description_display = (
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
)
profile.save()
# no description, no tags
bookmark = self.setup_bookmark(description="")
self.separate_bookmark_description_test(bookmark)
# with description, no tags
bookmark = self.setup_bookmark(description="Test description")
self.separate_bookmark_description_test(bookmark)
# no description, with tags
Bookmark.objects.all().delete()
bookmark = self.setup_bookmark(
description="", tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()]
)
self.separate_bookmark_description_test(bookmark)
# with description, with tags
Bookmark.objects.all().delete()
bookmark = self.setup_bookmark(
description="Test description",
tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()],
)
self.separate_bookmark_description_test(bookmark)
def test_bookmark_description_max_lines(self):
self.setup_bookmark()
html = self.render_template()
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list")
style = bookmark_list["style"]
self.assertIn("--ld-bookmark-description-max-lines:1;", style)
profile = self.get_or_create_test_user().profile
profile.bookmark_description_max_lines = 3
profile.save()
html = self.render_template()
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list")
style = bookmark_list["style"]
self.assertIn("--ld-bookmark-description-max-lines:3;", style)
def test_should_respect_absolute_date_setting(self):
bookmark = self.setup_date_format_test(
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
@ -539,9 +705,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def test_notes_are_hidden_initially_by_default(self):
self.setup_bookmark(notes="Test note")
html = collapse_whitespace(self.render_template())
html = self.render_template()
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
self.assertIsNone(bookmark_list)
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
profile = self.get_or_create_test_user().profile
@ -549,9 +717,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.save()
self.setup_bookmark(notes="Test note")
html = collapse_whitespace(self.render_template())
html = self.render_template()
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
self.assertIsNone(bookmark_list)
def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
profile = self.get_or_create_test_user().profile
@ -559,11 +729,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.save()
self.setup_bookmark(notes="Test note")
html = collapse_whitespace(self.render_template())
html = self.render_template()
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
self.assertIn(
'<ul class="bookmark-list show-notes" data-bookmarks-total="1">', html
)
self.assertIsNotNone(bookmark_list)
def test_toggle_notes_is_visible_by_default(self):
self.setup_bookmark(notes="Test note")

View file

@ -24,6 +24,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
form_data = {
"theme": UserProfile.THEME_AUTO,
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
"bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
"bookmark_description_max_lines": 1,
"bookmark_link_target": UserProfile.BOOKMARK_LINK_TARGET_BLANK,
"web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
"enable_sharing": False,
@ -56,6 +58,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"update_profile": "",
"theme": UserProfile.THEME_DARK,
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
"bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE,
"bookmark_description_max_lines": 3,
"bookmark_link_target": UserProfile.BOOKMARK_LINK_TARGET_SELF,
"web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
"enable_sharing": True,
@ -76,6 +80,14 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.bookmark_date_display, form_data["bookmark_date_display"]
)
self.assertEqual(
self.user.profile.bookmark_description_display,
form_data["bookmark_description_display"],
)
self.assertEqual(
self.user.profile.bookmark_description_max_lines,
form_data["bookmark_description_max_lines"],
)
self.assertEqual(
self.user.profile.bookmark_link_target, form_data["bookmark_link_target"]
)

View file

@ -96,6 +96,8 @@ class BookmarkListContext:
)
self.link_target = user_profile.bookmark_link_target
self.date_display = user_profile.bookmark_date_display
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_favicons = user_profile.enable_favicons
self.show_notes = user_profile.permanent_notes