Allow bulk editing unread and shared state of bookmarks (#517)

* Move bulk actions into select

* Update tests

* Implement bulk read / unread actions

* Implement bulk share/unshare actions

* Show correct archiving actions

* Allow selecting bookmarks across pages

* Dynamically update select across checkbox

* Filter available bulk actions

* Refactor tag autocomplete toggling
This commit is contained in:
Sascha Ißbrücker 2023-08-25 13:54:23 +02:00 committed by GitHub
parent 2ceac9a87d
commit e2e5930985
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1019 additions and 124 deletions

View file

@ -0,0 +1,232 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def setup_test_data(self):
self.setup_numbered_bookmarks(50)
self.setup_numbered_bookmarks(50, archived=True)
self.setup_numbered_bookmarks(50, prefix='foo')
self.setup_numbered_bookmarks(50, archived=True, prefix='foo')
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
def test_active_bookmarks_bulk_select_across(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
def test_archived_bookmarks_bulk_select_across(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
def test_active_bookmarks_bulk_select_across_respects_query(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse('bookmarks:index') + '?q=foo', p)
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
def test_archived_bookmarks_bulk_select_across_respects_query(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived') + '?q=foo', p)
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
def test_select_all_toggles_all_checkboxes(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse('bookmarks:index')
page = self.open(url, p)
self.locate_bulk_edit_toggle().click()
checkboxes = page.locator('label[ld-bulk-edit-checkbox] input')
self.assertEqual(6, checkboxes.count())
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked()
self.locate_bulk_edit_select_all().click()
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).to_be_checked()
self.locate_bulk_edit_select_all().click()
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked()
def test_select_all_shows_select_across(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse('bookmarks:index')
self.open(url, p)
self.locate_bulk_edit_toggle().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).to_be_visible()
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
def test_select_across_is_unchecked_when_toggling_all(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse('bookmarks:index')
self.open(url, p)
self.locate_bulk_edit_toggle().click()
# Show select across, check it
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
expect(self.locate_bulk_edit_select_across()).to_be_checked()
# Hide select across by toggling select all
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
# Show select across again, verify it is unchecked
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_select_across_is_unchecked_when_toggling_bookmark(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse('bookmarks:index')
self.open(url, p)
self.locate_bulk_edit_toggle().click()
# Show select across, check it
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
expect(self.locate_bulk_edit_select_across()).to_be_checked()
# Hide select across by toggling a single bookmark
self.locate_bookmark('Bookmark 1').locator('label[ld-bulk-edit-checkbox]').click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
# Show select across again, verify it is unchecked
self.locate_bookmark('Bookmark 1').locator('label[ld-bulk-edit-checkbox]').click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_execute_resets_all_checkboxes(self):
self.setup_numbered_bookmarks(100)
with sync_playwright() as p:
url = reverse('bookmarks:index')
page = self.open(url, p)
# Select all bookmarks, enable select across
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
# Get reference for bookmark list
bookmark_list = page.locator('ul[ld-bookmark-list]')
# Execute bulk action
self.select_bulk_action('Mark as unread')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
# Verify bulk edit checkboxes are reset
checkboxes = page.locator('label[ld-bulk-edit-checkbox] input')
self.assertEqual(31, checkboxes.count())
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked()
# Toggle select all and verify select across is reset
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_update_select_across_bookmark_count(self):
self.setup_numbered_bookmarks(100)
with sync_playwright() as p:
url = reverse('bookmarks:index')
self.open(url, p)
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_bar().get_by_text('All pages (100 bookmarks)')).to_be_visible()
self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_bar().get_by_text('All pages (70 bookmarks)')).to_be_visible()

View file

@ -150,7 +150,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click() self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Archive').click() self.select_bulk_action('Archive')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click() self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3']) self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
@ -165,7 +166,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click() self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Delete').click() self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click() self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3']) self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
@ -197,7 +199,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3']) self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
self.assertReloads(0) self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_bulk_archive(self): def test_archived_bookmarks_partial_update_on_bulk_unarchive(self):
self.setup_fixture() self.setup_fixture()
with sync_playwright() as p: with sync_playwright() as p:
@ -205,7 +207,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click() self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Archive').click() self.select_bulk_action('Unarchive')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click() self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3']) self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
@ -220,7 +223,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click() self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Delete').click() self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click() self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3']) self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])

View file

