Display shared state in bookmark list (#515)

* Add unshare action

* Show shared state in bookmark list

* Update tests

* Reflect unread and shared state as CSS class
This commit is contained in:
Sascha Ißbrücker 2023-08-24 19:11:36 +02:00 committed by GitHub
parent bca9bf9b11
commit 2ceac9a87d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 422 additions and 141 deletions

View file

@ -119,10 +119,27 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).to_have_class('text-italic')
self.locate_bookmark('Bookmark 2').get_by_text('Mark as read').click()
expect(self.locate_bookmark('Bookmark 2')).to_have_class('unread')
self.locate_bookmark('Bookmark 2').get_by_text('Unread').click()
self.locate_bookmark('Bookmark 2').get_by_text('Yes').click()
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).not_to_have_class('text-italic')
expect(self.locate_bookmark('Bookmark 2')).not_to_have_class('unread')
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_unshare(self):
self.setup_fixture()
bookmark2 = self.get_numbered_bookmark('Bookmark 2')
bookmark2.shared = True
bookmark2.save()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
expect(self.locate_bookmark('Bookmark 2')).to_have_class('shared')
self.locate_bookmark('Bookmark 2').get_by_text('Shared').click()
self.locate_bookmark('Bookmark 2').get_by_text('Yes').click()
expect(self.locate_bookmark('Bookmark 2')).not_to_have_class('shared')
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_bulk_archive(self):

View file

