mirror of
https://github.com/sissbruecker/linkding
synced 2024-11-25 04:40:20 +00:00
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:
parent
bca9bf9b11
commit
2ceac9a87d
14 changed files with 422 additions and 141 deletions
|
@ -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):
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -65,14 +65,20 @@ section.content-area {
|
|||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
gap: $unit-1;
|
||||
color: $error-color !important;
|
||||
|
||||
span.confirmation .btn.btn-link {
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: $error-color !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional utilities */
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,125 +6,116 @@
|
|||
{% 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 }}"
|
||||
{% 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">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
∞
|
||||
{{ bookmark_item.display_date }} ∞
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<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>
|
||||
{% 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>
|
||||
<span>Notes</span>
|
||||
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>
|
||||
{% endfor %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 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>
|
||||
∞
|
||||
{label_content} ∞
|
||||
</a>
|
||||
</span>
|
||||
<span class="separator">|</span>
|
||||
''', html)
|
||||
|
||||
|
@ -126,17 +119,35 @@ 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>
|
||||
<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>
|
||||
<span>Notes</span>
|
||||
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)
|
||||
|
||||
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue