Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add bookmark details view #665

Merged
merged 11 commits into from
Mar 29, 2024
Merged
133 changes: 133 additions & 0 deletions bookmarks/e2e/e2e_test_bookmark_details_modal.py
Original file line number Diff line number Diff line change
@@ -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)
37 changes: 37 additions & 0 deletions bookmarks/e2e/e2e_test_bookmark_details_view.py
Original file line number Diff line number Diff line change
@@ -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()
13 changes: 13 additions & 0 deletions bookmarks/e2e/helpers.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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")

Expand Down
38 changes: 38 additions & 0 deletions bookmarks/frontend/behaviors/bookmark-details.js
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 4 additions & 0 deletions bookmarks/frontend/behaviors/bookmark-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 6 additions & 2 deletions bookmarks/frontend/behaviors/confirm-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,22 @@ 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);
confirmButton.type = this.button.dataset.type;
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);
Expand Down
65 changes: 50 additions & 15 deletions bookmarks/frontend/behaviors/modal.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { registerBehavior } from "./index";
import { applyBehaviors, registerBehavior } from "./index";

class ModalBehavior {
constructor(element) {
Expand All @@ -7,22 +7,58 @@ 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 = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header d-flex justify-between align-center">
<div class="modal-title h5">Tags</div>
<button class="btn btn-link close">
<button class="close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
Expand All @@ -36,29 +72,28 @@ class ModalBehavior {
</div>
`;

// Teleport content element
const contentOwner = content.parentElement;
const contentContainer = modal.querySelector(".content");
contentContainer.append(content);
this.content = content;
this.contentOwner = contentOwner;

// Register close handlers
const modalOverlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector(".btn.close");
modalOverlay.addEventListener("click", this.onClose.bind(this));
closeButton.addEventListener("click", this.onClose.bind(this));

document.body.append(modal);
this.modal = modal;
return modal;
}

onClose() {
// Teleport content back
this.contentOwner.append(this.content);
if (this.content && this.contentOwner) {
this.contentOwner.append(this.content);
}

// Remove modal
this.modal.remove();
this.modal.classList.add("closing");
this.modal.addEventListener("animationend", (event) => {
if (event.animationName === "fade-out") {
this.modal.remove();
}
});
}
}

Expand Down
1 change: 1 addition & 0 deletions bookmarks/frontend/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import "./behaviors/bookmark-details";
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/confirm-button";
Expand Down
Loading