@ -16,9 +16,31 @@ class ConfirmButtonBehavior {
onClick(event) {
event.preventDefault();
const container = document.createElement("span");
container.className = "confirmation";
const icon = this.button.getAttribute("confirm-icon");
if (icon) {
const iconElement = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
iconElement.style.width = "16px";
iconElement.style.height = "16px";
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
container.append(iconElement);
}
const question = this.button.getAttribute("confirm-question");
if (question) {
const questionElement = document.createElement("span");
questionElement.innerText = question;
container.append(question);
}
const cancelButton = document.createElement(this.button.nodeName);
cancelButton.type = "button";
cancelButton.innerText = "Cancel";
cancelButton.innerText = question ? "No" : "Cancel";
cancelButton.className = "btn btn-link btn-sm mr-1";
cancelButton.addEventListener("click", this.reset.bind(this));
@ -26,12 +48,10 @@ class ConfirmButtonBehavior {
confirmButton.type = this.button.dataset.type;
confirmButton.name = this.button.dataset.name;
confirmButton.value = this.button.dataset.value;
confirmButton.innerText = "Confirm";
confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = "btn btn-link btn-sm";
confirmButton.addEventListener("click", this.reset.bind(this));
const container = document.createElement("span");
container.className = "confirmation";
container.append(cancelButton, confirmButton);
this.container = container;

View file

@ -65,13 +65,19 @@ section.content-area {
span.confirmation {
display: flex;
align-items: baseline;
}
span.confirmation .btn.btn-link {
gap: $unit-1;
color: $error-color !important;
&:hover {
text-decoration: underline;
svg {
align-self: center;
}
.btn.btn-link {
color: $error-color !important;
&:hover {
text-decoration: underline;
}
}
}
@ -116,3 +122,13 @@ span.confirmation .btn.btn-link {
padding-left: $unit-6;
padding-right: $unit-6;
}
.btn.btn-sm.btn-icon {
display: inline-flex;
align-items: baseline;
gap: $unit-h;
svg {
align-self: center;
}
}

View file

@ -66,6 +66,10 @@ li[ld-bookmark-item] {
text-overflow: ellipsis;
}
&.unread .title a {
font-style: italic;
}
.title img {
width: 16px;
height: 16px;
@ -85,11 +89,18 @@ li[ld-bookmark-item] {
}
}
.actions {
.actions, .extra-actions {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: $unit-2;
column-gap: $unit-2;
}
@media (max-width: $size-sm) {
.extra-actions {
width: 100%;
margin-top: $unit-1;
}
}
.actions {
@ -113,13 +124,6 @@ li[ld-bookmark-item] {
.separator {
align-self: flex-start;
}
.toggle-notes {
align-self: center;
display: flex;
align-items: center;
gap: $unit-h;
}
}
}

View file

@ -6,124 +6,115 @@
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}">
{% for bookmark in bookmark_list.bookmarks_page %}
<li ld-bookmark-item>
{% for bookmark_item in bookmark_list.items %}
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
<label ld-bulk-edit-checkbox class="form-checkbox">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
<i class="form-icon"></i>
</label>
<div class="title">
<a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
class="{% if bookmark.unread %}text-italic{% endif %}">
{% if bookmark.favicon_file and bookmark_list.show_favicons %}
<img src="{% static bookmark.favicon_file %}" alt="">
<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 %}
{{ bookmark.resolved_title }}
{{ bookmark_item.title }}
</a>
</div>
{% if bookmark_list.show_url %}
<div class="url-path truncate">
<a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
class="url-display text-sm">
{{ bookmark.url }}
{{ bookmark_item.url }}
</a>
</div>
{% endif %}
<div class="description truncate">
{% if bookmark.tag_names %}
{% if bookmark_item.tag_names %}
<span>
{% for tag_name in bookmark.tag_names %}
{% 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.tag_names and bookmark.resolved_description %} | {% endif %}
{% if bookmark.resolved_description %}
<span>{{ bookmark.resolved_description }}</span>
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
{% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span>
{% endif %}
</div>
{% if bookmark.notes %}
{% if bookmark_item.notes %}
<div class="notes bg-gray text-gray-dark">
<div class="notes-content">
{% markdown bookmark.notes %}
{% markdown bookmark_item.notes %}
</div>
</div>
{% endif %}
<div class="actions text-gray text-sm">
{% if bookmark_list.date_display == 'relative' %}
<span>
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}"
rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
</a>
{% endif %}
</span>
{% if bookmark_item.display_date %}
{% if bookmark_item.web_archive_snapshot_url %}
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}"
rel="noopener">
{{ bookmark_item.display_date }} ∞
</a>
{% else %}
<span>{{ bookmark_item.display_date }}</span>
{% endif %}
<span class="separator">|</span>
{% endif %}
{% if bookmark_list.date_display == 'absolute' %}
<span>
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}"
rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
</a>
{% endif %}
</span>
<span class="separator">|</span>
{% endif %}
{% if bookmark.owner == request.user %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ bookmark_list.return_url }}">Edit</a>
{% if bookmark.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url }}">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.id }}"
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark.id }}"
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove
</button>
{% if bookmark.unread %}
<span class="separator">|</span>
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Mark as read
</button>
{% endif %}
{% else %}
{# Shared bookmark actions #}
<span>Shared by
<a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
</span>
{% endif %}
{% if bookmark.notes and not bookmark_list.show_notes %}
<span class="separator">|</span>
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16"
height="16"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
<path d="M9 7l6 0"></path>
<path d="M9 11l6 0"></path>
<path d="M9 15l4 0"></path>
</svg>
<span>Notes</span>
</button>
{% if bookmark_item.has_extra_actions %}
<div class="extra-actions">
<span class="separator hide-sm">|</span>
{% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use>
</svg>
Unread
</button>
{% endif %}
{% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
Shared
</button>
{% endif %}
{% if bookmark_item.show_notes_button %}
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
</svg>
Notes
</button>
{% endif %}
</div>
{% endif %}
</div>
</li>

View file

@ -30,6 +30,66 @@
{% endif %}
</head>
<body ld-global-shortcuts>
<div class="d-none">
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-unread" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
<path d="M3 6l0 13"></path>
<path d="M12 6l0 13"></path>
<path d="M21 6l0 13"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-read" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 5.899 -1.096"></path>
<path d="M3 6a9 9 0 0 1 2.114 -.884m3.8 -.21c1.07 .17 2.116 .534 3.086 1.094a9 9 0 0 1 9 0"></path>
<path d="M3 6v13"></path>
<path d="M12 6v2m0 4v7"></path>
<path d="M21 6v11"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 18m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M8.7 10.7l6.6 -3.4"></path>
<path d="M8.7 13.3l6.6 3.4"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-unshare" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M15.861 15.896a3 3 0 0 0 4.265 4.22m.578 -3.417a3.012 3.012 0 0 0 -1.507 -1.45"></path>
<path d="M8.7 10.7l1.336 -.688m2.624 -1.352l2.64 -1.36"></path>
<path d="M8.7 13.3l6.6 3.4"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
<path d="M9 7l6 0"></path>
<path d="M9 11l6 0"></path>
<path d="M9 15l4 0"></path>
</symbol>
</svg>
</div>
<header class="container">
{% if has_toasts %}
<div class="toasts">

View file

@ -94,6 +94,30 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertFalse(bookmark.unread)
def test_unshare_should_unshare_bookmark(self):
bookmark = self.setup_bookmark(shared=True)
self.client.post(reverse('bookmarks:action'), {
'unshare': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertFalse(bookmark.shared)
def test_can_only_unshare_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user, shared=True)
response = self.client.post(reverse('bookmarks:action'), {
'unshare': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertEqual(response.status_code, 404)
self.assertTrue(bookmark.shared)
def test_bulk_archive(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()

View file

@ -20,7 +20,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html
)
@ -29,7 +29,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html,
count=0
)

View file

@ -21,7 +21,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html
)
@ -30,7 +30,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html,
count=0
)

View file

@ -16,13 +16,13 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
def assertBookmarkCount(self, html: str, bookmark: Bookmark, count: int, link_target: str = '_blank'):
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html, count=count
)
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
self.assertContains(response, '<li ld-bookmark-item class="shared">', count=len(bookmarks))
for bookmark in bookmarks:
self.assertBookmarkCount(html, bookmark, 1, link_target)

View file

@ -28,7 +28,7 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks)
self.assertContains(response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks)
number_of_queries = context.final_queries
@ -41,4 +41,4 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks)
self.assertContains(response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks + num_additional_bookmarks)

