Skip to content

Commit

Permalink
Add bookmark details view (#665)
Browse files Browse the repository at this point in the history
* Experiment with bookmark details

* Add basic tests

* Refactor details into modal

* Implement edit and delete button

* Remove slide down animation

* Add fallback details view

* Add status actions

* Improve dark theme

* Improve return URLs

* Make bookmark details sharable

* Fix E2E tests
  • Loading branch information
sissbruecker committed Mar 29, 2024
1 parent 77e1525 commit 9c48085
Show file tree
Hide file tree
Showing 27 changed files with 1,276 additions and 67 deletions.
133 changes: 133 additions & 0 deletions 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)
37 changes: 37 additions & 0 deletions 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()
13 changes: 13 additions & 0 deletions 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

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
@@ -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
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
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
@@ -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
@@ -1,3 +1,4 @@
import "./behaviors/bookmark-details";
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/confirm-button";
Expand Down

0 comments on commit 9c48085

Please sign in to comment.