mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-07 18:58:30 +02:00
Add bookmark details view (#665)
* 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
This commit is contained in:
133
bookmarks/e2e/e2e_test_bookmark_details_modal.py
Normal file
133
bookmarks/e2e/e2e_test_bookmark_details_modal.py
Normal file
@@ -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
bookmarks/e2e/e2e_test_bookmark_details_view.py
Normal file
37
bookmarks/e2e/e2e_test_bookmark_details_view.py
Normal file
@@ -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()
|
@@ -1,5 +1,6 @@
|
|||||||
from django.contrib.staticfiles.testing import LiveServerTestCase
|
from django.contrib.staticfiles.testing import LiveServerTestCase
|
||||||
from playwright.sync_api import BrowserContext, Playwright, Page
|
from playwright.sync_api import BrowserContext, Playwright, Page
|
||||||
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
@@ -45,6 +46,18 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
|||||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||||
return bookmark_tags.filter(has_text=title)
|
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):
|
def locate_bulk_edit_bar(self):
|
||||||
return self.page.locator(".bulk-edit-bar")
|
return self.page.locator(".bulk-edit-bar")
|
||||||
|
|
||||||
|
38
bookmarks/frontend/behaviors/bookmark-details.js
Normal file
38
bookmarks/frontend/behaviors/bookmark-details.js
Normal file
@@ -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);
|
@@ -8,6 +8,10 @@ class BookmarkPage {
|
|||||||
|
|
||||||
this.bookmarkList = element.querySelector(".bookmark-list-container");
|
this.bookmarkList = element.querySelector(".bookmark-list-container");
|
||||||
this.tagCloud = element.querySelector(".tag-cloud-container");
|
this.tagCloud = element.querySelector(".tag-cloud-container");
|
||||||
|
|
||||||
|
document.addEventListener("bookmark-page-refresh", () => {
|
||||||
|
this.refresh();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onFormSubmit(event) {
|
async onFormSubmit(event) {
|
||||||
|
@@ -38,10 +38,14 @@ class ConfirmButtonBehavior {
|
|||||||
container.append(question);
|
container.append(question);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buttonClasses = Array.from(this.button.classList.values())
|
||||||
|
.filter((cls) => cls.startsWith("btn"))
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
const cancelButton = document.createElement(this.button.nodeName);
|
const cancelButton = document.createElement(this.button.nodeName);
|
||||||
cancelButton.type = "button";
|
cancelButton.type = "button";
|
||||||
cancelButton.innerText = question ? "No" : "Cancel";
|
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));
|
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||||
|
|
||||||
const confirmButton = document.createElement(this.button.nodeName);
|
const confirmButton = document.createElement(this.button.nodeName);
|
||||||
@@ -49,7 +53,7 @@ class ConfirmButtonBehavior {
|
|||||||
confirmButton.name = this.button.dataset.name;
|
confirmButton.name = this.button.dataset.name;
|
||||||
confirmButton.value = this.button.dataset.value;
|
confirmButton.value = this.button.dataset.value;
|
||||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||||
confirmButton.className = "btn btn-link btn-sm";
|
confirmButton.className = buttonClasses;
|
||||||
confirmButton.addEventListener("click", this.reset.bind(this));
|
confirmButton.addEventListener("click", this.reset.bind(this));
|
||||||
|
|
||||||
container.append(cancelButton, confirmButton);
|
container.append(cancelButton, confirmButton);
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { registerBehavior } from "./index";
|
import { applyBehaviors, registerBehavior } from "./index";
|
||||||
|
|
||||||
class ModalBehavior {
|
class ModalBehavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
@@ -7,14 +7,50 @@ class ModalBehavior {
|
|||||||
this.toggle = toggle;
|
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 contentSelector = this.toggle.getAttribute("modal-content");
|
||||||
const content = document.querySelector(contentSelector);
|
const content = document.querySelector(contentSelector);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create modal
|
// Todo: make title configurable, only used for tag cloud for now
|
||||||
const modal = document.createElement("div");
|
const modal = document.createElement("div");
|
||||||
modal.classList.add("modal", "active");
|
modal.classList.add("modal", "active");
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
@@ -22,7 +58,7 @@ class ModalBehavior {
|
|||||||
<div class="modal-container">
|
<div class="modal-container">
|
||||||
<div class="modal-header d-flex justify-between align-center">
|
<div class="modal-header d-flex justify-between align-center">
|
||||||
<div class="modal-title h5">Tags</div>
|
<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">
|
<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 stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
<path d="M18 6l-12 12"></path>
|
<path d="M18 6l-12 12"></path>
|
||||||
@@ -36,30 +72,29 @@ class ModalBehavior {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Teleport content element
|
|
||||||
const contentOwner = content.parentElement;
|
const contentOwner = content.parentElement;
|
||||||
const contentContainer = modal.querySelector(".content");
|
const contentContainer = modal.querySelector(".content");
|
||||||
contentContainer.append(content);
|
contentContainer.append(content);
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.contentOwner = contentOwner;
|
this.contentOwner = contentOwner;
|
||||||
|
|
||||||
// Register close handlers
|
return modal;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose() {
|
onClose() {
|
||||||
// Teleport content back
|
// Teleport content back
|
||||||
|
if (this.content && this.contentOwner) {
|
||||||
this.contentOwner.append(this.content);
|
this.contentOwner.append(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove modal
|
// Remove modal
|
||||||
|
this.modal.classList.add("closing");
|
||||||
|
this.modal.addEventListener("animationend", (event) => {
|
||||||
|
if (event.animationName === "fade-out") {
|
||||||
this.modal.remove();
|
this.modal.remove();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerBehavior("ld-modal", ModalBehavior);
|
registerBehavior("ld-modal", ModalBehavior);
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import "./behaviors/bookmark-details";
|
||||||
import "./behaviors/bookmark-page";
|
import "./behaviors/bookmark-page";
|
||||||
import "./behaviors/bulk-edit";
|
import "./behaviors/bulk-edit";
|
||||||
import "./behaviors/confirm-button";
|
import "./behaviors/confirm-button";
|
||||||
|
79
bookmarks/styles/bookmark-details.scss
Normal file
79
bookmarks/styles/bookmark-details.scss
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/* Common styles */
|
||||||
|
.bookmark-details {
|
||||||
|
h2 {
|
||||||
|
flex: 1 1 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weblinks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.weblink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.weblink img, a.weblink svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: $body-font-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.weblink span {
|
||||||
|
flex: 1 1 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags a {
|
||||||
|
color: $alternative-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status form {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status form .form-group, .status form .form-switch {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark details view specific */
|
||||||
|
.bookmark-details.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark details modal specific */
|
||||||
|
.bookmark-details.modal {
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $unit-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
@@ -82,9 +82,11 @@
|
|||||||
|
|
||||||
.radio-group {
|
.radio-group {
|
||||||
margin-bottom: $unit-1;
|
margin-bottom: $unit-1;
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-radio.form-inline {
|
.form-radio.form-inline {
|
||||||
margin: 0 $unit-2 0 0;
|
margin: 0 $unit-2 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -92,6 +94,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: $unit-1;
|
column-gap: $unit-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-icon {
|
.form-icon {
|
||||||
top: 0;
|
top: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -268,55 +271,13 @@ ul.bookmark-list {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.show-notes .notes,
|
.notes .markdown {
|
||||||
li.show-notes .notes {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmark notes markdown styles */
|
|
||||||
ul.bookmark-list .notes-content {
|
|
||||||
& {
|
|
||||||
padding: $unit-2 $unit-3;
|
padding: $unit-2 $unit-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
p, ul, ol, pre, blockquote {
|
&.show-notes .notes,
|
||||||
margin: 0 0 $unit-2 0;
|
li.show-notes .notes {
|
||||||
}
|
display: block;
|
||||||
|
|
||||||
> *:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
> *:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul, ol {
|
|
||||||
margin-left: $unit-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul li, ol li {
|
|
||||||
margin-top: $unit-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
padding: $unit-1 $unit-2;
|
|
||||||
background-color: $code-bg-color;
|
|
||||||
border-radius: $unit-1;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre code {
|
|
||||||
background: none;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
> pre:first-child:last-child {
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
40
bookmarks/styles/markdown.scss
Normal file
40
bookmarks/styles/markdown.scss
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
.markdown {
|
||||||
|
p, ul, ol, pre, blockquote {
|
||||||
|
margin: 0 0 $unit-2 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin-left: $unit-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li, ol li {
|
||||||
|
margin-top: $unit-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: $unit-1 $unit-2;
|
||||||
|
background-color: $code-bg-color;
|
||||||
|
border-radius: $unit-1;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> pre:first-child:last-child {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
@@ -37,6 +37,14 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.columns-2 {
|
||||||
|
--grid-columns: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-0 {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.col-1 {
|
.col-1 {
|
||||||
grid-column: unquote("span min(1, var(--grid-columns))");
|
grid-column: unquote("span min(1, var(--grid-columns))");
|
||||||
}
|
}
|
||||||
|
@@ -127,6 +127,53 @@ ul.menu li:first-child {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customize modal animation
|
||||||
|
@keyframes fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-out {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active .modal-container, .modal.active .modal-overlay {
|
||||||
|
animation: fade-in .15s ease 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active.closing .modal-container, .modal.active.closing .modal-overlay {
|
||||||
|
animation: fade-out .15s ease 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customize menu animation
|
||||||
|
.dropdown .menu {
|
||||||
|
animation: fade-in .15s ease 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal close button
|
||||||
|
.modal .modal-header button.close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: .85;
|
||||||
|
color: $gray-color-dark;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Increase input font size on small viewports to prevent zooming on focus the input
|
// Increase input font size on small viewports to prevent zooming on focus the input
|
||||||
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
||||||
// viewport size
|
// viewport size
|
||||||
|
@@ -7,9 +7,11 @@
|
|||||||
// Import style modules
|
// Import style modules
|
||||||
@import "base";
|
@import "base";
|
||||||
@import "responsive";
|
@import "responsive";
|
||||||
|
@import "bookmark-details";
|
||||||
@import "bookmark-page";
|
@import "bookmark-page";
|
||||||
@import "bookmark-form";
|
@import "bookmark-form";
|
||||||
@import "settings";
|
@import "settings";
|
||||||
|
@import "markdown";
|
||||||
|
|
||||||
/* Dark theme overrides */
|
/* Dark theme overrides */
|
||||||
|
|
||||||
@@ -40,8 +42,17 @@ a:focus, .btn:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||||
background: $dt-primary-button-color;
|
background: $dt-primary-input-color;
|
||||||
border-color: $dt-primary-button-color;
|
border-color: $dt-primary-input-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-switch .form-icon::before, .form-switch input:active + .form-icon::before {
|
||||||
|
background: $light-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-switch input:checked + .form-icon {
|
||||||
|
background: $dt-primary-input-color;
|
||||||
|
border-color: $dt-primary-input-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-radio input:checked + .form-icon::before {
|
.form-radio input:checked + .form-icon::before {
|
||||||
|
@@ -7,6 +7,8 @@
|
|||||||
// Import style modules
|
// Import style modules
|
||||||
@import "base";
|
@import "base";
|
||||||
@import "responsive";
|
@import "responsive";
|
||||||
|
@import "bookmark-details";
|
||||||
@import "bookmark-page";
|
@import "bookmark-page";
|
||||||
@import "bookmark-form";
|
@import "bookmark-form";
|
||||||
@import "settings";
|
@import "settings";
|
||||||
|
@import "markdown";
|
||||||
|
@@ -30,4 +30,5 @@ $code-bg-color: rgba(255, 255, 255, 0.1);
|
|||||||
$code-shadow-color: rgba(255, 255, 255, 0.2);
|
$code-shadow-color: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
/* Dark theme specific */
|
/* Dark theme specific */
|
||||||
|
$dt-primary-input-color: #5C68E7 !default;
|
||||||
$dt-primary-button-color: #5761cb !default;
|
$dt-primary-button-color: #5761cb !default;
|
||||||
|
@@ -60,7 +60,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.notes %}
|
{% if bookmark_item.notes %}
|
||||||
<div class="notes bg-gray text-gray-dark">
|
<div class="notes bg-gray text-gray-dark">
|
||||||
<div class="notes-content">
|
<div class="markdown">
|
||||||
{% markdown bookmark_item.notes %}
|
{% markdown bookmark_item.notes %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,6 +79,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="separator">|</span>
|
<span class="separator">|</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# View link is always visible #}
|
||||||
|
<a ld-modal
|
||||||
|
modal-url="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
||||||
|
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
||||||
{% if bookmark_item.is_editable %}
|
{% if bookmark_item.is_editable %}
|
||||||
{# Bookmark owner actions #}
|
{# Bookmark owner actions #}
|
||||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||||
|
13
bookmarks/templates/bookmarks/details.html
Normal file
13
bookmarks/templates/bookmarks/details.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'bookmarks/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div ld-bookmark-details class="bookmark-details page">
|
||||||
|
{% if request.user == bookmark.owner %}
|
||||||
|
{% include 'bookmarks/details/actions.html' %}
|
||||||
|
{% endif %}
|
||||||
|
{% include 'bookmarks/details/title.html' %}
|
||||||
|
<div>
|
||||||
|
{% include 'bookmarks/details/content.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
13
bookmarks/templates/bookmarks/details/actions.html
Normal file
13
bookmarks/templates/bookmarks/details/actions.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<div class="actions">
|
||||||
|
<div class="left-actions">
|
||||||
|
<a class="btn" href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ edit_return_url|urlencode }}">Edit</a>
|
||||||
|
</div>
|
||||||
|
<div class="right-actions">
|
||||||
|
<form action="{% url 'bookmarks:index.action' %}?return_url={{ delete_return_url|urlencode }}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark.id }}" class="btn btn-link text-error">
|
||||||
|
Delete...
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
85
bookmarks/templates/bookmarks/details/content.html
Normal file
85
bookmarks/templates/bookmarks/details/content.html
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{% load static %}
|
||||||
|
{% load shared %}
|
||||||
|
|
||||||
|
<div class="weblinks">
|
||||||
|
<a class="weblink" href="{{ bookmark.url }}" rel="noopener"
|
||||||
|
target="{{ request.user_profile.bookmark_link_target }}">
|
||||||
|
{% if bookmark.favicon_file and request.user_profile.enable_favicons %}
|
||||||
|
<img class="favicon" src="{% static bookmark.favicon_file %}" alt="">
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ bookmark.url }}</span>
|
||||||
|
</a>
|
||||||
|
{% if bookmark.web_archive_snapshot_url %}
|
||||||
|
<a class="weblink" href="{{ bookmark.web_archive_snapshot_url }}"
|
||||||
|
target="{{ request.user_profile.bookmark_link_target }}">
|
||||||
|
{% if bookmark.favicon_file and request.user_profile.enable_favicons %}
|
||||||
|
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="m76 82v4h-76l.00080851-4zm-3-6v5h-70v-5zm-62.6696277-54 .8344146.4217275.4176066 6.7436084.4176065 10.9576581v10.5383496l-.4176065 13.1364492-.0694681 8.8498268-1.1825531.3523804h-4.17367003l-1.25202116-.3523804-.48627608-8.8498268-.41840503-13.0662957v-10.5375432l.41840503-11.028618.38167482-6.7798947.87034634-.3854412zm60.0004653 0 .8353798.4217275.4168913 6.7436084.4168913 10.9576581v10.5383496l-.4168913 13.1364492-.0686832 8.8498268-1.1835879.3523804h-4.1737047l-1.2522712-.3523804-.4879704-8.8498268-.4168913-13.0662957v-10.5375432l.4168913-11.028618.3833483-6.7798947.8697215-.3854412zm-42.000632 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2529447-.3523804-.4863246-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8688361-.3854412zm23 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2521462-.3523804-.4871231-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8696347-.3854412zm21.6697944-9v7h-70v-7zm-35.7200748-13 36.7200748 8.4088317-1.4720205 2.5911683h-70.32799254l-2.19998696-2.10140371z"
|
||||||
|
fill="currentColor" fill-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
<span>View on Internet Archive</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<dl class="grid columns-2 columns-sm-1 gap-0">
|
||||||
|
{% if request.user == bookmark.owner %}
|
||||||
|
<div class="status col-2">
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd class="d-flex" style="gap: .8rem">
|
||||||
|
<form action="{% url 'bookmarks:details' bookmark.id %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-switch">
|
||||||
|
<input type="checkbox" name="is_archived" {% if bookmark.is_archived %}checked{% endif %}>
|
||||||
|
<i class="form-icon"></i> Archived
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-switch">
|
||||||
|
<input type="checkbox" name="unread" {% if bookmark.unread %}checked{% endif %}>
|
||||||
|
<i class="form-icon"></i> Unread
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% if request.user_profile.enable_sharing %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-switch">
|
||||||
|
<input type="checkbox" name="shared" {% if bookmark.shared %}checked{% endif %}>
|
||||||
|
<i class="form-icon"></i> Shared
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark.tag_names %}
|
||||||
|
<div class="tags col-1">
|
||||||
|
<dt>Tags</dt>
|
||||||
|
<dd>
|
||||||
|
{% for tag_name in bookmark.tag_names %}
|
||||||
|
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="date-added col-1">
|
||||||
|
<dt>Date added</dt>
|
||||||
|
<dd>
|
||||||
|
<span>{{ bookmark.date_added }}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% if bookmark.resolved_description %}
|
||||||
|
<div class="description col-2">
|
||||||
|
<dt>Description</dt>
|
||||||
|
<dd>{{ bookmark.resolved_description }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark.notes %}
|
||||||
|
<div class="notes col-2">
|
||||||
|
<dt>Notes</dt>
|
||||||
|
<dd class="markdown">{% markdown bookmark.notes %}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
3
bookmarks/templates/bookmarks/details/title.html
Normal file
3
bookmarks/templates/bookmarks/details/title.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<h2>
|
||||||
|
{{ bookmark.resolved_title }}
|
||||||
|
</h2>
|
27
bookmarks/templates/bookmarks/details_modal.html
Normal file
27
bookmarks/templates/bookmarks/details_modal.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<div ld-bookmark-details class="modal active bookmark-details">
|
||||||
|
<div class="modal-overlay" aria-label="Close"></div>
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
{% include 'bookmarks/details/title.html' %}
|
||||||
|
<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>
|
||||||
|
<path d="M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="content">
|
||||||
|
{% include 'bookmarks/details/content.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if request.user == bookmark.owner %}
|
||||||
|
<div class="modal-footer">
|
||||||
|
{% include 'bookmarks/details/actions.html' %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
562
bookmarks/tests/test_bookmark_details_modal.py
Normal file
562
bookmarks/tests/test_bookmark_details_modal.py
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import formats
|
||||||
|
|
||||||
|
from bookmarks.models import UserProfile
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
|
def setUp(self):
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
def get_base_url(self, bookmark):
|
||||||
|
return reverse("bookmarks:details_modal", args=[bookmark.id])
|
||||||
|
|
||||||
|
def get_details(self, bookmark, return_url=""):
|
||||||
|
url = self.get_base_url(bookmark)
|
||||||
|
if return_url:
|
||||||
|
url += f"?return_url={return_url}"
|
||||||
|
response = self.client.get(url)
|
||||||
|
soup = self.make_soup(response.content)
|
||||||
|
return soup
|
||||||
|
|
||||||
|
def find_section(self, soup, section_name):
|
||||||
|
dt = soup.find("dt", string=section_name)
|
||||||
|
dd = dt.find_next_sibling("dd") if dt else None
|
||||||
|
return dd
|
||||||
|
|
||||||
|
def get_section(self, soup, section_name):
|
||||||
|
dd = self.find_section(soup, section_name)
|
||||||
|
self.assertIsNotNone(dd)
|
||||||
|
return dd
|
||||||
|
|
||||||
|
def find_weblink(self, soup, url):
|
||||||
|
return soup.find("a", {"class": "weblink", "href": url})
|
||||||
|
|
||||||
|
def test_access(self):
|
||||||
|
# own bookmark
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:details_modal", args=[bookmark.id])
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# other user's bookmark
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:details_modal", args=[bookmark.id])
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# non-existent bookmark
|
||||||
|
response = self.client.get(reverse("bookmarks:details_modal", args=[9999]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# guest user
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:details_modal", args=[bookmark.id])
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_access_with_sharing(self):
|
||||||
|
# shared bookmark, sharing disabled
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(shared=True, user=other_user)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:details_modal", args=[bookmark.id])
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared bookmark, sharing enabled
|
||||||
|
profile = other_user.profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:details_modal", args=[bookmark.id])
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# shared bookmark, guest user, no public sharing
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:details_modal", args=[bookmark.id])
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared bookmark, guest user, public sharing
|
||||||
|
profile.enable_public_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:details_modal", args=[bookmark.id])
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_displays_title(self):
|
||||||
|
# with title
|
||||||
|
bookmark = self.setup_bookmark(title="Test title")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
title = soup.find("h2")
|
||||||
|
self.assertIsNotNone(title)
|
||||||
|
self.assertEqual(title.text.strip(), bookmark.title)
|
||||||
|
|
||||||
|
# with website title
|
||||||
|
bookmark = self.setup_bookmark(title="", website_title="Website title")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
title = soup.find("h2")
|
||||||
|
self.assertIsNotNone(title)
|
||||||
|
self.assertEqual(title.text.strip(), bookmark.website_title)
|
||||||
|
|
||||||
|
# with URL only
|
||||||
|
bookmark = self.setup_bookmark(title="", website_title="")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
title = soup.find("h2")
|
||||||
|
self.assertIsNotNone(title)
|
||||||
|
self.assertEqual(title.text.strip(), bookmark.url)
|
||||||
|
|
||||||
|
def test_website_link(self):
|
||||||
|
# basics
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
|
self.assertIsNotNone(link)
|
||||||
|
self.assertEqual(link["href"], bookmark.url)
|
||||||
|
self.assertEqual(link.text.strip(), bookmark.url)
|
||||||
|
|
||||||
|
# favicons disabled
|
||||||
|
bookmark = self.setup_bookmark(favicon_file="example.png")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
|
image = link.select_one("img")
|
||||||
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
# favicons enabled, no favicon
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_favicons = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark(favicon_file="")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
|
image = link.select_one("img")
|
||||||
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
# favicons enabled, favicon present
|
||||||
|
bookmark = self.setup_bookmark(favicon_file="example.png")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
|
image = link.select_one("img")
|
||||||
|
self.assertIsNotNone(image)
|
||||||
|
self.assertEqual(image["src"], "/static/example.png")
|
||||||
|
|
||||||
|
def test_internet_archive_link(self):
|
||||||
|
# without snapshot url
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
self.assertIsNone(link)
|
||||||
|
|
||||||
|
# with snapshot url
|
||||||
|
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
self.assertIsNotNone(link)
|
||||||
|
self.assertEqual(link["href"], bookmark.web_archive_snapshot_url)
|
||||||
|
self.assertEqual(link.text.strip(), "View on Internet Archive")
|
||||||
|
|
||||||
|
# favicons disabled
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
|
||||||
|
)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
image = link.select_one("svg")
|
||||||
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
# favicons enabled, no favicon
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_favicons = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
web_archive_snapshot_url="https://example.com/", favicon_file=""
|
||||||
|
)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
image = link.select_one("svg")
|
||||||
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
# favicons enabled, favicon present
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
|
||||||
|
)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
image = link.select_one("svg")
|
||||||
|
self.assertIsNotNone(image)
|
||||||
|
|
||||||
|
def test_weblinks_respect_target_setting(self):
|
||||||
|
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||||
|
|
||||||
|
# target blank
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_BLANK
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
website_link = self.find_weblink(soup, bookmark.url)
|
||||||
|
self.assertIsNotNone(website_link)
|
||||||
|
self.assertEqual(website_link["target"], UserProfile.BOOKMARK_LINK_TARGET_BLANK)
|
||||||
|
|
||||||
|
web_archive_link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
self.assertIsNotNone(web_archive_link)
|
||||||
|
self.assertEqual(
|
||||||
|
web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_BLANK
|
||||||
|
)
|
||||||
|
|
||||||
|
# target self
|
||||||
|
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
website_link = self.find_weblink(soup, bookmark.url)
|
||||||
|
self.assertIsNotNone(website_link)
|
||||||
|
self.assertEqual(website_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF)
|
||||||
|
|
||||||
|
web_archive_link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
self.assertIsNotNone(web_archive_link)
|
||||||
|
self.assertEqual(
|
||||||
|
web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status(self):
|
||||||
|
# renders form
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
|
form = section.find("form")
|
||||||
|
self.assertIsNotNone(form)
|
||||||
|
self.assertEqual(
|
||||||
|
form["action"], reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
)
|
||||||
|
self.assertEqual(form["method"], "post")
|
||||||
|
|
||||||
|
# sharing disabled
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
self.assertIsNotNone(archived)
|
||||||
|
unread = section.find("input", {"type": "checkbox", "name": "unread"})
|
||||||
|
self.assertIsNotNone(unread)
|
||||||
|
shared = section.find("input", {"type": "checkbox", "name": "shared"})
|
||||||
|
self.assertIsNone(shared)
|
||||||
|
|
||||||
|
# sharing enabled
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
self.assertIsNotNone(archived)
|
||||||
|
unread = section.find("input", {"type": "checkbox", "name": "unread"})
|
||||||
|
self.assertIsNotNone(unread)
|
||||||
|
shared = section.find("input", {"type": "checkbox", "name": "shared"})
|
||||||
|
self.assertIsNotNone(shared)
|
||||||
|
|
||||||
|
# unchecked
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
self.assertFalse(archived.has_attr("checked"))
|
||||||
|
unread = section.find("input", {"type": "checkbox", "name": "unread"})
|
||||||
|
self.assertFalse(unread.has_attr("checked"))
|
||||||
|
shared = section.find("input", {"type": "checkbox", "name": "shared"})
|
||||||
|
self.assertFalse(shared.has_attr("checked"))
|
||||||
|
|
||||||
|
# checked
|
||||||
|
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
self.assertTrue(archived.has_attr("checked"))
|
||||||
|
unread = section.find("input", {"type": "checkbox", "name": "unread"})
|
||||||
|
self.assertTrue(unread.has_attr("checked"))
|
||||||
|
shared = section.find("input", {"type": "checkbox", "name": "shared"})
|
||||||
|
self.assertTrue(shared.has_attr("checked"))
|
||||||
|
|
||||||
|
def test_status_visibility(self):
|
||||||
|
# own bookmark
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.find_section(soup, "Status")
|
||||||
|
form_action = reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
form = soup.find("form", {"action": form_action})
|
||||||
|
self.assertIsNotNone(section)
|
||||||
|
self.assertIsNotNone(form)
|
||||||
|
|
||||||
|
# other user's bookmark
|
||||||
|
other_user = self.setup_user(enable_sharing=True)
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.find_section(soup, "Status")
|
||||||
|
form_action = reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
form = soup.find("form", {"action": form_action})
|
||||||
|
self.assertIsNone(section)
|
||||||
|
self.assertIsNone(form)
|
||||||
|
|
||||||
|
# guest user
|
||||||
|
self.client.logout()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.find_section(soup, "Status")
|
||||||
|
form_action = reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
form = soup.find("form", {"action": form_action})
|
||||||
|
self.assertIsNone(section)
|
||||||
|
self.assertIsNone(form)
|
||||||
|
|
||||||
|
def test_status_update(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
# update status
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertTrue(bookmark.is_archived)
|
||||||
|
self.assertTrue(bookmark.unread)
|
||||||
|
self.assertTrue(bookmark.shared)
|
||||||
|
|
||||||
|
# update individual status
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "", "unread": "on", "shared": ""},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertFalse(bookmark.is_archived)
|
||||||
|
self.assertTrue(bookmark.unread)
|
||||||
|
self.assertFalse(bookmark.shared)
|
||||||
|
|
||||||
|
def test_status_update_access(self):
|
||||||
|
# no sharing
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared, sharing disabled
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared, sharing enabled
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
profile = other_user.profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared, public sharing enabled
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
profile = other_user.profile
|
||||||
|
profile.enable_public_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# guest user
|
||||||
|
self.client.logout()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_date_added(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Date added")
|
||||||
|
|
||||||
|
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
|
||||||
|
date = section.find("span", string=expected_date)
|
||||||
|
self.assertIsNotNone(date)
|
||||||
|
|
||||||
|
def test_tags(self):
|
||||||
|
# without tags
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.find_section(soup, "Tags")
|
||||||
|
self.assertIsNone(section)
|
||||||
|
|
||||||
|
# with tags
|
||||||
|
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Tags")
|
||||||
|
|
||||||
|
for tag in bookmark.tags.all():
|
||||||
|
tag_link = section.find("a", string=f"#{tag.name}")
|
||||||
|
self.assertIsNotNone(tag_link)
|
||||||
|
expected_url = reverse("bookmarks:index") + f"?q=%23{tag.name}"
|
||||||
|
self.assertEqual(tag_link["href"], expected_url)
|
||||||
|
|
||||||
|
def test_description(self):
|
||||||
|
# without description
|
||||||
|
bookmark = self.setup_bookmark(description="", website_description="")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.find_section(soup, "Description")
|
||||||
|
self.assertIsNone(section)
|
||||||
|
|
||||||
|
# with description
|
||||||
|
bookmark = self.setup_bookmark(description="Test description")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.get_section(soup, "Description")
|
||||||
|
self.assertEqual(section.text.strip(), bookmark.description)
|
||||||
|
|
||||||
|
# with website description
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
description="", website_description="Website description"
|
||||||
|
)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.get_section(soup, "Description")
|
||||||
|
self.assertEqual(section.text.strip(), bookmark.website_description)
|
||||||
|
|
||||||
|
def test_notes(self):
|
||||||
|
# without notes
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.find_section(soup, "Notes")
|
||||||
|
self.assertIsNone(section)
|
||||||
|
|
||||||
|
# with notes
|
||||||
|
bookmark = self.setup_bookmark(notes="Test notes")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.get_section(soup, "Notes")
|
||||||
|
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
|
||||||
|
|
||||||
|
def test_edit_link(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
# with default return URL
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
edit_link = soup.find("a", string="Edit")
|
||||||
|
self.assertIsNotNone(edit_link)
|
||||||
|
details_url = reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
expected_url = (
|
||||||
|
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=" + details_url
|
||||||
|
)
|
||||||
|
self.assertEqual(edit_link["href"], expected_url)
|
||||||
|
|
||||||
|
# with custom return URL
|
||||||
|
soup = self.get_details(bookmark, return_url="/custom")
|
||||||
|
edit_link = soup.find("a", string="Edit")
|
||||||
|
self.assertIsNotNone(edit_link)
|
||||||
|
expected_url = (
|
||||||
|
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=/custom"
|
||||||
|
)
|
||||||
|
self.assertEqual(edit_link["href"], expected_url)
|
||||||
|
|
||||||
|
def test_delete_button(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
# basics
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
|
self.assertIsNotNone(delete_button)
|
||||||
|
self.assertEqual(delete_button.text.strip(), "Delete...")
|
||||||
|
self.assertEqual(delete_button["value"], str(bookmark.id))
|
||||||
|
|
||||||
|
form = delete_button.find_parent("form")
|
||||||
|
self.assertIsNotNone(form)
|
||||||
|
expected_url = reverse("bookmarks:index.action") + f"?return_url=/bookmarks"
|
||||||
|
self.assertEqual(form["action"], expected_url)
|
||||||
|
|
||||||
|
# with custom return URL
|
||||||
|
soup = self.get_details(bookmark, return_url="/custom")
|
||||||
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
|
form = delete_button.find_parent("form")
|
||||||
|
expected_url = reverse("bookmarks:index.action") + f"?return_url=/custom"
|
||||||
|
self.assertEqual(form["action"], expected_url)
|
||||||
|
|
||||||
|
def test_actions_visibility(self):
|
||||||
|
# with sharing
|
||||||
|
other_user = self.setup_user(enable_sharing=True)
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
edit_link = soup.find("a", string="Edit")
|
||||||
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
|
self.assertIsNone(edit_link)
|
||||||
|
self.assertIsNone(delete_button)
|
||||||
|
|
||||||
|
# with public sharing
|
||||||
|
profile = other_user.profile
|
||||||
|
profile.enable_public_sharing = True
|
||||||
|
profile.save()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
edit_link = soup.find("a", string="Edit")
|
||||||
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
|
self.assertIsNone(edit_link)
|
||||||
|
self.assertIsNone(delete_button)
|
||||||
|
|
||||||
|
# guest user
|
||||||
|
self.client.logout()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
edit_link = soup.find("a", string="Edit")
|
||||||
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
|
self.assertIsNone(edit_link)
|
||||||
|
self.assertIsNone(delete_button)
|
8
bookmarks/tests/test_bookmark_details_view.py
Normal file
8
bookmarks/tests/test_bookmark_details_view.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.tests.test_bookmark_details_modal import BookmarkDetailsModalTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkDetailsViewTestCase(BookmarkDetailsModalTestCase):
|
||||||
|
def get_base_url(self, bookmark):
|
||||||
|
return reverse("bookmarks:details", args=[bookmark.id])
|
@@ -59,6 +59,19 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def assertViewLink(
|
||||||
|
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
|
||||||
|
):
|
||||||
|
details_url = reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id])
|
||||||
|
self.assertInHTML(
|
||||||
|
f"""
|
||||||
|
<a ld-modal modal-url="{details_modal_url}?return_url={return_url}" href="{details_url}">View</a>
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
|
||||||
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
|
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
|
||||||
self.assertBookmarkActionsCount(html, bookmark, count=1)
|
self.assertBookmarkActionsCount(html, bookmark, count=1)
|
||||||
|
|
||||||
@@ -101,6 +114,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
self.assertShareInfoCount(html, bookmark, 0)
|
self.assertShareInfoCount(html, bookmark, 0)
|
||||||
|
|
||||||
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
|
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
|
||||||
|
# Shared by link
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<span>Shared by
|
<span>Shared by
|
||||||
@@ -154,7 +168,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<div class="notes bg-gray text-gray-dark">
|
<div class="notes bg-gray text-gray-dark">
|
||||||
<div class="notes-content">
|
<div class="markdown">
|
||||||
{notes_html}
|
{notes_html}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -517,6 +531,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
html = self.render_template()
|
html = self.render_template()
|
||||||
|
|
||||||
|
self.assertViewLink(html, bookmark)
|
||||||
self.assertBookmarkActions(html, bookmark)
|
self.assertBookmarkActions(html, bookmark)
|
||||||
self.assertNoShareInfo(html, bookmark)
|
self.assertNoShareInfo(html, bookmark)
|
||||||
|
|
||||||
@@ -530,6 +545,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
||||||
|
|
||||||
|
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
|
||||||
self.assertNoBookmarkActions(html, bookmark)
|
self.assertNoBookmarkActions(html, bookmark)
|
||||||
self.assertShareInfo(html, bookmark)
|
self.assertShareInfo(html, bookmark)
|
||||||
|
|
||||||
@@ -785,6 +801,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
self.assertWebArchiveLink(
|
self.assertWebArchiveLink(
|
||||||
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
|
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
|
||||||
)
|
)
|
||||||
|
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
|
||||||
self.assertNoBookmarkActions(html, bookmark)
|
self.assertNoBookmarkActions(html, bookmark)
|
||||||
self.assertShareInfo(html, bookmark)
|
self.assertShareInfo(html, bookmark)
|
||||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||||
|
@@ -34,6 +34,16 @@ urlpatterns = [
|
|||||||
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/<int:bookmark_id>/details",
|
||||||
|
views.bookmarks.details,
|
||||||
|
name="details",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"bookmarks/<int:bookmark_id>/details_modal",
|
||||||
|
views.bookmarks.details_modal,
|
||||||
|
name="details_modal",
|
||||||
|
),
|
||||||
# Partials
|
# Partials
|
||||||
path(
|
path(
|
||||||
"bookmarks/partials/bookmark-list/active",
|
"bookmarks/partials/bookmark-list/active",
|
||||||
|
@@ -104,6 +104,59 @@ def search_action(request):
|
|||||||
return HttpResponseRedirect(url)
|
return HttpResponseRedirect(url)
|
||||||
|
|
||||||
|
|
||||||
|
def _details(request, bookmark_id: int, template: str):
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(pk=bookmark_id)
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
is_owner = bookmark.owner == request.user
|
||||||
|
is_shared = (
|
||||||
|
request.user.is_authenticated
|
||||||
|
and bookmark.shared
|
||||||
|
and bookmark.owner.profile.enable_sharing
|
||||||
|
)
|
||||||
|
is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing
|
||||||
|
if not is_owner and not is_shared and not is_public_shared:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
edit_return_url = get_safe_return_url(
|
||||||
|
request.GET.get("return_url"), reverse("bookmarks:details", args=[bookmark_id])
|
||||||
|
)
|
||||||
|
delete_return_url = get_safe_return_url(
|
||||||
|
request.GET.get("return_url"), reverse("bookmarks:index")
|
||||||
|
)
|
||||||
|
|
||||||
|
# handles status actions form
|
||||||
|
if request.method == "POST":
|
||||||
|
if not is_owner:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
bookmark.is_archived = request.POST.get("is_archived") == "on"
|
||||||
|
bookmark.unread = request.POST.get("unread") == "on"
|
||||||
|
bookmark.shared = request.POST.get("shared") == "on"
|
||||||
|
bookmark.save()
|
||||||
|
|
||||||
|
return HttpResponseRedirect(edit_return_url)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
template,
|
||||||
|
{
|
||||||
|
"bookmark": bookmark,
|
||||||
|
"edit_return_url": edit_return_url,
|
||||||
|
"delete_return_url": delete_return_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def details(request, bookmark_id: int):
|
||||||
|
return _details(request, bookmark_id, "bookmarks/details.html")
|
||||||
|
|
||||||
|
|
||||||
|
def details_modal(request, bookmark_id: int):
|
||||||
|
return _details(request, bookmark_id, "bookmarks/details_modal.html")
|
||||||
|
|
||||||
|
|
||||||
def convert_tag_string(tag_string: str):
|
def convert_tag_string(tag_string: str):
|
||||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||||
# strings
|
# strings
|
||||||
|
Reference in New Issue
Block a user