diff --git a/bookmarks/e2e/e2e_test_bookmark_details_modal.py b/bookmarks/e2e/e2e_test_bookmark_details_modal.py new file mode 100644 index 00000000..d3eb1833 --- /dev/null +++ b/bookmarks/e2e/e2e_test_bookmark_details_modal.py @@ -0,0 +1,133 @@ +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 BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase): + def test_show_details(self): + bookmark = self.setup_bookmark() + + with sync_playwright() as p: + self.open(reverse("bookmarks:index"), p) + + details_modal = self.open_details_modal(bookmark) + title = details_modal.locator("h2") + expect(title).to_have_text(bookmark.title) + + def test_close_details(self): + bookmark = self.setup_bookmark() + + with sync_playwright() as p: + self.open(reverse("bookmarks:index"), p) + + # close with close button + details_modal = self.open_details_modal(bookmark) + details_modal.locator("button.close").click() + expect(details_modal).to_be_hidden() + + # close with backdrop + details_modal = self.open_details_modal(bookmark) + overlay = details_modal.locator(".modal-overlay") + overlay.click(position={"x": 0, "y": 0}) + expect(details_modal).to_be_hidden() + + def test_toggle_archived(self): + bookmark = self.setup_bookmark() + + with sync_playwright() as p: + # archive + url = reverse("bookmarks:index") + self.open(url, p) + + details_modal = self.open_details_modal(bookmark) + details_modal.get_by_text("Archived", exact=False).click() + expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() + + # unarchive + url = reverse("bookmarks:archived") + self.page.goto(self.live_server_url + url) + + details_modal = self.open_details_modal(bookmark) + details_modal.get_by_text("Archived", exact=False).click() + expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() + + def test_toggle_unread(self): + bookmark = self.setup_bookmark() + + with sync_playwright() as p: + # mark as unread + url = reverse("bookmarks:index") + self.open(url, p) + + details_modal = self.open_details_modal(bookmark) + + details_modal.get_by_text("Unread").click() + bookmark_item = self.locate_bookmark(bookmark.title) + expect(bookmark_item.get_by_text("Unread")).to_be_visible() + + # mark as read + details_modal.get_by_text("Unread").click() + bookmark_item = self.locate_bookmark(bookmark.title) + expect(bookmark_item.get_by_text("Unread")).not_to_be_visible() + + def test_toggle_shared(self): + profile = self.get_or_create_test_user().profile + profile.enable_sharing = True + profile.save() + + bookmark = self.setup_bookmark() + + with sync_playwright() as p: + # share bookmark + url = reverse("bookmarks:index") + self.open(url, p) + + details_modal = self.open_details_modal(bookmark) + + details_modal.get_by_text("Shared").click() + bookmark_item = self.locate_bookmark(bookmark.title) + expect(bookmark_item.get_by_text("Shared")).to_be_visible() + + # unshare bookmark + details_modal.get_by_text("Shared").click() + bookmark_item = self.locate_bookmark(bookmark.title) + expect(bookmark_item.get_by_text("Shared")).not_to_be_visible() + + def test_edit_return_url(self): + bookmark = self.setup_bookmark() + + with sync_playwright() as p: + url = reverse("bookmarks:index") + f"?q={bookmark.title}" + self.open(url, p) + + details_modal = self.open_details_modal(bookmark) + + # Navigate to edit page + with self.page.expect_navigation(): + details_modal.get_by_text("Edit").click() + + # Cancel edit, verify return url + with self.page.expect_navigation(url=self.live_server_url + url): + self.page.get_by_text("Nevermind").click() + + def test_delete(self): + bookmark = self.setup_bookmark() + + with sync_playwright() as p: + url = reverse("bookmarks:index") + f"?q={bookmark.title}" + self.open(url, p) + + details_modal = self.open_details_modal(bookmark) + + # Delete bookmark, verify return url + with self.page.expect_navigation(url=self.live_server_url + url): + details_modal.get_by_text("Delete...").click() + details_modal.get_by_text("Confirm").click() + + # verify bookmark is deleted + self.locate_bookmark(bookmark.title) + expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() + + self.assertEqual(Bookmark.objects.count(), 0) diff --git a/bookmarks/e2e/e2e_test_bookmark_details_view.py b/bookmarks/e2e/e2e_test_bookmark_details_view.py new file mode 100644 index 00000000..8b792ffa --- /dev/null +++ b/bookmarks/e2e/e2e_test_bookmark_details_view.py @@ -0,0 +1,37 @@ +from django.urls import reverse +from playwright.sync_api import sync_playwright + +from bookmarks.e2e.helpers import LinkdingE2ETestCase + + +class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase): + def test_edit_return_url(self): + bookmark = self.setup_bookmark() + + with sync_playwright() as p: + self.open(reverse("bookmarks:details", args=[bookmark.id]), p) + + # Navigate to edit page + with self.page.expect_navigation(): + self.page.get_by_text("Edit").click() + + # Cancel edit, verify return url + with self.page.expect_navigation( + url=self.live_server_url + + reverse("bookmarks:details", args=[bookmark.id]) + ): + self.page.get_by_text("Nevermind").click() + + def test_delete_return_url(self): + bookmark = self.setup_bookmark() + + with sync_playwright() as p: + self.open(reverse("bookmarks:details", args=[bookmark.id]), p) + + # Trigger delete, verify return url + # Should probably return to last bookmark list page, but for now just returns to index + with self.page.expect_navigation( + url=self.live_server_url + reverse("bookmarks:index") + ): + self.page.get_by_text("Delete...").click() + self.page.get_by_text("Confirm").click() diff --git a/bookmarks/e2e/helpers.py b/bookmarks/e2e/helpers.py index 4aad225e..99c4fed7 100644 --- a/bookmarks/e2e/helpers.py +++ b/bookmarks/e2e/helpers.py @@ -1,5 +1,6 @@ from django.contrib.staticfiles.testing import LiveServerTestCase from playwright.sync_api import BrowserContext, Playwright, Page +from playwright.sync_api import expect from bookmarks.tests.helpers import BookmarkFactoryMixin @@ -45,6 +46,18 @@ def locate_bookmark(self, title: str): bookmark_tags = self.page.locator("li[ld-bookmark-item]") return bookmark_tags.filter(has_text=title) + def locate_details_modal(self): + return self.page.locator(".modal.bookmark-details") + + def open_details_modal(self, bookmark): + details_button = self.locate_bookmark(bookmark.title).get_by_text("View") + details_button.click() + + details_modal = self.locate_details_modal() + expect(details_modal).to_be_visible() + + return details_modal + def locate_bulk_edit_bar(self): return self.page.locator(".bulk-edit-bar") diff --git a/bookmarks/frontend/behaviors/bookmark-details.js b/bookmarks/frontend/behaviors/bookmark-details.js new file mode 100644 index 00000000..eccc8c30 --- /dev/null +++ b/bookmarks/frontend/behaviors/bookmark-details.js @@ -0,0 +1,38 @@ +import { registerBehavior } from "./index"; + +class BookmarkDetails { + constructor(element) { + this.form = element.querySelector(".status form"); + if (!this.form) { + // Form may not exist if user does not own the bookmark + return; + } + this.form.addEventListener("submit", (event) => { + event.preventDefault(); + this.submitForm(); + }); + + const inputs = this.form.querySelectorAll("input"); + inputs.forEach((input) => { + input.addEventListener("change", () => { + this.submitForm(); + }); + }); + } + + async submitForm() { + const url = this.form.action; + const formData = new FormData(this.form); + + await fetch(url, { + method: "POST", + body: formData, + redirect: "manual", // ignore redirect + }); + + // Refresh bookmark page if it exists + document.dispatchEvent(new CustomEvent("bookmark-page-refresh")); + } +} + +registerBehavior("ld-bookmark-details", BookmarkDetails); diff --git a/bookmarks/frontend/behaviors/bookmark-page.js b/bookmarks/frontend/behaviors/bookmark-page.js index 4b5574f4..89b1b76e 100644 --- a/bookmarks/frontend/behaviors/bookmark-page.js +++ b/bookmarks/frontend/behaviors/bookmark-page.js @@ -8,6 +8,10 @@ class BookmarkPage { this.bookmarkList = element.querySelector(".bookmark-list-container"); this.tagCloud = element.querySelector(".tag-cloud-container"); + + document.addEventListener("bookmark-page-refresh", () => { + this.refresh(); + }); } async onFormSubmit(event) { diff --git a/bookmarks/frontend/behaviors/confirm-button.js b/bookmarks/frontend/behaviors/confirm-button.js index 6b6ea014..fbea5912 100644 --- a/bookmarks/frontend/behaviors/confirm-button.js +++ b/bookmarks/frontend/behaviors/confirm-button.js @@ -38,10 +38,14 @@ class ConfirmButtonBehavior { container.append(question); } + const buttonClasses = Array.from(this.button.classList.values()) + .filter((cls) => cls.startsWith("btn")) + .join(" "); + const cancelButton = document.createElement(this.button.nodeName); cancelButton.type = "button"; cancelButton.innerText = question ? "No" : "Cancel"; - cancelButton.className = "btn btn-link btn-sm mr-1"; + cancelButton.className = `${buttonClasses} mr-1`; cancelButton.addEventListener("click", this.reset.bind(this)); const confirmButton = document.createElement(this.button.nodeName); @@ -49,7 +53,7 @@ class ConfirmButtonBehavior { confirmButton.name = this.button.dataset.name; confirmButton.value = this.button.dataset.value; confirmButton.innerText = question ? "Yes" : "Confirm"; - confirmButton.className = "btn btn-link btn-sm"; + confirmButton.className = buttonClasses; confirmButton.addEventListener("click", this.reset.bind(this)); container.append(cancelButton, confirmButton); diff --git a/bookmarks/frontend/behaviors/modal.js b/bookmarks/frontend/behaviors/modal.js index e75543d8..592fde78 100644 --- a/bookmarks/frontend/behaviors/modal.js +++ b/bookmarks/frontend/behaviors/modal.js @@ -1,4 +1,4 @@ -import { registerBehavior } from "./index"; +import { applyBehaviors, registerBehavior } from "./index"; class ModalBehavior { constructor(element) { @@ -7,14 +7,50 @@ class ModalBehavior { this.toggle = toggle; } - onToggleClick() { + async onToggleClick(event) { + // Ignore Ctrl + click + if (event.ctrlKey || event.metaKey) { + return; + } + event.preventDefault(); + event.stopPropagation(); + + // Create modal either by teleporting existing content or fetching from URL + const modal = this.toggle.hasAttribute("modal-content") + ? this.createFromContent() + : await this.createFromUrl(); + + if (!modal) { + return; + } + + // Register close handlers + const modalOverlay = modal.querySelector(".modal-overlay"); + const closeButton = modal.querySelector("button.close"); + modalOverlay.addEventListener("click", this.onClose.bind(this)); + closeButton.addEventListener("click", this.onClose.bind(this)); + + document.body.append(modal); + applyBehaviors(document.body); + this.modal = modal; + } + + async createFromUrl() { + const url = this.toggle.getAttribute("modal-url"); + const modalHtml = await fetch(url).then((response) => response.text()); + const parser = new DOMParser(); + const doc = parser.parseFromString(modalHtml, "text/html"); + return doc.querySelector(".modal"); + } + + createFromContent() { const contentSelector = this.toggle.getAttribute("modal-content"); const content = document.querySelector(contentSelector); if (!content) { return; } - // Create modal + // Todo: make title configurable, only used for tag cloud for now const modal = document.createElement("div"); modal.classList.add("modal", "active"); modal.innerHTML = ` @@ -22,7 +58,7 @@ class ModalBehavior {