diff --git a/bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py b/bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py new file mode 100644 index 0000000..d9ca896 --- /dev/null +++ b/bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py @@ -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() diff --git a/bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py b/bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py index 47ec11a..a883c56 100644 --- a/bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py +++ b/bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py @@ -150,7 +150,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): self.locate_bulk_edit_toggle().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.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3']) @@ -165,7 +166,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): self.locate_bulk_edit_toggle().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.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3']) @@ -197,7 +199,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3']) 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() with sync_playwright() as p: @@ -205,7 +207,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): self.locate_bulk_edit_toggle().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.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3']) @@ -220,7 +223,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): self.locate_bulk_edit_toggle().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.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3']) diff --git a/bookmarks/e2e/helpers.py b/bookmarks/e2e/helpers.py index ef80625..f3931a2 100644 --- a/bookmarks/e2e/helpers.py +++ b/bookmarks/e2e/helpers.py @@ -41,5 +41,14 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin): def locate_bulk_edit_bar(self): 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): 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) diff --git a/bookmarks/frontend/behaviors/bookmark-page.js b/bookmarks/frontend/behaviors/bookmark-page.js index f535f90..eb160b2 100644 --- a/bookmarks/frontend/behaviors/bookmark-page.js +++ b/bookmarks/frontend/behaviors/bookmark-page.js @@ -36,8 +36,18 @@ class BookmarkPage { swap(this.bookmarkList, bookmarkListHtml); 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( - new CustomEvent("bookmark-list-updated", { bubbles: true }), + new CustomEvent("bookmark-list-updated", { + bubbles: true, + detail: { bookmarksTotal }, + }), ); }); } diff --git a/bookmarks/frontend/behaviors/bulk-edit.js b/bookmarks/frontend/behaviors/bulk-edit.js index fd9092d..f0927c6 100644 --- a/bookmarks/frontend/behaviors/bulk-edit.js +++ b/bookmarks/frontend/behaviors/bulk-edit.js @@ -4,6 +4,9 @@ class BulkEdit { constructor(element) { this.element = element; 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( "bulk-edit-toggle-active", @@ -21,6 +24,11 @@ class BulkEdit { "bookmark-list-updated", this.onListUpdated.bind(this), ); + + this.actionSelect.addEventListener( + "change", + this.onActionSelected.bind(this), + ); } get allCheckbox() { @@ -48,23 +56,56 @@ class BulkEdit { } onToggleBookmark() { - this.allCheckbox.checked = this.bookmarkCheckboxes.every((checkbox) => { + const allChecked = this.bookmarkCheckboxes.every((checkbox) => { return checkbox.checked; }); + this.allCheckbox.checked = allChecked; + this.updateSelectAcross(allChecked); } onToggleAll() { - const checked = this.allCheckbox.checked; + const allChecked = this.allCheckbox.checked; 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.bookmarkCheckboxes.forEach((checkbox) => { checkbox.checked = false; }); + this.updateSelectAcross(false); } } diff --git a/bookmarks/frontend/behaviors/tag-autocomplete.js b/bookmarks/frontend/behaviors/tag-autocomplete.js index a7644ca..24e81a0 100644 --- a/bookmarks/frontend/behaviors/tag-autocomplete.js +++ b/bookmarks/frontend/behaviors/tag-autocomplete.js @@ -14,12 +14,13 @@ class TagAutocomplete { id: element.id, name: element.name, value: element.value, + placeholder: element.getAttribute("placeholder") || "", apiClient: apiClient, variant: element.getAttribute("variant"), }, }); - element.replaceWith(wrapper); + element.replaceWith(wrapper.firstElementChild); } } diff --git a/bookmarks/frontend/components/TagAutocomplete.svelte b/bookmarks/frontend/components/TagAutocomplete.svelte index 52270ab..779fcc9 100644 --- a/bookmarks/frontend/components/TagAutocomplete.svelte +++ b/bookmarks/frontend/components/TagAutocomplete.svelte @@ -4,6 +4,7 @@ export let id; export let name; export let value; + export let placeholder; export let apiClient; export let variant = 'default'; @@ -118,7 +119,7 @@