@ -41,5 +41,14 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
def locate_bulk_edit_bar(self): def locate_bulk_edit_bar(self):
return self.page.locator('.bulk-edit-bar') return self.page.locator('.bulk-edit-bar')
def locate_bulk_edit_select_all(self):
return self.locate_bulk_edit_bar().locator('label[ld-bulk-edit-checkbox][all]')
def locate_bulk_edit_select_across(self):
return self.locate_bulk_edit_bar().locator('label.select-across')
def locate_bulk_edit_toggle(self): def locate_bulk_edit_toggle(self):
return self.page.get_by_title('Bulk edit') return self.page.get_by_title('Bulk edit')
def select_bulk_action(self, value: str):
return self.locate_bulk_edit_bar().locator('select[name="bulk_action"]').select_option(value)

View file

@ -36,8 +36,18 @@ class BookmarkPage {
swap(this.bookmarkList, bookmarkListHtml); swap(this.bookmarkList, bookmarkListHtml);
swap(this.tagCloud, tagCloudHtml); swap(this.tagCloud, tagCloudHtml);
// Dispatch list updated event
const listElement = this.bookmarkList.querySelector(
"ul[data-bookmarks-total]",
);
const bookmarksTotal =
(listElement && listElement.dataset.bookmarksTotal) || 0;
this.bookmarkList.dispatchEvent( this.bookmarkList.dispatchEvent(
new CustomEvent("bookmark-list-updated", { bubbles: true }), new CustomEvent("bookmark-list-updated", {
bubbles: true,
detail: { bookmarksTotal },
}),
); );
}); });
} }

View file

@ -4,6 +4,9 @@ class BulkEdit {
constructor(element) { constructor(element) {
this.element = element; this.element = element;
this.active = false; this.active = false;
this.actionSelect = element.querySelector("select[name='bulk_action']");
this.tagAutoComplete = element.querySelector(".tag-autocomplete");
this.selectAcross = element.querySelector("label.select-across");
element.addEventListener( element.addEventListener(
"bulk-edit-toggle-active", "bulk-edit-toggle-active",
@ -21,6 +24,11 @@ class BulkEdit {
"bookmark-list-updated", "bookmark-list-updated",
this.onListUpdated.bind(this), this.onListUpdated.bind(this),
); );
this.actionSelect.addEventListener(
"change",
this.onActionSelected.bind(this),
);
} }
get allCheckbox() { get allCheckbox() {
@ -48,23 +56,56 @@ class BulkEdit {
} }
onToggleBookmark() { onToggleBookmark() {
this.allCheckbox.checked = this.bookmarkCheckboxes.every((checkbox) => { const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
return checkbox.checked; return checkbox.checked;
}); });
this.allCheckbox.checked = allChecked;
this.updateSelectAcross(allChecked);
} }
onToggleAll() { onToggleAll() {
const checked = this.allCheckbox.checked; const allChecked = this.allCheckbox.checked;
this.bookmarkCheckboxes.forEach((checkbox) => { this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = checked; checkbox.checked = allChecked;
}); });
this.updateSelectAcross(allChecked);
} }
onListUpdated() { onActionSelected() {
const action = this.actionSelect.value;
if (action === "bulk_tag" || action === "bulk_untag") {
this.tagAutoComplete.classList.remove("d-none");
} else {
this.tagAutoComplete.classList.add("d-none");
}
}
onListUpdated(event) {
// Reset checkbox states
this.reset();
// Update total number of bookmarks
const total = event.detail.bookmarksTotal;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
}
updateSelectAcross(allChecked) {
if (allChecked) {
this.selectAcross.classList.remove("d-none");
} else {
this.selectAcross.classList.add("d-none");
this.selectAcross.querySelector("input").checked = false;
}
}
reset() {
this.allCheckbox.checked = false; this.allCheckbox.checked = false;
this.bookmarkCheckboxes.forEach((checkbox) => { this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = false; checkbox.checked = false;
}); });
this.updateSelectAcross(false);
} }
} }

View file

@ -14,12 +14,13 @@ class TagAutocomplete {
id: element.id, id: element.id,
name: element.name, name: element.name,
value: element.value, value: element.value,
placeholder: element.getAttribute("placeholder") || "",
apiClient: apiClient, apiClient: apiClient,
variant: element.getAttribute("variant"), variant: element.getAttribute("variant"),
}, },
}); });
element.replaceWith(wrapper); element.replaceWith(wrapper.firstElementChild);
} }
} }

View file