View file

@ -17,14 +17,12 @@ from bookmarks.views.partials import contexts
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
unread = bookmark.unread
favicon_img = f'<img src="/static/{bookmark.favicon_file}" alt="">' if bookmark.favicon_file else ''
self.assertInHTML(
f'''
<a href="{bookmark.url}"
target="{link_target}"
rel="noopener"
class="{'text-italic' if unread else ''}">
rel="noopener">
{favicon_img}
{bookmark.resolved_title}
</a>
@ -34,21 +32,16 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def assertDateLabel(self, html: str, label_content: str):
self.assertInHTML(f'''
<span>
<span>{label_content}</span>
</span>
<span>{label_content}</span>
<span class="separator">|</span>
''', html)
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
self.assertInHTML(f'''
<span>
<a href="{url}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
<span>{label_content}</span>
</a>
</span>
<a href="{url}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
{label_content}
</a>
<span class="separator">|</span>
''', html)
@ -126,18 +119,36 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def assertNotesToggle(self, html: str, count=1):
self.assertInHTML(f'''
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
<path d="M9 7l6 0"></path>
<path d="M9 11l6 0"></path>
<path d="M9 15l4 0"></path>
</svg>
<span>Notes</span>
</button>
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
</svg>
Notes
</button>
''', html, count=count)
def assertUnshareButton(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
<button type="submit" name="unshare" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
Shared
</button>
''', html, count=count)
def assertMarkAsReadButton(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
<button type="submit" name="mark_as_read" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use>
</svg>
Unread
</button>
''', html, count=count)
def render_template(self,
@ -236,11 +247,31 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')
def test_should_respect_unread_flag(self):
bookmark = self.setup_bookmark(unread=True)
def test_should_reflect_unread_state_as_css_class(self):
self.setup_bookmark(unread=True)
html = self.render_template()
self.assertBookmarksLink(html, bookmark)
self.assertIn('<li ld-bookmark-item class="unread">', html)
def test_should_reflect_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
self.setup_bookmark(shared=True)
html = self.render_template()
self.assertIn('<li ld-bookmark-item class="shared">', html)
def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
self.setup_bookmark(unread=True, shared=True)
html = self.render_template()
self.assertIn('<li ld-bookmark-item class="unread shared">', html)
def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark()
@ -333,6 +364,66 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertBookmarkURLHidden(html, bookmark)
def test_show_mark_as_read_when_unread(self):
bookmark = self.setup_bookmark(unread=True)
html = self.render_template()
self.assertMarkAsReadButton(html, bookmark)
def test_hide_mark_as_read_when_read(self):
bookmark = self.setup_bookmark(unread=False)
html = self.render_template()
self.assertMarkAsReadButton(html, bookmark, count=0)
def test_hide_mark_as_read_for_non_owned_bookmarks(self):
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True, unread=True)
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
self.assertBookmarksLink(html, bookmark)
self.assertMarkAsReadButton(html, bookmark, count=0)
def test_show_unshare_button_when_shared(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
bookmark = self.setup_bookmark(shared=True)
html = self.render_template()
self.assertUnshareButton(html, bookmark)
def test_hide_unshare_button_when_not_shared(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
bookmark = self.setup_bookmark(shared=False)
html = self.render_template()
self.assertUnshareButton(html, bookmark, count=0)
def test_hide_unshare_button_when_sharing_is_disabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = False
profile.save()
bookmark = self.setup_bookmark(shared=True)
html = self.render_template()
self.assertUnshareButton(html, bookmark, count=0)
def test_hide_unshare_for_non_owned_bookmarks(self):
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
self.assertBookmarksLink(html, bookmark)
self.assertUnshareButton(html, bookmark, count=0)
def test_without_notes(self):
self.setup_bookmark()
html = self.render_template()
@ -425,6 +516,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
bookmark.notes = '**Example:** `print("Hello world!")`'
bookmark.favicon_file = 'https_example_com.png'
bookmark.shared = True
bookmark.unread = True
bookmark.save()
html = self.render_template(context_type=contexts.SharedBookmarkListContext, user=AnonymousUser())
@ -432,6 +524,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark)
self.assertMarkAsReadButton(html, bookmark, count=0)
self.assertUnshareButton(html, bookmark, count=0)
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
self.assertNotes(html, note_html, 1)
self.assertFaviconVisible(html, bookmark)

View file

@ -145,6 +145,16 @@ def unarchive(request, bookmark_id: int):
unarchive_bookmark(bookmark)
def unshare(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404('Bookmark does not exist')
bookmark.shared = False
bookmark.save()
def mark_as_read(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
@ -166,6 +176,8 @@ def action(request):
remove(request, request.POST['remove'])
if 'mark_as_read' in request.POST:
mark_as_read(request, request.POST['mark_as_read'])
if 'unshare' in request.POST:
unshare(request, request.POST['unshare'])
if 'bulk_archive' in request.POST:
bookmark_ids = request.POST.getlist('bookmark_id')
archive_bookmarks(bookmark_ids, request.user)

View file

@ -7,17 +7,58 @@ from django.db import models
from django.urls import reverse
from bookmarks import queries
from bookmarks.models import BookmarkFilters, User, UserProfile, Tag
from bookmarks.utils import unique
from bookmarks.models import Bookmark, BookmarkFilters, User, UserProfile, Tag
from bookmarks import utils
DEFAULT_PAGE_SIZE = 30
class BookmarkItem:
def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None:
self.bookmark = bookmark
is_editable = bookmark.owner == user
self.is_editable = is_editable
self.id = bookmark.id
self.url = bookmark.url
self.title = bookmark.resolved_title
self.description = bookmark.resolved_description
self.notes = bookmark.notes
self.tag_names = bookmark.tag_names
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
self.favicon_file = bookmark.favicon_file
self.is_archived = bookmark.is_archived
self.unread = bookmark.unread
self.owner = bookmark.owner
css_classes = []
if bookmark.unread:
css_classes.append('unread')
if bookmark.shared:
css_classes.append('shared')
self.css_classes = ' '.join(css_classes)
if profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE:
self.display_date = utils.humanize_relative_date(bookmark.date_added)
elif profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE:
self.display_date = utils.humanize_absolute_date(bookmark.date_added)
self.show_notes_button = bookmark.notes and not profile.permanent_notes
self.show_mark_as_read = is_editable and bookmark.unread
self.show_unshare = is_editable and bookmark.shared and profile.enable_sharing
self.has_extra_actions = self.show_notes_button or self.show_mark_as_read or self.show_unshare
class BookmarkListContext:
def __init__(self, request: WSGIRequest) -> None:
self.request = request
self.filters = BookmarkFilters(self.request)
user = request.user
user_profile = request.user_profile
query_set = self.get_bookmark_query_set()
page_number = request.GET.get('page')
paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
@ -25,14 +66,16 @@ class BookmarkListContext:
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
models.prefetch_related_objects(bookmarks_page.object_list, 'owner', 'tags')
self.items = [BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page]
self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page
self.return_url = self.generate_return_url(page_number)
self.link_target = request.user_profile.bookmark_link_target
self.date_display = request.user_profile.bookmark_date_display
self.show_url = request.user_profile.display_url
self.show_favicons = request.user_profile.enable_favicons
self.show_notes = request.user_profile.permanent_notes
self.link_target = user_profile.bookmark_link_target
self.date_display = user_profile.bookmark_date_display
self.show_url = user_profile.display_url
self.show_favicons = user_profile.enable_favicons
self.show_notes = user_profile.permanent_notes
def generate_return_url(self, page: int):
base_url = self.get_base_url()
@ -120,8 +163,8 @@ class TagCloudContext:
query_set = self.get_tag_query_set()
tags = list(query_set)
selected_tags = self.get_selected_tags(tags)
unique_tags = unique(tags, key=lambda x: str.lower(x.name))
unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name))
unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name))
unique_selected_tags = utils.unique(selected_tags, key=lambda x: str.lower(x.name))
has_selected_tags = len(unique_selected_tags) > 0
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
groups = TagGroup.create_tag_groups(unselected_tags)