@ -4,6 +4,7 @@
export let id; export let id;
export let name; export let name;
export let value; export let value;
export let placeholder;
export let apiClient; export let apiClient;
export let variant = 'default'; export let variant = 'default';
@ -118,7 +119,7 @@
<!-- autocomplete input container --> <!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}> <div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box --> <!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="&nbsp;" <input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
class="form-input" type="text" autocomplete="off" autocapitalize="off" class="form-input" type="text" autocomplete="off" autocapitalize="off"
on:input={handleInput} on:keydown={handleKeyDown} on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}> on:focus={handleFocus} on:blur={handleBlur}>
@ -152,6 +153,7 @@
.form-autocomplete.small .form-autocomplete-input { .form-autocomplete.small .form-autocomplete-input {
height: 1.4rem; height: 1.4rem;
min-height: 1.4rem; min-height: 1.4rem;
padding: 0.05rem 0.3rem;
} }
.form-autocomplete.small .form-autocomplete-input input { .form-autocomplete.small .form-autocomplete-input input {

View file

@ -119,6 +119,34 @@ def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_us
Bookmark.objects.bulk_update(bookmarks, ['date_modified']) Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
def mark_bookmarks_as_read(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(unread=False, date_modified=timezone.now())
def mark_bookmarks_as_unread(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(unread=True, date_modified=timezone.now())
def share_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(shared=True, date_modified=timezone.now())
def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(shared=False, date_modified=timezone.now())
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description to_bookmark.description = from_bookmark.description

View file

@ -118,6 +118,10 @@ span.confirmation {
margin-right: auto; margin-right: auto;
} }
.ml-auto {
margin-left: auto;
}
.btn.btn-wide { .btn.btn-wide {
padding-left: $unit-6; padding-left: $unit-6;
padding-right: $unit-6; padding-right: $unit-6;

View file

@ -292,10 +292,15 @@ $bulk-edit-transition-duration: 400ms;
text-decoration: underline; text-decoration: underline;
} }
> input, .form-autocomplete { > input, .form-autocomplete, select {
width: auto; width: auto;
max-width: 200px; max-width: 140px;
-webkit-appearance: none; -webkit-appearance: none;
} }
.select-across {
margin: 0 0 0 auto;
font-size: $font-size-sm;
}
} }
} }

View file

@ -20,10 +20,10 @@
</div> </div>
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:archived.action' %}?q={{ bookmark_list.filters.query }}&return_url={{ bookmark_list.return_url }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
<div class="bookmark-list-container"> <div class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}

View file

@ -5,7 +5,8 @@
{% if bookmark_list.is_empty %} {% if bookmark_list.is_empty %}
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/empty_bookmarks.html' %}
{% else %} {% else %}
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"> <ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
{% for bookmark_item in bookmark_list.items %} {% for bookmark_item in bookmark_list.items %}
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}> <li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
<label ld-bulk-edit-checkbox class="form-checkbox"> <label ld-bulk-edit-checkbox class="form-checkbox">

View file

@ -3,32 +3,37 @@
<div class="bulk-edit-bar"> <div class="bulk-edit-bar">
<div class="bulk-edit-actions bg-gray"> <div class="bulk-edit-actions bg-gray">
<label ld-bulk-edit-checkbox all class="form-checkbox"> <label ld-bulk-edit-checkbox all class="form-checkbox">
<input type="checkbox" style="display: none"> <input type="checkbox">
<i class="form-icon"></i> <i class="form-icon"></i>
</label> </label>
{% if mode == 'archive' %} <select name="bulk_action" class="form-select select-sm">
<button ld-confirm-button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm" {% if not 'bulk_archive' in disable_actions %}
title="Unarchive selected bookmarks">Unarchive <option value="bulk_archive">Archive</option>
</button>
{% else %}
<button ld-confirm-button type="submit" name="bulk_archive" class="btn btn-link btn-sm"
title="Archive selected bookmarks">Archive
</button>
{% endif %} {% endif %}
<span class="text-sm text-gray-dark"></span> {% if not 'bulk_unarchive' in disable_actions %}
<button ld-confirm-button type="submit" name="bulk_delete" class="btn btn-link btn-sm" <option value="bulk_unarchive">Unarchive</option>
title="Delete selected bookmarks">Delete {% endif %}
</button> <option value="bulk_delete">Delete</option>
<span class="text-sm text-gray-dark"></span> <option value="bulk_tag">Add tags</option>
<span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span> <option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
{% if request.user_profile.enable_sharing %}
<option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option>
{% endif %}
</select>
<div class="tag-autocomplete d-none">
<input ld-tag-autocomplete variant="small" <input ld-tag-autocomplete variant="small"
name="bulk_tag_string" class="form-input input-sm" placeholder="&nbsp;"> name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names...">
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm" </div>
title="Add tags to selected bookmarks">Add <button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">Execute</button>
</button>
<button type="submit" name="bulk_untag" class="btn btn-link btn-sm" <label class="form-checkbox select-across d-none">
title="Remove tags from selected bookmarks">Remove <input type="checkbox" name="bulk_select_across">
</button> <i class="form-icon"></i>
All pages (<span class="total">{{ bookmark_list.bookmarks_total }}</span> bookmarks)
</label>
</div> </div>
</div> </div>
{% endhtmlmin %} {% endhtmlmin %}

View file

@ -20,10 +20,10 @@
</div> </div>
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:index.action' %}?q={{ bookmark_list.filters.query }}&return_url={{ bookmark_list.return_url }}"
method="post"> method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
<div class="bookmark-list-container"> <div class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}

View file

@ -16,7 +16,7 @@
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='shared' %} {% bookmark_search bookmark_list.filters tag_cloud.tags mode='shared' %}
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:shared.action' %}?return_url={{ bookmark_list.return_url }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}

View file

@ -100,11 +100,12 @@ class BookmarkFactoryMixin:
for i in range(1, count + 1): for i in range(1, count + 1):
title = f'{prefix} {i}{suffix}' title = f'{prefix} {i}{suffix}'
url = f'https://example.com/{prefix}/{i}'
tags = [] tags = []
if with_tags: if with_tags:
tag_name = f'{tag_prefix} {i}{suffix}' tag_name = f'{tag_prefix} {i}{suffix}'
tags = [self.setup_tag(name=tag_name)] tags = [self.setup_tag(name=tag_name)]
self.setup_bookmark(title=title, is_archived=archived, shared=shared, tags=tags, user=user) self.setup_bookmark(url=url, title=title, is_archived=archived, shared=shared, tags=tags, user=user)
def get_numbered_bookmark(self, title: str): def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title) return Bookmark.objects.get(title=title)
@ -251,3 +252,8 @@ def disable_logging(f):
return result return result
return wrapper return wrapper
def collapse_whitespace(text: str):
text = text.replace('\n', '').replace('\r', '')
return ' '.join(text.split())

View file

@ -22,7 +22,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
def test_archive_should_archive_bookmark(self): def test_archive_should_archive_bookmark(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'archive': [bookmark.id], 'archive': [bookmark.id],
}) })
@ -34,7 +34,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(reverse('bookmarks:action'), { response = self.client.post(reverse('bookmarks:index.action'), {
'archive': [bookmark.id], 'archive': [bookmark.id],
}) })
@ -46,7 +46,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
def test_unarchive_should_unarchive_bookmark(self): def test_unarchive_should_unarchive_bookmark(self):
bookmark = self.setup_bookmark(is_archived=True) bookmark = self.setup_bookmark(is_archived=True)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'unarchive': [bookmark.id], 'unarchive': [bookmark.id],
}) })
bookmark.refresh_from_db() bookmark.refresh_from_db()
@ -57,7 +57,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(is_archived=True, user=other_user) bookmark = self.setup_bookmark(is_archived=True, user=other_user)
response = self.client.post(reverse('bookmarks:action'), { response = self.client.post(reverse('bookmarks:index.action'), {
'unarchive': [bookmark.id], 'unarchive': [bookmark.id],
}) })
bookmark.refresh_from_db() bookmark.refresh_from_db()
@ -68,7 +68,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
def test_delete_should_delete_bookmark(self): def test_delete_should_delete_bookmark(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'remove': [bookmark.id], 'remove': [bookmark.id],
}) })
@ -78,7 +78,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(reverse('bookmarks:action'), { response = self.client.post(reverse('bookmarks:index.action'), {
'remove': [bookmark.id], 'remove': [bookmark.id],
}) })
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@ -87,7 +87,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
def test_mark_as_read(self): def test_mark_as_read(self):
bookmark = self.setup_bookmark(unread=True) bookmark = self.setup_bookmark(unread=True)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'mark_as_read': [bookmark.id], 'mark_as_read': [bookmark.id],
}) })
bookmark.refresh_from_db() bookmark.refresh_from_db()
@ -97,7 +97,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
def test_unshare_should_unshare_bookmark(self): def test_unshare_should_unshare_bookmark(self):
bookmark = self.setup_bookmark(shared=True) bookmark = self.setup_bookmark(shared=True)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'unshare': [bookmark.id], 'unshare': [bookmark.id],
}) })
@ -109,7 +109,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
response = self.client.post(reverse('bookmarks:action'), { response = self.client.post(reverse('bookmarks:index.action'), {
'unshare': [bookmark.id], 'unshare': [bookmark.id],
}) })
@ -123,8 +123,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -138,8 +139,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(user=other_user) bookmark2 = self.setup_bookmark(user=other_user)
bookmark3 = self.setup_bookmark(user=other_user) bookmark3 = self.setup_bookmark(user=other_user)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -152,8 +154,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(is_archived=True) bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True) bookmark3 = self.setup_bookmark(is_archived=True)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:archived.action'), {
'bulk_unarchive': [''], 'bulk_action': ['bulk_unarchive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -167,8 +170,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(is_archived=True, user=other_user) bookmark2 = self.setup_bookmark(is_archived=True, user=other_user)
bookmark3 = self.setup_bookmark(is_archived=True, user=other_user) bookmark3 = self.setup_bookmark(is_archived=True, user=other_user)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:archived.action'), {
'bulk_unarchive': [''], 'bulk_action': ['bulk_unarchive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -181,8 +185,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_delete': [''], 'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -196,8 +201,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(user=other_user) bookmark2 = self.setup_bookmark(user=other_user)
bookmark3 = self.setup_bookmark(user=other_user) bookmark3 = self.setup_bookmark(user=other_user)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_delete': [''], 'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -212,8 +218,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag() tag1 = self.setup_tag()
tag2 = self.setup_tag() tag2 = self.setup_tag()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_tag': [''], 'bulk_action': ['bulk_tag'],
'bulk_execute': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'], 'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -234,8 +241,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag() tag1 = self.setup_tag()
tag2 = self.setup_tag() tag2 = self.setup_tag()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_tag': [''], 'bulk_action': ['bulk_tag'],
'bulk_execute': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'], 'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -255,8 +263,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2]) bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2]) bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_untag': [''], 'bulk_action': ['bulk_untag'],
'bulk_execute': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'], 'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -277,8 +286,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2], user=other_user) bookmark2 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user) bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_untag': [''], 'bulk_action': ['bulk_untag'],
'bulk_execute': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'], 'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -291,18 +301,240 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2]) self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2]) self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_bulk_mark_as_read(self):
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True)
bookmark3 = self.setup_bookmark(unread=True)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_read'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
def test_can_only_bulk_mark_as_read_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(unread=True, user=other_user)
bookmark2 = self.setup_bookmark(unread=True, user=other_user)
bookmark3 = self.setup_bookmark(unread=True, user=other_user)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_read'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
def test_bulk_mark_as_unread(self):
bookmark1 = self.setup_bookmark(unread=False)
bookmark2 = self.setup_bookmark(unread=False)
bookmark3 = self.setup_bookmark(unread=False)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_unread'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
def test_can_only_bulk_mark_as_unread_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(unread=False, user=other_user)
bookmark2 = self.setup_bookmark(unread=False, user=other_user)
bookmark3 = self.setup_bookmark(unread=False, user=other_user)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_unread'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
def test_bulk_share(self):
bookmark1 = self.setup_bookmark(shared=False)
bookmark2 = self.setup_bookmark(shared=False)
bookmark3 = self.setup_bookmark(shared=False)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_share'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
def test_can_only_bulk_share_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(shared=False, user=other_user)
bookmark2 = self.setup_bookmark(shared=False, user=other_user)
bookmark3 = self.setup_bookmark(shared=False, user=other_user)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_share'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_bulk_unshare(self):
bookmark1 = self.setup_bookmark(shared=True)
bookmark2 = self.setup_bookmark(shared=True)
bookmark3 = self.setup_bookmark(shared=True)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_unshare'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_can_only_bulk_unshare_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(shared=True, user=other_user)
bookmark2 = self.setup_bookmark(shared=True, user=other_user)
bookmark3 = self.setup_bookmark(shared=True, user=other_user)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_unshare'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
def test_bulk_select_across(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_bulk_select_across_respects_query(self):
self.setup_numbered_bookmarks(3, prefix='foo')
self.setup_numbered_bookmarks(3, prefix='bar')
self.assertEqual(3, Bookmark.objects.filter(title__startswith='foo').count())
self.client.post(reverse('bookmarks:index.action') + '?q=foo', {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(0, Bookmark.objects.filter(title__startswith='foo').count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith='bar').count())
def test_bulk_select_across_ignores_page(self):
self.setup_numbered_bookmarks(100)
self.client.post(reverse('bookmarks:index.action') + '?page=2', {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(0, Bookmark.objects.count())
def setup_bulk_edit_scope_test_data(self):
# create a number of bookmarks with different states / visibility
self.setup_numbered_bookmarks(3, with_tags=True)
self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
self.setup_numbered_bookmarks(3,
shared=True,
prefix="Joe's Bookmark",
user=self.setup_user(enable_sharing=True))
def test_index_action_bulk_select_across_only_affects_active_bookmarks(self):
self.setup_bulk_edit_scope_test_data()
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 1').first())
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 2').first())
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 3').first())
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(6, Bookmark.objects.count())
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 1').first())
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 2').first())
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 3').first())
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
self.setup_bulk_edit_scope_test_data()
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 1').first())
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 2').first())
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 3').first())
self.client.post(reverse('bookmarks:archived.action'), {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(6, Bookmark.objects.count())
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 1').first())
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 2').first())
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 3').first())
def test_shared_action_bulk_select_across_not_supported(self):
self.setup_bulk_edit_scope_test_data()
response = self.client.post(reverse('bookmarks:shared.action'), {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(response.status_code, 400)
def test_handles_empty_bookmark_id(self): def test_handles_empty_bookmark_id(self):
bookmark1 = self.setup_bookmark() bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
response = self.client.post(reverse('bookmarks:action'), { response = self.client.post(reverse('bookmarks:index.action'), {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
}) })
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
response = self.client.post(reverse('bookmarks:action'), { response = self.client.post(reverse('bookmarks:index.action'), {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bookmark_id': [], 'bookmark_id': [],
}) })
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@ -314,7 +546,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -325,9 +557,10 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
url = reverse('bookmarks:action') + '?return_url=' + reverse('bookmarks:settings.index') url = reverse('bookmarks:index.action') + '?return_url=' + reverse('bookmarks:settings.index')
response = self.client.post(url, { response = self.client.post(url, {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@ -339,9 +572,10 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
def post_with(return_url, follow=None): def post_with(return_url, follow=None):
url = reverse('bookmarks:action') + f'?return_url={return_url}' url = reverse('bookmarks:index.action') + f'?return_url={return_url}'
return self.client.post(url, { return self.client.post(url, {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}, follow=follow) }, follow=follow)

View file

@ -5,7 +5,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
@ -68,8 +68,10 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
] ]
response = self.client.get(reverse('bookmarks:archived')) response = self.client.get(reverse('bookmarks:archived'))
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@ -86,8 +88,10 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
] ]
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue') response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@ -214,3 +218,41 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
response = self.client.get(reverse('bookmarks:archived')) response = self.client.get(reverse('bookmarks:archived'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self') self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
def test_allowed_bulk_actions(self):
url = reverse('bookmarks:archived')
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(f'''
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_unarchive">Unarchive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
</select>
''', html)
def test_allowed_bulk_actions_with_sharing_enabled(self):
user_profile = self.user.profile
user_profile.enable_sharing = True
user_profile.save()
url = reverse('bookmarks:archived')
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(f'''
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_unarchive">Unarchive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option>
</select>
''', html)

View file

@ -6,7 +6,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
@ -69,8 +69,10 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
] ]
response = self.client.get(reverse('bookmarks:index')) response = self.client.get(reverse('bookmarks:index'))
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@ -87,8 +89,10 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
] ]
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue') response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@ -240,3 +244,41 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertInHTML(f''' self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}">Edit</a> <a href="{edit_url}?return_url={return_url}">Edit</a>
''', html) ''', html)
def test_allowed_bulk_actions(self):
url = reverse('bookmarks:index')
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(f'''
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_archive">Archive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
</select>
''', html)
def test_allowed_bulk_actions_with_sharing_enabled(self):
user_profile = self.user.profile
user_profile.enable_sharing = True
user_profile.save()
url = reverse('bookmarks:index')
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(f'''
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_archive">Archive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option>
</select>
''', html)

View file

@ -5,7 +5,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin): class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
@ -84,8 +84,10 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
] ]
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@ -124,8 +126,10 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
] ]
response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue') response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue')
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@ -145,8 +149,10 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
] ]
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)

View file

@ -10,7 +10,7 @@ from django.utils import timezone, formats
from bookmarks.middlewares import UserProfileMiddleware from bookmarks.middlewares import UserProfileMiddleware
from bookmarks.models import Bookmark, UserProfile, User from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
@ -454,9 +454,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def test_notes_are_hidden_initially_by_default(self): def test_notes_are_hidden_initially_by_default(self):
self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_template() html = collapse_whitespace(self.render_template())
self.assertIn('<ul class="bookmark-list">', html) self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self): def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@ -464,9 +464,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_template() html = collapse_whitespace(self.render_template())
self.assertIn('<ul class="bookmark-list">', html) self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
def test_notes_are_visible_initially_with_permanent_notes_enabled(self): def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@ -474,9 +474,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_template() html = collapse_whitespace(self.render_template())
self.assertIn('<ul class="bookmark-list show-notes">', html) self.assertIn('<ul class="bookmark-list show-notes" data-bookmarks-total="1">', html)
def test_toggle_notes_is_visible_by_default(self): def test_toggle_notes_is_visible_by_default(self):
self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')

View file

@ -8,7 +8,8 @@ from bookmarks.models import Bookmark, Tag
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services import website_loader from bookmarks.services import website_loader
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \ from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
from bookmarks.services.website_loader import WebsiteMetadata from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@ -452,3 +453,183 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(bookmark1.tags.all(), []) self.assertCountEqual(bookmark1.tags.all(), [])
self.assertCountEqual(bookmark2.tags.all(), []) self.assertCountEqual(bookmark2.tags.all(), [])
self.assertCountEqual(bookmark3.tags.all(), []) self.assertCountEqual(bookmark3.tags.all(), [])
def test_mark_bookmarks_as_read(self):
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True)
bookmark3 = self.setup_bookmark(unread=True)
mark_bookmarks_as_read([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_read_should_only_update_specified_bookmarks(self):
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True)
bookmark3 = self.setup_bookmark(unread=True)
mark_bookmarks_as_read([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_read_should_only_update_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True)
inaccessible_bookmark = self.setup_bookmark(unread=True, user=other_user)
mark_bookmarks_as_read([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).unread)
def test_mark_bookmarks_as_read_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True)
bookmark3 = self.setup_bookmark(unread=True)
mark_bookmarks_as_read([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_unread(self):
bookmark1 = self.setup_bookmark(unread=False)
bookmark2 = self.setup_bookmark(unread=False)
bookmark3 = self.setup_bookmark(unread=False)
mark_bookmarks_as_unread([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_unread_should_only_update_specified_bookmarks(self):
bookmark1 = self.setup_bookmark(unread=False)
bookmark2 = self.setup_bookmark(unread=False)
bookmark3 = self.setup_bookmark(unread=False)
mark_bookmarks_as_unread([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_unread_should_only_update_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(unread=False)
bookmark2 = self.setup_bookmark(unread=False)
inaccessible_bookmark = self.setup_bookmark(unread=False, user=other_user)
mark_bookmarks_as_unread([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).unread)
def test_mark_bookmarks_as_unread_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark(unread=False)
bookmark2 = self.setup_bookmark(unread=False)
bookmark3 = self.setup_bookmark(unread=False)
mark_bookmarks_as_unread([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
def test_share_bookmarks(self):
bookmark1 = self.setup_bookmark(shared=False)
bookmark2 = self.setup_bookmark(shared=False)
bookmark3 = self.setup_bookmark(shared=False)
share_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
def test_share_bookmarks_should_only_update_specified_bookmarks(self):
bookmark1 = self.setup_bookmark(shared=False)
bookmark2 = self.setup_bookmark(shared=False)
bookmark3 = self.setup_bookmark(shared=False)
share_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
def test_share_bookmarks_should_only_update_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(shared=False)
bookmark2 = self.setup_bookmark(shared=False)
inaccessible_bookmark = self.setup_bookmark(shared=False, user=other_user)
share_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).shared)
def test_share_bookmarks_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark(shared=False)
bookmark2 = self.setup_bookmark(shared=False)
bookmark3 = self.setup_bookmark(shared=False)
share_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
def test_unshare_bookmarks(self):
bookmark1 = self.setup_bookmark(shared=True)
bookmark2 = self.setup_bookmark(shared=True)
bookmark3 = self.setup_bookmark(shared=True)
unshare_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_unshare_bookmarks_should_only_update_specified_bookmarks(self):
bookmark1 = self.setup_bookmark(shared=True)
bookmark2 = self.setup_bookmark(shared=True)
bookmark3 = self.setup_bookmark(shared=True)
unshare_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_unshare_bookmarks_should_only_update_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(shared=True)
bookmark2 = self.setup_bookmark(shared=True)
inaccessible_bookmark = self.setup_bookmark(shared=True, user=other_user)
unshare_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).shared)
def test_unshare_bookmarks_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark(shared=True)
bookmark2 = self.setup_bookmark(shared=True)
bookmark3 = self.setup_bookmark(shared=True)
unshare_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)

View file

@ -13,12 +13,14 @@ urlpatterns = [
re_path(r'^$', RedirectView.as_view(pattern_name='bookmarks:index', permanent=False)), re_path(r'^$', RedirectView.as_view(pattern_name='bookmarks:index', permanent=False)),
# Bookmarks # Bookmarks
path('bookmarks', views.bookmarks.index, name='index'), path('bookmarks', views.bookmarks.index, name='index'),
path('bookmarks/action', views.bookmarks.index_action, name='index.action'),
path('bookmarks/archived', views.bookmarks.archived, name='archived'), path('bookmarks/archived', views.bookmarks.archived, name='archived'),
path('bookmarks/archived/action', views.bookmarks.archived_action, name='archived.action'),
path('bookmarks/shared', views.bookmarks.shared, name='shared'), path('bookmarks/shared', views.bookmarks.shared, name='shared'),
path('bookmarks/shared/action', views.bookmarks.shared_action, name='shared.action'),
path('bookmarks/new', views.bookmarks.new, name='new'), path('bookmarks/new', views.bookmarks.new, name='new'),
path('bookmarks/close', views.bookmarks.close, name='close'), path('bookmarks/close', views.bookmarks.close, name='close'),
path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'), path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'),
path('bookmarks/action', views.bookmarks.action, name='action'),
# Partials # Partials
path('bookmarks/partials/bookmark-list/active', partials.active_bookmark_list, path('bookmarks/partials/bookmark-list/active', partials.active_bookmark_list,
name='partials.bookmark_list.active'), name='partials.bookmark_list.active'),

View file

@ -1,12 +1,14 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect, Http404 from django.db.models import QuerySet
from django.http import HttpResponseRedirect, Http404, HttpResponseBadRequest
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, build_tag_string from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \ from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
from bookmarks.utils import get_safe_return_url from bookmarks.utils import get_safe_return_url
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
@ -166,8 +168,26 @@ def mark_as_read(request, bookmark_id: int):
@login_required @login_required
def action(request): def index_action(request):
# Determine action filters = BookmarkFilters(request)
query = queries.query_bookmarks(request.user, request.user_profile, filters.query)
return action(request, query)
@login_required
def archived_action(request):
filters = BookmarkFilters(request)
query = queries.query_archived_bookmarks(request.user, request.user_profile, filters.query)
return action(request, query)
@login_required
def shared_action(request):
return action(request)
def action(request, query: QuerySet[Bookmark] = None):
# Single bookmark actions
if 'archive' in request.POST: if 'archive' in request.POST:
archive(request, request.POST['archive']) archive(request, request.POST['archive'])
if 'unarchive' in request.POST: if 'unarchive' in request.POST:
@ -178,23 +198,42 @@ def action(request):
mark_as_read(request, request.POST['mark_as_read']) mark_as_read(request, request.POST['mark_as_read'])
if 'unshare' in request.POST: if 'unshare' in request.POST:
unshare(request, request.POST['unshare']) unshare(request, request.POST['unshare'])
if 'bulk_archive' in request.POST:
# Bulk actions
if 'bulk_execute' in request.POST:
if query is None:
return HttpResponseBadRequest('View does not support bulk actions')
bulk_action = request.POST['bulk_action']
# Determine set of bookmarks
if request.POST.get('bulk_select_across') == 'on':
# Query full list of bookmarks across all pages
bookmark_ids = query.only('id').values_list('id', flat=True)
else:
# Use only selected bookmarks
bookmark_ids = request.POST.getlist('bookmark_id') bookmark_ids = request.POST.getlist('bookmark_id')
if 'bulk_archive' == bulk_action:
archive_bookmarks(bookmark_ids, request.user) archive_bookmarks(bookmark_ids, request.user)
if 'bulk_unarchive' in request.POST: if 'bulk_unarchive' == bulk_action:
bookmark_ids = request.POST.getlist('bookmark_id')
unarchive_bookmarks(bookmark_ids, request.user) unarchive_bookmarks(bookmark_ids, request.user)
if 'bulk_delete' in request.POST: if 'bulk_delete' == bulk_action:
bookmark_ids = request.POST.getlist('bookmark_id')
delete_bookmarks(bookmark_ids, request.user) delete_bookmarks(bookmark_ids, request.user)
if 'bulk_tag' in request.POST: if 'bulk_tag' == bulk_action:
bookmark_ids = request.POST.getlist('bookmark_id')
tag_string = convert_tag_string(request.POST['bulk_tag_string']) tag_string = convert_tag_string(request.POST['bulk_tag_string'])
tag_bookmarks(bookmark_ids, tag_string, request.user) tag_bookmarks(bookmark_ids, tag_string, request.user)
if 'bulk_untag' in request.POST: if 'bulk_untag' == bulk_action:
bookmark_ids = request.POST.getlist('bookmark_id')
tag_string = convert_tag_string(request.POST['bulk_tag_string']) tag_string = convert_tag_string(request.POST['bulk_tag_string'])
untag_bookmarks(bookmark_ids, tag_string, request.user) untag_bookmarks(bookmark_ids, tag_string, request.user)
if 'bulk_read' == bulk_action:
mark_bookmarks_as_read(bookmark_ids, request.user)
if 'bulk_unread' == bulk_action:
mark_bookmarks_as_unread(bookmark_ids, request.user)
if 'bulk_share' == bulk_action:
share_bookmarks(bookmark_ids, request.user)
if 'bulk_unshare' == bulk_action:
unshare_bookmarks(bookmark_ids, request.user)
return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index')) return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index'))
return HttpResponseRedirect(return_url) return HttpResponseRedirect(return_url)

View file

@ -70,6 +70,7 @@ class BookmarkListContext:
self.is_empty = paginator.count == 0 self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page self.bookmarks_page = bookmarks_page
self.bookmarks_total = paginator.count
self.return_url = self.generate_return_url(page_number) self.return_url = self.generate_return_url(page_number)
self.link_target = user_profile.bookmark_link_target self.link_target = user_profile.bookmark_link_target
self.date_display = user_profile.bookmark_date_display self.date_display = user_profile.bookmark_date_display