Speed up response times for certain actions (#829)

* return updated HTML from bookmark actions

* open details through URL

* fix details update

* improve modal behavior

* use a frame

* make behaviors properly destroy themselves

* remove page and details params from tag urls

* use separate behavior for details and tags

* remove separate details view

* make it work with other views

* add asset actions

* remove asset refresh for now

* remove details partial

* fix tests

* remove old partials

* update tests

* cache and reuse tags

* extract search autocomplete behavior

* remove details param from pagination

* fix tests

* only return details modal when navigating in frame

* fix link target

* remove unused behaviors

* use auto submit behavior for user select

* fix import
This commit is contained in:
Sascha Ißbrücker 2024-09-16 12:48:19 +02:00 committed by GitHub
parent db225d5267
commit ffaaf0521d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 1419 additions and 1444 deletions

View file

@ -121,8 +121,9 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
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):
# Cancel edit, verify return to details url
details_url = url + f"&details={bookmark.id}"
with self.page.expect_navigation(url=self.live_server_url + details_url):
self.page.get_by_text("Nevermind").click()
def test_delete(self):
@ -167,7 +168,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
# Has new snapshots
expect(snapshot).to_be_visible()
# Create snapshot
# Remove snapshot
asset_list.get_by_text("Remove", exact=False).click()
asset_list.get_by_text("Confirm", exact=False).click()

View file

@ -1,37 +0,0 @@
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()

View file

@ -1,9 +1,7 @@
from django.test import override_settings
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect, Locator
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
@ -26,7 +24,7 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
# verify modal is visible
modal = page.locator(".modal")
expect(modal).to_be_visible()
expect(modal.locator(".modal-title")).to_have_text("Tags")
expect(modal.locator("h2")).to_have_text("Tags")
# close with close button
modal.locator("button.close").click()

View file

@ -1,4 +1,4 @@
export class ApiClient {
export class Api {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
@ -27,3 +27,6 @@ export class ApiClient {
.then((data) => data.results);
}
}
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
export const api = new Api(apiBaseUrl);

View file

@ -5,9 +5,10 @@ class BookmarkItem extends Behavior {
super(element);
// Toggle notes
const notesToggle = element.querySelector(".toggle-notes");
if (notesToggle) {
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
this.onToggleNotes = this.onToggleNotes.bind(this);
this.notesToggle = element.querySelector(".toggle-notes");
if (this.notesToggle) {
this.notesToggle.addEventListener("click", this.onToggleNotes);
}
// Add tooltip to title if it is truncated
@ -20,6 +21,12 @@ class BookmarkItem extends Behavior {
});
}
destroy() {
if (this.notesToggle) {
this.notesToggle.removeEventListener("click", this.onToggleNotes);
}
}
onToggleNotes(event) {
event.preventDefault();
event.stopPropagation();

View file

@ -13,12 +13,13 @@ class BulkEdit extends Behavior {
this.onActionSelected = this.onActionSelected.bind(this);
this.init();
// Reset when bookmarks are refreshed
document.addEventListener("refresh-bookmark-list-done", this.init);
// Reset when bookmarks are updated
document.addEventListener("bookmark-list-updated", this.init);
}
destroy() {
document.removeEventListener("refresh-bookmark-list-done", this.init);
this.removeListeners();
document.removeEventListener("bookmark-list-updated", this.init);
}
init() {
@ -36,13 +37,9 @@ class BulkEdit extends Behavior {
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
);
// Remove previous listeners if elements are the same
this.activeToggle.removeEventListener("click", this.onToggleActive);
this.actionSelect.removeEventListener("change", this.onActionSelected);
this.allCheckbox.removeEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.removeEventListener("change", this.onToggleBookmark);
});
// Add listeners, ensure there are no dupes by possibly removing existing listeners
this.removeListeners();
this.addListeners();
// Reset checkbox states
this.reset();
@ -52,8 +49,9 @@ class BulkEdit extends Behavior {
const total = totalHolder?.dataset.bookmarksTotal || 0;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
}
// Add new listeners
addListeners() {
this.activeToggle.addEventListener("click", this.onToggleActive);
this.actionSelect.addEventListener("change", this.onActionSelected);
this.allCheckbox.addEventListener("change", this.onToggleAll);
@ -62,6 +60,15 @@ class BulkEdit extends Behavior {
});
}
removeListeners() {
this.activeToggle.removeEventListener("click", this.onToggleActive);
this.actionSelect.removeEventListener("change", this.onActionSelected);
this.allCheckbox.removeEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.removeEventListener("change", this.onToggleBookmark);
});
}
onToggleActive() {
this.active = !this.active;
if (this.active) {

View file

@ -3,20 +3,14 @@ import { Behavior, registerBehavior } from "./index";
class ConfirmButtonBehavior extends Behavior {
constructor(element) {
super(element);
element.dataset.type = element.type;
element.dataset.name = element.name;
element.dataset.value = element.value;
element.removeAttribute("type");
element.removeAttribute("name");
element.removeAttribute("value");
element.addEventListener("click", this.onClick.bind(this));
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.reset();
this.element.setAttribute("type", this.element.dataset.type);
this.element.setAttribute("name", this.element.dataset.name);
this.element.setAttribute("value", this.element.dataset.value);
this.element.removeEventListener("click", this.onClick);
}
onClick(event) {
@ -56,9 +50,9 @@ class ConfirmButtonBehavior extends Behavior {
cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.element.nodeName);
confirmButton.type = this.element.dataset.type;
confirmButton.name = this.element.dataset.name;
confirmButton.value = this.element.dataset.value;
confirmButton.type = this.element.type;
confirmButton.name = this.element.name;
confirmButton.value = this.element.value;
confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this));

View file

@ -0,0 +1,62 @@
import { Behavior, registerBehavior } from "./index";
class DetailsModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
this.buttonLink = element.querySelector("a:has(button.close)");
this.overlayLink.addEventListener("click", this.onClose);
this.buttonLink.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.overlayLink.removeEventListener("click", this.onClose);
this.buttonLink.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.element.remove();
const closeUrl = this.overlayLink.href;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
}
},
{ once: true },
);
}
}
registerBehavior("ld-details-modal", DetailsModalBehavior);

View file

@ -4,20 +4,16 @@ class DropdownBehavior extends Behavior {
constructor(element) {
super(element);
this.opened = false;
this.onClick = this.onClick.bind(this);
this.onOutsideClick = this.onOutsideClick.bind(this);
const toggle = element.querySelector(".dropdown-toggle");
toggle.addEventListener("click", () => {
if (this.opened) {
this.close();
} else {
this.open();
}
});
this.toggle = element.querySelector(".dropdown-toggle");
this.toggle.addEventListener("click", this.onClick);
}
destroy() {
this.close();
this.toggle.removeEventListener("click", this.onClick);
}
open() {
@ -30,6 +26,14 @@ class DropdownBehavior extends Behavior {
document.removeEventListener("click", this.onOutsideClick);
}
onClick() {
if (this.opened) {
this.close();
} else {
this.open();
}
}
onOutsideClick(event) {
if (!this.element.contains(event.target)) {
this.close();

View file

@ -1,48 +0,0 @@
import { Behavior, fireEvents, registerBehavior, swap } from "./index";
class FetchBehavior extends Behavior {
constructor(element) {
super(element);
const eventName = element.getAttribute("ld-on");
const interval = parseInt(element.getAttribute("ld-interval")) * 1000;
this.onFetch = this.onFetch.bind(this);
this.onInterval = this.onInterval.bind(this);
element.addEventListener(eventName, this.onFetch);
if (interval) {
this.intervalId = setInterval(this.onInterval, interval);
}
}
destroy() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
async onFetch(maybeEvent) {
if (maybeEvent) {
maybeEvent.preventDefault();
}
const url = this.element.getAttribute("ld-fetch");
const html = await fetch(url).then((response) => response.text());
const target = this.element.getAttribute("ld-target");
const select = this.element.getAttribute("ld-select");
swap(this.element, html, { target, select });
const events = this.element.getAttribute("ld-fire");
fireEvents(events);
}
onInterval() {
if (Behavior.interacting) {
return;
}
this.onFetch();
}
}
registerBehavior("ld-fetch", FetchBehavior);

View file

@ -1,64 +1,55 @@
import { Behavior, fireEvents, registerBehavior } from "./index";
class FormBehavior extends Behavior {
constructor(element) {
super(element);
element.addEventListener("submit", this.onSubmit.bind(this));
}
async onSubmit(event) {
event.preventDefault();
const url = this.element.action;
const formData = new FormData(this.element);
if (event.submitter) {
formData.append(event.submitter.name, event.submitter.value);
}
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
const events = this.element.getAttribute("ld-fire");
if (fireEvents) {
fireEvents(events);
}
}
}
import { Behavior, registerBehavior } from "./index";
class AutoSubmitBehavior extends Behavior {
constructor(element) {
super(element);
element.addEventListener("change", () => {
const form = element.closest("form");
form.dispatchEvent(new Event("submit", { cancelable: true }));
});
this.submit = this.submit.bind(this);
element.addEventListener("change", this.submit);
}
destroy() {
this.element.removeEventListener("change", this.submit);
}
submit() {
this.element.closest("form").requestSubmit();
}
}
class UploadButton extends Behavior {
constructor(element) {
super(element);
this.fileInput = element.nextElementSibling;
const fileInput = element.nextElementSibling;
this.onClick = this.onClick.bind(this);
this.onChange = this.onChange.bind(this);
element.addEventListener("click", () => {
fileInput.click();
});
element.addEventListener("click", this.onClick);
this.fileInput.addEventListener("change", this.onChange);
}
fileInput.addEventListener("change", () => {
const form = fileInput.closest("form");
const event = new Event("submit", { cancelable: true });
event.submitter = element;
form.dispatchEvent(event);
});
destroy() {
this.element.removeEventListener("click", this.onClick);
this.fileInput.removeEventListener("change", this.onChange);
}
onClick(event) {
event.preventDefault();
this.fileInput.click();
}
onChange() {
// Check if the file input has a file selected
if (!this.fileInput.files.length) {
return;
}
const form = this.fileInput.closest("form");
form.requestSubmit(this.element);
// remove selected file so it doesn't get submitted again
this.fileInput.value = "";
}
}
registerBehavior("ld-form", FormBehavior);
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-upload-button", UploadButton);

View file

@ -103,51 +103,3 @@ export function destroyBehaviors(element) {
});
});
}
export function swap(element, html, options) {
const dom = new DOMParser().parseFromString(html, "text/html");
let targetElement = element;
let strategy = "innerHTML";
if (options.target) {
const parts = options.target.split("|");
targetElement =
parts[0] === "self" ? element : document.querySelector(parts[0]);
strategy = parts[1] || "innerHTML";
}
let contents = Array.from(dom.body.children);
if (options.select) {
contents = Array.from(dom.querySelectorAll(options.select));
}
switch (strategy) {
case "append":
targetElement.append(...contents);
break;
case "outerHTML":
targetElement.parentElement.replaceChild(contents[0], targetElement);
break;
case "innerHTML":
default:
Array.from(targetElement.children).forEach((child) => {
child.remove();
});
targetElement.append(...contents);
}
}
export function fireEvents(events) {
if (!events) {
return;
}
events.split(",").forEach((eventName) => {
const targets = Array.from(
document.querySelectorAll(`[ld-on='${eventName}']`),
);
targets.push(document);
targets.forEach((target) => {
target.dispatchEvent(new CustomEvent(eventName));
});
});
}

View file

@ -1,51 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class ModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
const modalOverlay = element.querySelector(".modal-overlay");
const closeButton = element.querySelector("button.close");
modalOverlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
}
destroy() {
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
this.onClose();
}
}
onClose() {
document.removeEventListener("keydown", this.onKeyDown);
this.element.classList.add("closing");
this.element.addEventListener("animationend", (event) => {
if (event.animationName === "fade-out") {
this.element.remove();
}
});
}
}
registerBehavior("ld-modal", ModalBehavior);

View file

@ -0,0 +1,41 @@
import { Behavior, registerBehavior } from "./index";
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte";
class SearchAutocomplete extends Behavior {
constructor(element) {
super(element);
const input = element.querySelector("input");
if (!input) {
console.warn("SearchAutocomplete: input element not found");
return;
}
const container = document.createElement("div");
new SearchAutoCompleteComponent({
target: container,
props: {
name: "q",
placeholder: input.getAttribute("placeholder") || "",
value: input.value,
linkTarget: input.dataset.linkTarget,
mode: input.dataset.mode,
search: {
user: input.dataset.user,
shared: input.dataset.shared,
unread: input.dataset.unread,
},
},
});
this.input = input;
this.autocomplete = container.firstElementChild;
input.replaceWith(this.autocomplete);
}
destroy() {
this.autocomplete.replaceWith(this.input);
}
}
registerBehavior("ld-search-autocomplete", SearchAutocomplete);

View file

@ -1,19 +1,16 @@
import { Behavior, registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
import { ApiClient } from "../api";
class TagAutocomplete extends Behavior {
constructor(element) {
super(element);
const input = element.querySelector("input");
if (!input) {
console.warning("TagAutocomplete: input element not found");
console.warn("TagAutocomplete: input element not found");
return;
}
const container = document.createElement("div");
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
const apiClient = new ApiClient(apiBaseUrl);
new TagAutoCompleteComponent({
target: container,
@ -22,7 +19,6 @@ class TagAutocomplete extends Behavior {
name: input.name,
value: input.value,
placeholder: input.getAttribute("placeholder") || "",
apiClient: apiClient,
variant: input.getAttribute("variant"),
},
});

View file

@ -0,0 +1,68 @@
import { Behavior, registerBehavior } from "./index";
class TagModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
this.onClose = this.onClose.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.onClose();
this.element.removeEventListener("click", this.onClick);
}
onClick() {
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">
<h2>Tags</h2>
<button class="close" aria-label="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"></div>
</div>
</div>
`;
const tagCloud = document.querySelector(".tag-cloud");
const tagCloudContainer = tagCloud.parentElement;
const content = modal.querySelector(".content");
content.appendChild(tagCloud);
const overlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector(".close");
overlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
this.modal = modal;
this.tagCloud = tagCloud;
this.tagCloudContainer = tagCloudContainer;
document.body.appendChild(modal);
}
onClose() {
if (!this.modal) {
return;
}
this.modal.remove();
this.tagCloudContainer.appendChild(this.tagCloud);
}
}
registerBehavior("ld-tag-modal", TagModalBehavior);

View file

@ -0,0 +1,35 @@
import { api } from "./api.js";
class Cache {
constructor(api) {
this.api = api;
// Reset cached tags after a form submission
document.addEventListener("turbo:submit-end", () => {
this.tagsPromise = null;
});
}
getTags() {
if (!this.tagsPromise) {
this.tagsPromise = this.api
.getTags({
limit: 5000,
offset: 0,
})
.then((tags) =>
tags.sort((left, right) =>
left.name.toLowerCase().localeCompare(right.name.toLowerCase()),
),
)
.catch((e) => {
console.warn("Cache: Error loading tags", e);
return [];
});
}
return this.tagsPromise;
}
}
export const cache = new Cache(api);

View file

@ -1,5 +1,7 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {api} from "../api";
import {cache} from "../cache";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
const searchHistory = new SearchHistory()
@ -7,9 +9,7 @@
export let name;
export let placeholder;
export let value;
export let tags;
export let mode = '';
export let apiClient;
export let search;
export let linkTarget = '_blank';
@ -88,17 +88,18 @@
}
// Tag suggestions
const tags = await cache.getTags();
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tagName => ({
.map(tag => ({
type: 'tag',
index: nextIndex(),
label: `#${tagName}`,
tagName: tagName
label: `#${tag.name}`,
tagName: tag.name
}))
}
@ -119,7 +120,7 @@
...search,
q: value
}
const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)

View file

@ -1,14 +1,13 @@
<script>
import {cache} from "../cache";
import {getCurrentWord, getCurrentWordBounds} from "../util";
export let id;
export let name;
export let value;
export let placeholder;
export let apiClient;
export let variant = 'default';
let tags = [];
let isFocus = false;
let isOpen = false;
let input = null;
@ -17,18 +16,6 @@
let suggestions = [];
let selectedIndex = 0;
init();
async function init() {
// For now we cache all tags on load as the template did before
try {
tags = await apiClient.getTags({limit: 5000, offset: 0});
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
} catch (e) {
console.warn('TagAutocomplete: Error loading tag list');
}
}
function handleFocus() {
isFocus = true;
}
@ -38,9 +25,10 @@
close();
}
function handleInput(e) {
async function handleInput(e) {
input = e.target;
const tags = await cache.getTags();
const word = getCurrentWord(input);
suggestions = word

View file

@ -3,11 +3,14 @@ import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/confirm-button";
import "./behaviors/dropdown";
import "./behaviors/fetch";
import "./behaviors/form";
import "./behaviors/modal";
import "./behaviors/details-modal";
import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete";
import "./behaviors/tag-autocomplete";
import "./behaviors/tag-modal";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
export { ApiClient } from "./api";
export { api } from "./api";
export { cache } from "./cache";

View file

@ -282,7 +282,7 @@ class BookmarkSearchForm(forms.Form):
]
q = forms.CharField()
user = forms.ChoiceField()
user = forms.ChoiceField(required=False)
sort = forms.ChoiceField(choices=SORT_CHOICES)
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)

View file

@ -1,12 +1,5 @@
/* Common styles */
.bookmark-details {
& h2 {
flex: 1 1 0;
align-items: flex-start;
font-size: 1rem;
margin: 0;
}
& .weblinks {
display: flex;
flex-direction: column;
@ -134,17 +127,3 @@
flex-direction: column;
gap: var(--unit-6);
}
/* Bookmark details modal specific */
.bookmark-details.modal {
& .modal-header {
display: flex;
align-items: flex-start;
gap: var(--unit-2);
}
& .modal-body {
padding-top: 0;
padding-bottom: 0;
}
}

View file

@ -65,8 +65,18 @@
width: 100%;
& .modal-header {
display: flex;
align-items: flex-start;
gap: var(--unit-2);
color: var(--text-color);
& h2 {
flex: 1 1 0;
align-items: flex-start;
font-size: 1rem;
margin: 0;
}
& button.close {
background: none;
border: none;

View file

@ -11,24 +11,20 @@
<div class="content-area-header mb-0">
<h2>Archived bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
{% bookmark_search bookmark_list.search mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
class="btn ml-2 show-md">Tags
<button ld-tag-modal class="btn ml-2 show-md">Tags
</button>
</div>
</div>
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
class="bookmark-actions"
<form class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off">
{% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
<div id="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %}
</div>
</form>
@ -39,10 +35,16 @@
<div class="content-area-header">
<h2>Tags</h2>
</div>
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
class="tag-cloud-container">
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}

View file

@ -78,10 +78,7 @@
{% endif %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
ld-on="click" ld-target="body|append"
data-turbo-prefetch="false"
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}

View file

@ -1,13 +0,0 @@
{% extends 'bookmarks/layout.html' %}
{% block content %}
<div class="bookmark-details page">
{% if details.is_editable %}
{% include 'bookmarks/details/actions.html' %}
{% endif %}
{% include 'bookmarks/details/title.html' %}
<div>
{% include 'bookmarks/details/form.html' %}
</div>
</div>
{% endblock %}

View file

@ -1,16 +0,0 @@
<div class="actions">
<div class="left-actions">
<a class="btn btn-wide"
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
</div>
<div class="right-actions">
<form action="{% url 'bookmarks:index.action' %}?return_url={{ details.delete_return_url|urlencode }}"
method="post">
{% csrf_token %}
<button ld-confirm-button type="submit" name="remove" value="{{ details.bookmark.id }}"
class="btn btn-error btn-wide">
Delete...
</button>
</form>
</div>
</div>

View file

@ -1,7 +1,4 @@
<div {% if details.has_pending_assets %}
ld-fetch="{% url 'bookmarks:details_assets' details.bookmark.id %}"
ld-interval="5" ld-target="self|outerHTML"
{% endif %}>
<div>
{% if details.assets %}
<div class="assets">
{% for asset in details.assets %}
@ -36,10 +33,10 @@
{% if details.is_editable %}
<div class="assets-actions">
<button type="submit" name="create_snapshot" class="btn btn-sm"
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
</button>
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="button"
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
class="btn btn-sm">Upload file
</button>
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">

View file

@ -1,9 +1,10 @@
{% load static %}
{% load shared %}
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud,refresh-details"
action="{% url 'bookmarks:details' details.bookmark.id %}"
method="post">
<form action="{{ details.action_url }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="update_state" value="{{ details.bookmark.id }}">
<div class="weblinks">
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
target="{{ details.profile.bookmark_link_target }}">
@ -47,7 +48,6 @@
<div class="status col-2">
<dt>Status</dt>
<dd class="d-flex" style="gap: .8rem">
{% csrf_token %}
<div class="form-group">
<label class="form-switch">
<input ld-auto-submit type="checkbox" name="is_archived"

View file

@ -0,0 +1,47 @@
<div class="modal active bookmark-details"
ld-details-modal>
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
<div class="modal-overlay" aria-label="Close"></div>
</a>
<div class="modal-container">
<div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2>
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
<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>
</a>
</div>
<div class="modal-body">
<div class="content">
{% include 'bookmarks/details/form.html' %}
</div>
</div>
{% if details.is_editable %}
<div class="modal-footer">
<div class="actions">
<div class="left-actions">
<a class="btn btn-wide"
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
</div>
<div class="right-actions">
<form action="{{ details.delete_url }}" method="post" data-turbo-action="replace">
{% csrf_token %}
<input type="hidden" name="disable_turbo" value="true">
<button ld-confirm-button class="btn btn-error btn-wide"
type="submit" name="remove" value="{{ details.bookmark.id }}">
Delete...
</button>
</form>
</div>
</div>
</div>
{% endif %}
</div>
</div>

View file

@ -1,3 +0,0 @@
<h2>
{{ details.bookmark.resolved_title }}
</h2>

View file

@ -1,30 +0,0 @@
<div ld-modal
ld-fetch="{% url 'bookmarks:details_modal' details.bookmark.id %}" ld-on="refresh-details"
ld-select=".content" ld-target=".modal.bookmark-details .content|outerHTML"
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/form.html' %}
</div>
</div>
{% if details.is_editable %}
<div class="modal-footer">
{% include 'bookmarks/details/actions.html' %}
</div>
{% endif %}
</div>
</div>

View file

@ -0,0 +1,40 @@
{% load static %}
<head>
<meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="48x48">
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="Self-hosted bookmark service">
<meta name="robots" content="index,follow">
<meta name="author" content="Sascha Ißbrücker">
<title>linkding</title>
{# Include specific theme variant based on user profile setting #}
{% if request.user_profile.theme == 'light' %}
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#5856e0">
{% elif request.user_profile.theme == 'dark' %}
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#161822">
{% else %}
{# Use auto theme as fallback #}
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %}
{% if request.user_profile.custom_css %}
<style>{{ request.user_profile.custom_css }}</style>
{% endif %}
<meta name="turbo-cache-control" content="no-preview">
{% if not request.global_settings.enable_link_prefetch %}
<meta name="turbo-prefetch" content="false">
{% endif %}
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</head>

View file

@ -11,24 +11,19 @@
<div class="content-area-header mb-0">
<h2>Bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags %}
{% bookmark_search bookmark_list.search %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
class="btn ml-2 show-md">Tags
</button>
<button ld-tag-modal class="btn ml-2 show-md">Tags</button>
</div>
</div>
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
class="bookmark-actions"
<form class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off">
{% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
<div id="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %}
</div>
</form>
@ -39,10 +34,16 @@
<div class="content-area-header">
<h2>Tags</h2>
</div>
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
class="tag-cloud-container">
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}

View file

@ -3,44 +3,7 @@
<!DOCTYPE html>
{# Use data attributes as storage for access in static scripts #}
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
<head>
<meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="48x48">
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="Self-hosted bookmark service">
<meta name="robots" content="index,follow">
<meta name="author" content="Sascha Ißbrücker">
<title>linkding</title>
{# Include specific theme variant based on user profile setting #}
{% if request.user_profile.theme == 'light' %}
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#5856e0">
{% elif request.user_profile.theme == 'dark' %}
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#161822">
{% else %}
{# Use auto theme as fallback #}
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %}
{% if request.user_profile.custom_css %}
<style>{{ request.user_profile.custom_css }}</style>
{% endif %}
<meta name="turbo-cache-control" content="no-preview">
{% if not request.global_settings.enable_link_prefetch %}
<meta name="turbo-prefetch" content="false">
{% endif %}
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</head>
{% include 'bookmarks/head.html' %}
<body ld-global-shortcuts>
<div class="d-none">

View file

@ -1,9 +1,9 @@
{% load shared %}
<ul class="pagination">
{% if page.has_previous %}
{% if prev_link %}
<li class="page-item">
<a href="?{% update_query_string page=page.previous_page_number %}" tabindex="-1">Previous</a>
<a href="?{{ prev_link }}" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
@ -11,10 +11,10 @@
</li>
{% endif %}
{% for page_number in visible_page_numbers %}
{% if page_number >= 0 %}
<li class="page-item {% if page.number == page_number %}active{% endif %}">
<a href="?{% update_query_string page=page_number %}">{{ page_number }}</a>
{% for page_link in page_links %}
{% if page_link %}
<li class="page-item {% if page_link.active %}active{% endif %}">
<a href="?{{ page_link.link }}">{{ page_link.number }}</a>
</li>
{% else %}
<li class="page-item">
@ -23,9 +23,9 @@
{% endif %}
{% endfor %}
{% if page.has_next %}
{% if next_link %}
<li class="page-item">
<a href="?{% update_query_string page=page.next_page_number %}" tabindex="-1">Next</a>
<a href="?{{ next_link }}" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">

View file

@ -1,9 +1,14 @@
{% load widget_tweaks %}
<div class="search-container">
<div ld-search-autocomplete class="search-container">
<form id="search" action="" method="get" role="search">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ search.q }}">
value="{{ search.q }}"
data-link-target="{{ request.user_profile.bookmark_link_target }}"
data-mode="{{ mode }}"
data-user="{{ search.user }}"
data-shared="{{ search.shared }}"
data-unread="{{ search.unread }}">
<input type="submit" value="Search" class="d-none">
{% for hidden_field in search_form.hidden_fields %}
{{ hidden_field }}
@ -73,42 +78,3 @@
</div>
</div>
</div>
{# Replace search input with auto-complete component #}
<script type="application/javascript">
(function init() {
const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' ');
const uniqueTags = [...new Set(currentTags)]
const search = {
q: '{{ search.q }}',
user: '{{ search.user }}',
shared: '{{ search.shared }}',
unread: '{{ search.unread }}',
}
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const input = document.querySelector('#search input[name="q"]')
const container = document.createElement('div')
new linkding.SearchAutoComplete({
target: container,
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: input.value,
tags: uniqueTags,
mode: '{{ mode }}',
linkTarget: '{{ request.user_profile.bookmark_link_target }}',
apiClient,
search,
}
})
const autoComplete = container.firstElementChild;
input.replaceWith(autoComplete);
document.addEventListener("turbo:before-cache", () => {
autoComplete.replaceWith(input);
}, {once: true});
})();
</script>

View file

@ -11,21 +11,17 @@
<div class="content-area-header">
<h2>Shared bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %}
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
class="btn ml-2 show-md">Tags
{% bookmark_search bookmark_list.search mode='shared' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags
</button>
</div>
</div>
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
class="bookmark-actions"
<form class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off">
{% csrf_token %}
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
<div id="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %}
</div>
</form>
@ -43,10 +39,16 @@
<div class="content-area-header">
<h2>Tags</h2>
</div>
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
class="tag-cloud-container">
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}

View file

@ -1,21 +0,0 @@
<div ld-modal class="modal active">
<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="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/tag_cloud.html' %}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,21 @@
<turbo-stream action="update" target="bookmark-list-container">
<template>
{% include 'bookmarks/bookmark_list.html' %}
<script>
document.dispatchEvent(new CustomEvent('bookmark-list-updated'));
</script>
</template>
</turbo-stream>
<turbo-stream action="update" target="tag-cloud-container">
<template>
{% include 'bookmarks/tag_cloud.html' %}
</template>
</turbo-stream>
<turbo-stream action="update" method="morph" target="details-modal">
<template>
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</template>
</turbo-stream>

View file

@ -0,0 +1,10 @@
<html>
{% include 'bookmarks/head.html' %}
<body>
<turbo-frame id="details-modal">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</body>
</html>

View file

@ -6,17 +6,10 @@
{% endfor %}
<div class="form-group">
<div class="d-flex">
{{ form.user|add_class:"form-select" }}
{% render_field form.user class+="form-select" ld-auto-submit="" %}
<noscript>
<button type="submit" class="btn btn-link ml-2">Apply</button>
</noscript>
</div>
</div>
</form>
<script>
const form = document.getElementById('user-select');
const select = form.querySelector('select');
select.addEventListener('change', () => {
form.submit();
});
</script>

View file

@ -27,7 +27,7 @@
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browser's toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
class="btn btn-primary">📎 Add bookmark</a>
</section>
@ -43,7 +43,7 @@
<strong>Please treat this token as you would any other credential.</strong>
Any party with access to this token can access and manage all your bookmarks.
If you think that a token was compromised you can revoke (delete) it in the <a
href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
target="_blank" href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
After deleting the token, a new one will be generated when you reload this settings page.
</p>
</section>

View file

@ -6,8 +6,6 @@ from bookmarks.models import (
BookmarkForm,
BookmarkSearch,
BookmarkSearchForm,
Tag,
build_tag_string,
User,
)
@ -34,9 +32,7 @@ def bookmark_form(
@register.inclusion_tag(
"bookmarks/search.html", name="bookmark_search", takes_context=True
)
def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ""):
tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, " ")
def bookmark_search(context, search: BookmarkSearch, mode: str = ""):
search_form = BookmarkSearchForm(search, editable_fields=["q"])
if mode == "shared":
@ -50,7 +46,6 @@ def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ""
"search": search,
"search_form": search_form,
"preferences_form": preferences_form,
"tags_string": tags_string,
"mode": mode,
}

View file

@ -2,6 +2,7 @@ from functools import reduce
from django import template
from django.core.paginator import Page
from django.http import QueryDict
NUM_ADJACENT_PAGES = 2
@ -12,11 +13,44 @@ register = template.Library()
"bookmarks/pagination.html", name="pagination", takes_context=True
)
def pagination(context, page: Page):
# remove page number and details from query parameters
query_params = context["request"].GET.copy()
query_params.pop("page", None)
query_params.pop("details", None)
prev_link = (
_generate_link(query_params, page.previous_page_number())
if page.has_previous()
else None
)
next_link = (
_generate_link(query_params, page.next_page_number())
if page.has_next()
else None
)
visible_page_numbers = get_visible_page_numbers(
page.number, page.paginator.num_pages
)
page_links = []
for page_number in visible_page_numbers:
if page_number == -1:
page_links.append(None)
else:
link = _generate_link(query_params, page_number)
page_links.append(
{
"active": page_number == page.number,
"number": page_number,
"link": link,
}
)
return {"page": page, "visible_page_numbers": visible_page_numbers}
return {
"prev_link": prev_link,
"next_link": next_link,
"page_links": page_links,
}
def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
@ -56,3 +90,8 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
return result
return reduce(append_page, visible_pages, [])
def _generate_link(query_params: QueryDict, page_number: int) -> str:
query_params["page"] = page_number
return query_params.urlencode()

View file

@ -28,12 +28,13 @@ def add_tag_to_query(context, tag_name: str):
params = context.request.GET.copy()
# Append to or create query string
if params.__contains__("q"):
query_string = params.__getitem__("q") + " "
else:
query_string = ""
query_string = query_string + "#" + tag_name
params.__setitem__("q", query_string)
query_string = params.get("q", "")
query_string = (query_string + " #" + tag_name).strip()
params.setlist("q", [query_string])
# Remove details ID and page number
params.pop("details", None)
params.pop("page", None)
return params.urlencode()
@ -62,6 +63,10 @@ def remove_tag_from_query(context, tag_name: str):
query_string = " ".join(query_parts)
params.__setitem__("q", query_string)
# Remove details ID and page number
params.pop("details", None)
params.pop("page", None)
return params.urlencode()

View file

@ -2,6 +2,7 @@ import random
import logging
from datetime import datetime
from typing import List
from unittest import TestCase
from bs4 import BeautifulSoup
from django.contrib.auth.models import User
@ -220,6 +221,75 @@ class HtmlTestMixin:
return BeautifulSoup(html, features="html.parser")
class BookmarkListTestMixin(TestCase, HtmlTestMixin):
def assertVisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
bookmark_list = soup.select_one(
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
)
self.assertIsNotNone(bookmark_list)
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
self.assertEqual(len(bookmark_items), len(bookmarks))
for bookmark in bookmarks:
bookmark_item = bookmark_list.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNotNone(bookmark_item)
def assertInvisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
for bookmark in bookmarks:
bookmark_item = soup.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNone(bookmark_item)
class TagCloudTestMixin(TestCase, HtmlTestMixin):
def assertVisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_cloud = soup.select_one("div.tag-cloud")
self.assertIsNotNone(tag_cloud)
tag_items = tag_cloud.select("a[data-is-tag-item]")
self.assertEqual(len(tag_items), len(tags))
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertTrue(tag.name in tag_item_names)
def assertInvisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_items = soup.select("a[data-is-tag-item]")
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertFalse(tag.name in tag_item_names)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select_one("p.selected-tags")
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select("a")
self.assertEqual(len(tag_list), len(tags))
for tag in tags:
self.assertTrue(
tag.name in selected_tags.text,
msg=f"Selected tags do not contain: {tag.name}",
)
class LinkdingApiTestCase(APITestCase):
def get(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.get(url)

View file

@ -1,13 +1,24 @@
from unittest.mock import patch
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.forms import model_to_dict
from django.test import TestCase
from django.http import HttpResponse
from django.test import TestCase, override_settings
from django.urls import reverse
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import Bookmark, BookmarkAsset
from bookmarks.services import tasks, bookmarks
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
TagCloudTestMixin,
)
class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
class BookmarkActionViewTestCase(
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
):
def setUp(self) -> None:
user = self.get_or_create_test_user()
@ -156,6 +167,129 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 404)
self.assertTrue(bookmark.shared)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot(self):
bookmark = self.setup_bookmark()
with patch.object(tasks, "_create_html_snapshot_task"):
self.client.post(
reverse("bookmarks:index.action"),
{
"create_html_snapshot": [bookmark.id],
},
)
self.assertEqual(bookmark.bookmarkasset_set.count(), 1)
asset = bookmark.bookmarkasset_set.first()
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_can_only_create_html_snapshot_for_own_bookmarks(self):
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
with patch.object(tasks, "_create_html_snapshot_task"):
response = self.client.post(
reverse("bookmarks:index.action"),
{
"create_html_snapshot": [bookmark.id],
},
)
self.assertEqual(response.status_code, 404)
self.assertEqual(bookmark.bookmarkasset_set.count(), 0)
def test_upload_asset(self):
bookmark = self.setup_bookmark()
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
)
self.assertEqual(response.status_code, 302)
mock_upload_asset.assert_called_once()
args, _ = mock_upload_asset.call_args
self.assertEqual(args[0], bookmark)
upload_file = args[1]
self.assertEqual(upload_file.name, "test.txt")
def test_can_only_upload_asset_for_own_bookmarks(self):
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
)
self.assertEqual(response.status_code, 404)
mock_upload_asset.assert_not_called()
def test_remove_asset(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)
response = self.client.post(
reverse("bookmarks:index.action"), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 302)
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
def test_can_only_remove_own_asset(self):
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset(bookmark)
response = self.client.post(
reverse("bookmarks:index.action"), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
def test_update_state(self):
bookmark = self.setup_bookmark()
response = self.client.post(
reverse("bookmarks:index.action"),
{
"update_state": bookmark.id,
"is_archived": "on",
"unread": "on",
"shared": "on",
},
)
self.assertEqual(response.status_code, 302)
bookmark.refresh_from_db()
self.assertTrue(bookmark.unread)
self.assertTrue(bookmark.is_archived)
self.assertTrue(bookmark.shared)
def test_can_only_update_own_bookmark_state(self):
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(
reverse("bookmarks:index.action"),
{
"update_state": bookmark.id,
"is_archived": "on",
"unread": "on",
"shared": "on",
},
)
self.assertEqual(response.status_code, 404)
bookmark.refresh_from_db()
self.assertFalse(bookmark.unread)
self.assertFalse(bookmark.is_archived)
self.assertFalse(bookmark.shared)
def test_bulk_archive(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
@ -791,58 +925,119 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
def test_should_redirect_to_return_url(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
def test_index_action_redirects_to_index_with_query_params(self):
url = reverse("bookmarks:index.action") + "?q=foo&page=2"
redirect_url = reverse("bookmarks:index") + "?q=foo&page=2"
response = self.client.post(url)
url = (
reverse("bookmarks:index.action")
+ "?return_url="
+ reverse("bookmarks:settings.index")
self.assertRedirects(response, redirect_url)
def test_archived_action_redirects_to_archived_with_query_params(self):
url = reverse("bookmarks:archived.action") + "?q=foo&page=2"
redirect_url = reverse("bookmarks:archived") + "?q=foo&page=2"
response = self.client.post(url)
self.assertRedirects(response, redirect_url)
def test_shared_action_redirects_to_shared_with_query_params(self):
url = reverse("bookmarks:shared.action") + "?q=foo&page=2"
redirect_url = reverse("bookmarks:shared") + "?q=foo&page=2"
response = self.client.post(url)
self.assertRedirects(response, redirect_url)
def bookmark_update_fixture(self):
user = self.get_or_create_test_user()
profile = user.profile
profile.enable_sharing = True
profile.save()
return {
"active": self.setup_numbered_bookmarks(3),
"archived": self.setup_numbered_bookmarks(3, archived=True),
"shared": self.setup_numbered_bookmarks(3, shared=True),
}
def assertBookmarkUpdateResponse(self, response: HttpResponse):
self.assertEqual(response.status_code, 200)
html = response.content.decode("utf-8")
soup = self.make_soup(html)
# bookmark list update
self.assertIsNotNone(
soup.select_one(
"turbo-stream[action='update'][target='bookmark-list-container']"
)
)
# tag cloud update
self.assertIsNotNone(
soup.select_one(
"turbo-stream[action='update'][target='tag-cloud-container']"
)
)
# update event
self.assertInHTML(
"""
<script>
document.dispatchEvent(new CustomEvent('bookmark-list-updated'));
</script>
""",
html,
)
def test_index_action_with_turbo_returns_bookmark_update(self):
fixture = self.bookmark_update_fixture()
response = self.client.post(
url,
{
"bulk_action": ["bulk_archive"],
"bulk_execute": [""],
"bookmark_id": [
str(bookmark1.id),
str(bookmark2.id),
str(bookmark3.id),
],
},
reverse("bookmarks:index.action"),
HTTP_ACCEPT="text/vnd.turbo-stream.html",
)
self.assertRedirects(response, reverse("bookmarks:settings.index"))
visible_tags = self.get_tags_from_bookmarks(
fixture["active"] + fixture["shared"]
)
invisible_tags = self.get_tags_from_bookmarks(fixture["archived"])
def test_should_not_redirect_to_external_url(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.assertBookmarkUpdateResponse(response)
self.assertVisibleBookmarks(response, fixture["active"] + fixture["shared"])
self.assertInvisibleBookmarks(response, fixture["archived"])
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def post_with(return_url, follow=None):
url = reverse("bookmarks:index.action") + f"?return_url={return_url}"
return self.client.post(
url,
{
"bulk_action": ["bulk_archive"],
"bulk_execute": [""],
"bookmark_id": [
str(bookmark1.id),
str(bookmark2.id),
str(bookmark3.id),
],
},
follow=follow,
def test_archived_action_with_turbo_returns_bookmark_update(self):
fixture = self.bookmark_update_fixture()
response = self.client.post(
reverse("bookmarks:archived.action"),
HTTP_ACCEPT="text/vnd.turbo-stream.html",
)
response = post_with("https://example.com")
self.assertRedirects(response, reverse("bookmarks:index"))
response = post_with("//example.com")
self.assertRedirects(response, reverse("bookmarks:index"))
response = post_with("://example.com")
self.assertRedirects(response, reverse("bookmarks:index"))
visible_tags = self.get_tags_from_bookmarks(fixture["archived"])
invisible_tags = self.get_tags_from_bookmarks(
fixture["active"] + fixture["shared"]
)
response = post_with("/foo//example.com", follow=True)
self.assertEqual(response.status_code, 404)
self.assertBookmarkUpdateResponse(response)
self.assertVisibleBookmarks(response, fixture["archived"])
self.assertInvisibleBookmarks(response, fixture["active"] + fixture["shared"])
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_shared_action_with_turbo_returns_bookmark_update(self):
fixture = self.bookmark_update_fixture()
response = self.client.post(
reverse("bookmarks:shared.action"),
HTTP_ACCEPT="text/vnd.turbo-stream.html",
)
visible_tags = self.get_tags_from_bookmarks(fixture["shared"])
invisible_tags = self.get_tags_from_bookmarks(
fixture["active"] + fixture["archived"]
)
self.assertBookmarkUpdateResponse(response)
self.assertVisibleBookmarks(response, fixture["shared"])
self.assertInvisibleBookmarks(response, fixture["active"] + fixture["archived"])
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)

View file

@ -1,89 +1,26 @@
import urllib.parse
from typing import List
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
from bookmarks.models import BookmarkSearch, UserProfile
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
HtmlTestMixin,
BookmarkListTestMixin,
TagCloudTestMixin,
collapse_whitespace,
)
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
class BookmarkArchivedViewTestCase(
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertVisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
bookmark_list = soup.select_one(
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
)
self.assertIsNotNone(bookmark_list)
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
self.assertEqual(len(bookmark_items), len(bookmarks))
for bookmark in bookmarks:
bookmark_item = bookmark_list.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNotNone(bookmark_item)
def assertInvisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
for bookmark in bookmarks:
bookmark_item = soup.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNone(bookmark_item)
def assertVisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_cloud = soup.select_one("div.tag-cloud")
self.assertIsNotNone(tag_cloud)
tag_items = tag_cloud.select("a[data-is-tag-item]")
self.assertEqual(len(tag_items), len(tags))
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertTrue(tag.name in tag_item_names)
def assertInvisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_items = soup.select("a[data-is-tag-item]")
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertFalse(tag.name in tag_item_names)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select_one("p.selected-tags")
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select("a")
self.assertEqual(len(tag_list), len(tags))
for tag in tags:
self.assertTrue(
tag.name in selected_tags.text,
msg=f"Selected tags do not contain: {tag.name}",
)
def assertEditLink(self, response, url):
html = response.content.decode()
self.assertInHTML(
@ -307,24 +244,21 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
base_url = reverse("bookmarks:archived")
# without params
return_url = urllib.parse.quote_plus(base_url)
url = f"{action_url}?return_url={return_url}"
url = f"{action_url}"
response = self.client.get(base_url)
self.assertBulkActionForm(response, url)
# with query
url_params = "?q=foo"
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f"{action_url}?q=foo&return_url={return_url}"
url = f"{action_url}?q=foo"
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
# with query and sort
url_params = "?q=foo&sort=title_asc"
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f"{action_url}?q=foo&sort=title_asc&return_url={return_url}"
url = f"{action_url}?q=foo&sort=title_asc"
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
@ -527,7 +461,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertEqual(
actions_form.attrs["action"],
"/bookmarks/archived/action?q=%23foo&return_url=%2Fbookmarks%2Farchived%3Fq%3D%2523foo",
"/bookmarks/archived/action?q=%23foo",
)
def test_encode_search_params(self):
@ -557,3 +491,15 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
url = reverse("bookmarks:archived") + "?page=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
def test_turbo_frame_details_modal_renders_details_modal_update(self):
bookmark = self.setup_bookmark()
url = reverse("bookmarks:archived") + f"?bookmark_id={bookmark.id}"
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
self.assertEqual(200, response.status_code)
soup = self.make_soup(response.content.decode())
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))

View file

@ -1,14 +1,11 @@
import datetime
import re
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import formats, timezone
from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import bookmarks, tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
@ -17,23 +14,23 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
user = self.get_or_create_test_user()
self.client.force_login(user)
def get_view_name(self):
return "bookmarks:details_modal"
def get_base_url(self, bookmark):
return reverse(self.get_view_name(), args=[bookmark.id])
def get_details_form(self, soup, bookmark):
expected_url = reverse("bookmarks:details", args=[bookmark.id])
return soup.find("form", {"action": expected_url})
form_url = reverse("bookmarks:index.action") + f"?details={bookmark.id}"
return soup.find("form", {"action": form_url, "enctype": "multipart/form-data"})
def get_details(self, bookmark, return_url=""):
url = self.get_base_url(bookmark)
if return_url:
url += f"?return_url={return_url}"
def get_index_details_modal(self, bookmark):
url = reverse("bookmarks:index") + f"?details={bookmark.id}"
response = self.client.get(url)
soup = self.make_soup(response.content)
return soup
modal = soup.find("turbo-frame", {"id": "details-modal"})
return modal
def get_shared_details_modal(self, bookmark):
url = reverse("bookmarks:shared") + f"?details={bookmark.id}"
response = self.client.get(url)
soup = self.make_soup(response.content)
modal = soup.find("turbo-frame", {"id": "details-modal"})
return modal
def find_section(self, soup, section_name):
dt = soup.find("dt", string=section_name)
@ -54,35 +51,68 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def find_asset(self, soup, asset):
return soup.find("div", {"data-asset-id": asset.id})
def details_route_access_test(self, view_name: str, shareable: bool):
def details_route_access_test(self):
# own bookmark
bookmark = self.setup_bookmark()
response = self.client.get(reverse(view_name, args=[bookmark.id]))
response = self.client.get(
reverse("bookmarks:index") + f"?details={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(view_name, args=[bookmark.id]))
response = self.client.get(
reverse("bookmarks:index") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404)
# non-existent bookmark
response = self.client.get(reverse(view_name, args=[9999]))
self.assertEqual(response.status_code, 404)
# non-existent bookmark - just returns without modal in response
response = self.client.get(reverse("bookmarks:index") + "?details=9999")
self.assertEqual(response.status_code, 200)
# guest user
self.client.logout()
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 404 if shareable else 302)
response = self.client.get(
reverse("bookmarks:shared") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404)
def details_route_sharing_access_test(self, view_name: str, shareable: bool):
def test_access(self):
# own bookmark
bookmark = self.setup_bookmark()
response = self.client.get(
reverse("bookmarks:index") + f"?details={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:index") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404)
# non-existent bookmark - just returns without modal in response
response = self.client.get(reverse("bookmarks:index") + "?details=9999")
self.assertEqual(response.status_code, 200)
# guest user
self.client.logout()
response = self.client.get(
reverse("bookmarks:shared") + f"?details={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(view_name, args=[bookmark.id]))
response = self.client.get(
reverse("bookmarks:shared") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404)
# shared bookmark, sharing enabled
@ -90,37 +120,31 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.enable_sharing = True
profile.save()
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 200 if shareable else 404)
response = self.client.get(
reverse("bookmarks:shared") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 200)
# shared bookmark, guest user, no public sharing
self.client.logout()
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 404 if shareable else 302)
response = self.client.get(
reverse("bookmarks:shared") + f"?details={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(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 200 if shareable else 302)
def test_access(self):
self.details_route_access_test(self.get_view_name(), True)
def test_access_with_sharing(self):
self.details_route_sharing_access_test(self.get_view_name(), True)
def test_assets_access(self):
self.details_route_access_test("bookmarks:details_assets", True)
def test_assets_access_with_sharing(self):
self.details_route_sharing_access_test("bookmarks:details_assets", True)
response = self.client.get(
reverse("bookmarks:shared") + f"?details={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)
soup = self.get_index_details_modal(bookmark)
title = soup.find("h2")
self.assertIsNotNone(title)
@ -128,7 +152,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# with website title
bookmark = self.setup_bookmark(title="", website_title="Website title")
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
title = soup.find("h2")
self.assertIsNotNone(title)
@ -136,7 +160,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# with URL only
bookmark = self.setup_bookmark(title="", website_title="")
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
title = soup.find("h2")
self.assertIsNotNone(title)
@ -145,7 +169,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_website_link(self):
# basics
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.url)
self.assertIsNotNone(link)
self.assertEqual(link["href"], bookmark.url)
@ -153,7 +177,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# favicons disabled
bookmark = self.setup_bookmark(favicon_file="example.png")
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.url)
image = link.select_one("img")
self.assertIsNone(image)
@ -164,14 +188,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.save()
bookmark = self.setup_bookmark(favicon_file="")
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(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)
soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.url)
image = link.select_one("img")
self.assertIsNotNone(image)
@ -180,7 +204,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_reader_mode_link(self):
# no latest snapshot
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
self.assertEqual(self.count_weblinks(soup), 2)
# snapshot is not complete
@ -194,7 +218,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_FAILURE,
)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
self.assertEqual(self.count_weblinks(soup), 2)
# not a snapshot
@ -203,7 +227,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_type="upload",
status=BookmarkAsset.STATUS_COMPLETE,
)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
self.assertEqual(self.count_weblinks(soup), 2)
# snapshot is complete
@ -212,7 +236,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
self.assertEqual(self.count_weblinks(soup), 3)
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
@ -221,7 +245,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_internet_archive_link_with_snapshot_url(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
self.assertIsNotNone(link)
self.assertEqual(link["href"], bookmark.web_archive_snapshot_url)
@ -231,7 +255,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark(
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
image = link.select_one("svg")
self.assertIsNone(image)
@ -244,7 +268,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark(
web_archive_snapshot_url="https://example.com/", favicon_file=""
)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
image = link.select_one("svg")
self.assertIsNone(image)
@ -253,7 +277,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark(
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
image = link.select_one("svg")
self.assertIsNotNone(image)
@ -267,7 +291,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
"https://web.archive.org/web/20230811214511/https://example.com/"
)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, fallback_web_archive_url)
self.assertIsNotNone(link)
self.assertEqual(link["href"], fallback_web_archive_url)
@ -281,7 +305,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_BLANK
profile.save()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
website_link = self.find_weblink(soup, bookmark.url)
self.assertIsNotNone(website_link)
@ -297,7 +321,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
profile.save()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
website_link = self.find_weblink(soup, bookmark.url)
self.assertIsNotNone(website_link)
@ -312,13 +336,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_preview_image(self):
# without image
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
image = soup.select_one("div.preview-image img")
self.assertIsNone(image)
# with image
bookmark = self.setup_bookmark(preview_image_file="example.png")
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
image = soup.select_one("div.preview-image img")
self.assertIsNone(image)
@ -328,13 +352,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.save()
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
image = soup.select_one("div.preview-image img")
self.assertIsNone(image)
# preview images enabled, image present
bookmark = self.setup_bookmark(preview_image_file="example.png")
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
image = soup.select_one("div.preview-image img")
self.assertIsNotNone(image)
self.assertEqual(image["src"], "/static/example.png")
@ -342,18 +366,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_status(self):
# renders form
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
form = self.get_details_form(soup, bookmark)
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)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
@ -369,7 +390,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.save()
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
@ -381,7 +402,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# unchecked
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
@ -393,7 +414,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# checked
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
@ -406,106 +427,29 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_status_visibility(self):
# own bookmark
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Status")
self.assertIsNotNone(section)
# 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)
soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status")
self.assertIsNone(section)
# guest user
self.client.logout()
other_user.profile.enable_public_sharing = True
other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status")
self.assertIsNone(section)
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)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Date added")
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
@ -515,7 +459,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_tags(self):
# without tags
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Tags")
self.assertIsNone(section)
@ -523,7 +467,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# with tags
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Tags")
for tag in bookmark.tags.all():
@ -535,14 +479,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_description(self):
# without description
bookmark = self.setup_bookmark(description="", website_description="")
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Description")
self.assertIsNone(section)
# with description
bookmark = self.setup_bookmark(description="Test description")
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.description)
@ -551,7 +495,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark(
description="", website_description="Website description"
)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.website_description)
@ -559,14 +503,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_notes(self):
# without notes
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Notes")
self.assertIsNone(section)
# with notes
bookmark = self.setup_bookmark(notes="Test notes")
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Notes")
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
@ -575,52 +519,42 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
# with default return URL
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(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)
details_url = reverse("bookmarks:index") + f"?details={bookmark.id}"
expected_url = "/bookmarks/1/edit?return_url=/bookmarks%3Fdetails%3D1"
self.assertEqual(expected_url, edit_link["href"])
def test_delete_button(self):
bookmark = self.setup_bookmark()
# basics
soup = self.get_details(bookmark)
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
modal = self.get_index_details_modal(bookmark)
delete_button = modal.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))
self.assertEqual("Delete...", delete_button.text.strip())
self.assertEqual(str(bookmark.id), delete_button["value"])
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)
expected_url = reverse("bookmarks:index.action")
self.assertEqual(expected_url, form["action"])
def test_actions_visibility(self):
# own bookmark
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNotNone(edit_link)
self.assertIsNotNone(delete_button)
# with sharing
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
soup = self.get_shared_details_modal(bookmark)
edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link)
@ -632,7 +566,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
soup = self.get_shared_details_modal(bookmark)
edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link)
@ -642,7 +576,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.client.logout()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
soup = self.get_shared_details_modal(bookmark)
edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link)
@ -651,7 +585,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_assets_visibility_no_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNone(section)
@ -659,7 +593,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_assets_visibility_with_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNotNone(section)
@ -668,7 +602,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# no assets
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNone(asset_list)
@ -677,7 +611,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
self.setup_asset(bookmark)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNotNone(asset_list)
@ -691,7 +625,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.setup_asset(bookmark),
]
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
@ -717,7 +651,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset.file = ""
asset.save()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset)
view_url = reverse("bookmarks:assets.view", args=[asset.id])
view_link = asset_item.find("a", {"href": view_url})
@ -729,7 +663,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, pending_asset)
asset_text = asset_item.select_one(".asset-text span")
@ -746,7 +680,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset2 = self.setup_asset(bookmark, file_size=54639)
asset3 = self.setup_asset(bookmark, file_size=11492020)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset1)
asset_text = asset_item.select_one(".asset-text")
@ -766,7 +700,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# with file
asset = self.setup_asset(bookmark)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
@ -779,7 +713,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# without file
asset.file = ""
asset.save()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
@ -793,7 +727,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
other_user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
bookmark = self.setup_bookmark(shared=True, user=other_user)
asset = self.setup_asset(bookmark)
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
@ -805,7 +739,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# shared bookmark, guest user
self.client.logout()
soup = self.get_details(bookmark)
soup = self.get_shared_details_modal(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
@ -815,77 +749,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNotNone(view_link)
self.assertIsNone(delete_button)
def test_remove_asset(self):
# remove asset
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)
response = self.client.post(
self.get_base_url(bookmark), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 302)
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
# non-existent asset
response = self.client.post(self.get_base_url(bookmark), {"remove_asset": 9999})
self.assertEqual(response.status_code, 404)
# post without asset ID does not remove
asset = self.setup_asset(bookmark)
response = self.client.post(self.get_base_url(bookmark))
self.assertEqual(response.status_code, 302)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
# guest user
asset = self.setup_asset(bookmark)
self.client.logout()
response = self.client.post(
self.get_base_url(bookmark), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_assets_refresh_when_having_pending_asset(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
fetch_url = reverse("bookmarks:details_assets", args=[bookmark.id])
# no pending asset
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url})
self.assertIsNone(assets_wrapper)
# with pending asset
asset.status = BookmarkAsset.STATUS_PENDING
asset.save()
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url})
self.assertIsNotNone(assets_wrapper)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot(self):
with patch.object(
tasks, "_create_html_snapshot_task"
) as mock_create_html_snapshot_task:
bookmark = self.setup_bookmark()
response = self.client.post(
self.get_base_url(bookmark), {"create_snapshot": ""}
)
self.assertEqual(response.status_code, 302)
self.assertEqual(bookmark.bookmarkasset_set.count(), 1)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot_is_disabled_when_having_pending_asset(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
# no pending asset
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
@ -896,40 +766,9 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset.status = BookmarkAsset.STATUS_PENDING
asset.save()
soup = self.get_details(bookmark)
soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)
self.assertTrue(create_button.has_attr("disabled"))
def test_upload_file(self):
bookmark = self.setup_bookmark()
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
self.get_base_url(bookmark),
{"upload_asset": "", "upload_asset_file": upload_file},
)
self.assertEqual(response.status_code, 302)
mock_upload_asset.assert_called_once()
args, kwargs = mock_upload_asset.call_args
self.assertEqual(args[0], bookmark)
upload_file = args[1]
self.assertEqual(upload_file.name, "test.txt")
def test_upload_file_without_file(self):
bookmark = self.setup_bookmark()
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
self.get_base_url(bookmark),
{"upload_asset": ""},
)
self.assertEqual(response.status_code, 400)
mock_upload_asset.assert_not_called()

View file

@ -1,6 +0,0 @@
from bookmarks.tests.test_bookmark_details_modal import BookmarkDetailsModalTestCase
class BookmarkDetailsViewTestCase(BookmarkDetailsModalTestCase):
def get_view_name(self):
return "bookmarks:details"

View file

@ -1,85 +1,24 @@
import urllib.parse
from typing import List
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.models import BookmarkSearch, UserProfile
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
TagCloudTestMixin,
)
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
class BookmarkIndexViewTestCase(
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertVisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
bookmark_list = soup.select_one(
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
)
self.assertIsNotNone(bookmark_list)
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
self.assertEqual(len(bookmark_items), len(bookmarks))
for bookmark in bookmarks:
bookmark_item = bookmark_list.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNotNone(bookmark_item)
def assertInvisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
for bookmark in bookmarks:
bookmark_item = soup.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNone(bookmark_item)
def assertVisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_cloud = soup.select_one("div.tag-cloud")
self.assertIsNotNone(tag_cloud)
tag_items = tag_cloud.select("a[data-is-tag-item]")
self.assertEqual(len(tag_items), len(tags))
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertTrue(tag.name in tag_item_names)
def assertInvisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_items = soup.select("a[data-is-tag-item]")
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertFalse(tag.name in tag_item_names)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select_one("p.selected-tags")
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select("a")
self.assertEqual(len(tag_list), len(tags))
for tag in tags:
self.assertTrue(
tag.name in selected_tags.text,
msg=f"Selected tags do not contain: {tag.name}",
)
def assertEditLink(self, response, url):
html = response.content.decode()
self.assertInHTML(
@ -285,24 +224,21 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
base_url = reverse("bookmarks:index")
# without params
return_url = urllib.parse.quote_plus(base_url)
url = f"{action_url}?return_url={return_url}"
url = f"{action_url}"
response = self.client.get(base_url)
self.assertBulkActionForm(response, url)
# with query
url_params = "?q=foo"
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f"{action_url}?q=foo&return_url={return_url}"
url = f"{action_url}?q=foo"
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
# with query and sort
url_params = "?q=foo&sort=title_asc"
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f"{action_url}?q=foo&sort=title_asc&return_url={return_url}"
url = f"{action_url}?q=foo&sort=title_asc"
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
@ -503,7 +439,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(
actions_form.attrs["action"],
"/bookmarks/action?q=%23foo&return_url=%2Fbookmarks%3Fq%3D%2523foo",
"/bookmarks/action?q=%23foo",
)
def test_encode_search_params(self):
@ -533,3 +469,15 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
url = reverse("bookmarks:index") + "?page=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
def test_turbo_frame_details_modal_renders_details_modal_update(self):
bookmark = self.setup_bookmark()
url = reverse("bookmarks:index") + f"?bookmark_id={bookmark.id}"
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
self.assertEqual(200, response.status_code)
soup = self.make_soup(response.content.decode())
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))

View file

@ -1,16 +1,13 @@
from bs4 import BeautifulSoup
from django.db.models import QuerySet
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from bookmarks.models import BookmarkSearch, Tag
from bookmarks.models import BookmarkSearch
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def render_template(
self, url: str, tags: QuerySet[Tag] = Tag.objects.all(), mode: str = ""
):
def render_template(self, url: str, mode: str = ""):
rf = RequestFactory()
request = rf.get(url)
request.user = self.get_or_create_test_user()
@ -21,32 +18,31 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
{
"request": request,
"search": search,
"tags": tags,
"mode": mode,
},
)
template_to_render = Template(
"{% load bookmarks %}" "{% bookmark_search search tags mode %}"
"{% load bookmarks %} {% bookmark_search search mode %}"
)
return template_to_render.render(context)
def assertHiddenInput(self, form: BeautifulSoup, name: str, value: str = None):
input = form.select_one(f'input[name="{name}"][type="hidden"]')
self.assertIsNotNone(input)
element = form.select_one(f'input[name="{name}"][type="hidden"]')
self.assertIsNotNone(element)
if value is not None:
self.assertEqual(input["value"], value)
self.assertEqual(element["value"], value)
def assertNoHiddenInput(self, form: BeautifulSoup, name: str):
input = form.select_one(f'input[name="{name}"][type="hidden"]')
self.assertIsNone(input)
element = form.select_one(f'input[name="{name}"][type="hidden"]')
self.assertIsNone(element)
def assertSearchInput(self, form: BeautifulSoup, name: str, value: str = None):
input = form.select_one(f'input[name="{name}"][type="search"]')
self.assertIsNotNone(input)
element = form.select_one(f'input[name="{name}"][type="search"]')
self.assertIsNotNone(element)
if value is not None:
self.assertEqual(input["value"], value)
self.assertEqual(element["value"], value)
def assertSelect(self, form: BeautifulSoup, name: str, value: str = None):
select = form.select_one(f'select[name="{name}"]')

View file

@ -6,11 +6,16 @@ from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
TagCloudTestMixin,
)
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
class BookmarkSharedViewTestCase(
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
):
def authenticate(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
@ -24,57 +29,6 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
count=count,
)
def assertVisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
bookmark_list = soup.select_one(
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
)
self.assertIsNotNone(bookmark_list)
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
self.assertEqual(len(bookmark_items), len(bookmarks))
for bookmark in bookmarks:
bookmark_item = bookmark_list.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNotNone(bookmark_item)
def assertInvisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
for bookmark in bookmarks:
bookmark_item = soup.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNone(bookmark_item)
def assertVisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_cloud = soup.select_one("div.tag-cloud")
self.assertIsNotNone(tag_cloud)
tag_items = tag_cloud.select("a[data-is-tag-item]")
self.assertEqual(len(tag_items), len(tags))
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertTrue(tag.name in tag_item_names)
def assertInvisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_items = soup.select("a[data-is-tag-item]")
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertFalse(tag.name in tag_item_names)
def assertVisibleUserOptions(self, response, users: List[User]):
html = response.content.decode()
@ -84,7 +38,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f'<option value="{user.username}">{user.username}</option>'
)
user_select_html = f"""
<select name="user" class="form-select" required="" id="id_user">
<select name="user" class="form-select" id="id_user" ld-auto-submit>
{''.join(user_options)}
</select>
"""
@ -593,7 +547,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(
actions_form.attrs["action"],
"/bookmarks/shared/action?q=%23foo&return_url=%2Fbookmarks%2Fshared%3Fq%3D%2523foo",
"/bookmarks/shared/action?q=%23foo",
)
def test_encode_search_params(self):
@ -627,3 +581,15 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
url = reverse("bookmarks:shared") + "?page=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
def test_turbo_frame_details_modal_renders_details_modal_update(self):
bookmark = self.setup_bookmark()
url = reverse("bookmarks:shared") + f"?bookmark_id={bookmark.id}"
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
self.assertEqual(200, response.status_code)
soup = self.make_soup(response.content.decode())
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))

View file

@ -12,7 +12,7 @@ from django.utils import timezone, formats
from bookmarks.middlewares import LinkdingMiddleware
from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views.partials import contexts
from bookmarks.views import contexts
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
@ -51,31 +51,25 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
html,
)
def assertViewLink(
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
):
self.assertViewLinkCount(html, bookmark, return_url=return_url)
def assertViewLink(self, html: str, bookmark: Bookmark, base_url=None):
self.assertViewLinkCount(html, bookmark, base_url)
def assertNoViewLink(
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
):
self.assertViewLinkCount(html, bookmark, count=0, return_url=return_url)
def assertNoViewLink(self, html: str, bookmark: Bookmark, base_url=None):
self.assertViewLinkCount(html, bookmark, base_url, count=0)
def assertViewLinkCount(
self,
html: str,
bookmark: Bookmark,
base_url: str = None,
count=1,
return_url=reverse("bookmarks:index"),
):
details_url = reverse("bookmarks:details", args=[bookmark.id])
details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id])
if base_url is None:
base_url = reverse("bookmarks:index")
details_url = base_url + f"?details={bookmark.id}"
self.assertInHTML(
f"""
<a ld-fetch="{details_modal_url}?return_url={return_url}"
ld-on="click" ld-target="body|append"
data-turbo-prefetch="false"
href="{details_url}">View</a>
<a href="{details_url}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
""",
html,
count=count,
@ -652,7 +646,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
bookmark = self.setup_bookmark(user=other_user, shared=True)
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared"))
self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark)
@ -944,7 +938,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertWebArchiveLink(
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
)
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared"))
self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark)
self.assertMarkAsReadButton(html, bookmark, count=0)

View file

@ -172,3 +172,12 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2"
)
self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3")
def test_removes_details_parameter(self):
rendered_template = self.render_template(
100, 10, 2, url="/test?details=1&page=2"
)
self.assertPrevLink(rendered_template, 1, href="?page=1")
self.assertPageLink(rendered_template, 1, False, href="?page=1")
self.assertPageLink(rendered_template, 2, True, href="?page=2")
self.assertNextLink(rendered_template, 3, href="?page=3")

View file

@ -8,7 +8,7 @@ from django.test import TestCase, RequestFactory
from bookmarks.middlewares import LinkdingMiddleware
from bookmarks.models import UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views.partials import contexts
from bookmarks.views import contexts
class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
@ -203,13 +203,28 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
tag = self.setup_tag(name="tag1")
self.setup_bookmark(tags=[tag], title="term1")
rendered_template = self.render_template(url="/test?q=term1&sort=title_asc")
self.assertInHTML(
"""
<a href="?q=term1+%23tag1&sort=title_asc" class="mr-2" data-is-tag-item>
<span class="highlight-char">t</span><span>ag1</span>
</a>
""",
rendered_template,
)
def test_tag_url_removes_page_number_and_details_id(self):
tag = self.setup_tag(name="tag1")
self.setup_bookmark(tags=[tag], title="term1")
rendered_template = self.render_template(
url="/test?q=term1&sort=title_asc&page=2"
url="/test?q=term1&sort=title_asc&page=2&details=5"
)
self.assertInHTML(
"""
<a href="?q=term1+%23tag1&sort=title_asc&page=2" class="mr-2" data-is-tag-item>
<a href="?q=term1+%23tag1&sort=title_asc" class="mr-2" data-is-tag-item>
<span class="highlight-char">t</span><span>ag1</span>
</a>
""",
@ -347,12 +362,30 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(tags=[tag], title="term1", description="term2")
rendered_template = self.render_template(
url="/test?q=term1 %23tag1 term2&sort=title_asc&page=2"
url="/test?q=term1 %23tag1 term2&sort=title_asc"
)
self.assertInHTML(
"""
<a href="?q=term1+term2&sort=title_asc&page=2"
<a href="?q=term1+term2&sort=title_asc"
class="text-bold mr-2">
<span>-tag1</span>
</a>
""",
rendered_template,
)
def test_selected_tag_url_removes_page_number_and_details_id(self):
tag = self.setup_tag(name="tag1")
self.setup_bookmark(tags=[tag], title="term1", description="term2")
rendered_template = self.render_template(
url="/test?q=term1 %23tag1 term2&sort=title_asc&page=2&details=5"
)
self.assertInHTML(
"""
<a href="?q=term1+term2&sort=title_asc"
class="text-bold mr-2">
<span>-tag1</span>
</a>

View file

@ -9,7 +9,6 @@ from bookmarks.feeds import (
SharedBookmarksFeed,
PublicSharedBookmarksFeed,
)
from bookmarks.views import partials
app_name = "bookmarks"
urlpatterns = [
@ -31,21 +30,6 @@ urlpatterns = [
path("bookmarks/new", views.bookmarks.new, name="new"),
path("bookmarks/close", views.bookmarks.close, name="close"),
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",
),
path(
"bookmarks/<int:bookmark_id>/details_assets",
views.bookmarks.details_assets,
name="details_assets",
),
# Assets
path(
"assets/<int:asset_id>",
@ -57,52 +41,6 @@ urlpatterns = [
views.assets.read,
name="assets.read",
),
# Partials
path(
"bookmarks/partials/bookmark-list/active",
partials.active_bookmark_list,
name="partials.bookmark_list.active",
),
path(
"bookmarks/partials/tag-cloud/active",
partials.active_tag_cloud,
name="partials.tag_cloud.active",
),
path(
"bookmarks/partials/tag-modal/active",
partials.active_tag_modal,
name="partials.tag_modal.active",
),
path(
"bookmarks/partials/bookmark-list/archived",
partials.archived_bookmark_list,
name="partials.bookmark_list.archived",
),
path(
"bookmarks/partials/tag-cloud/archived",
partials.archived_tag_cloud,
name="partials.tag_cloud.archived",
),
path(
"bookmarks/partials/tag-modal/archived",
partials.archived_tag_modal,
name="partials.tag_modal.archived",
),
path(
"bookmarks/partials/bookmark-list/shared",
partials.shared_bookmark_list,
name="partials.bookmark_list.shared",
),
path(
"bookmarks/partials/tag-cloud/shared",
partials.shared_tag_cloud,
name="partials.tag_cloud.shared",
),
path(
"bookmarks/partials/tag-modal/shared",
partials.shared_tag_modal,
name="partials.tag_modal.shared",
),
# Settings
path("settings", views.settings.general, name="settings.index"),
path("settings/general", views.settings.general, name="settings.general"),

View file

@ -1,10 +1,12 @@
import logging
import re
import unicodedata
import urllib.parse
from datetime import datetime
from typing import Optional
from dateutil.relativedelta import relativedelta
from django.http import HttpResponseRedirect
from django.template.defaultfilters import pluralize
from django.utils import timezone, formats
@ -114,6 +116,14 @@ def get_safe_return_url(return_url: str, fallback_url: str):
return return_url
def redirect_with_query(request, redirect_url):
query_string = urllib.parse.urlencode(request.GET)
if query_string:
redirect_url += "?" + query_string
return HttpResponseRedirect(redirect_url)
def generate_username(email):
# taken from mozilla-django-oidc docs :)

View file

@ -11,7 +11,7 @@ from django.http import (
from django.shortcuts import render
from django.urls import reverse
from bookmarks import queries
from bookmarks import queries, utils
from bookmarks.models import (
Bookmark,
BookmarkAsset,
@ -19,6 +19,7 @@ from bookmarks.models import (
BookmarkSearch,
build_tag_string,
)
from bookmarks.services import bookmarks as bookmark_actions, tasks
from bookmarks.services.bookmarks import (
create_bookmark,
update_bookmark,
@ -34,9 +35,8 @@ from bookmarks.services.bookmarks import (
share_bookmarks,
unshare_bookmarks,
)
from bookmarks.services import bookmarks as bookmark_actions, tasks
from bookmarks.utils import get_safe_return_url
from bookmarks.views.partials import contexts
from bookmarks.views import contexts, partials, turbo
_default_page_size = 30
@ -48,12 +48,17 @@ def index(request):
bookmark_list = contexts.ActiveBookmarkListContext(request)
tag_cloud = contexts.ActiveTagCloudContext(request)
return render(
bookmark_details = contexts.get_details_context(
request, contexts.ActiveBookmarkDetailsContext
)
return render_bookmarks_view(
request,
"bookmarks/index.html",
{
"bookmark_list": bookmark_list,
"tag_cloud": tag_cloud,
"details": bookmark_details,
},
)
@ -65,12 +70,17 @@ def archived(request):
bookmark_list = contexts.ArchivedBookmarkListContext(request)
tag_cloud = contexts.ArchivedTagCloudContext(request)
return render(
bookmark_details = contexts.get_details_context(
request, contexts.ArchivedBookmarkDetailsContext
)
return render_bookmarks_view(
request,
"bookmarks/archive.html",
{
"bookmark_list": bookmark_list,
"tag_cloud": tag_cloud,
"details": bookmark_details,
},
)
@ -81,14 +91,37 @@ def shared(request):
bookmark_list = contexts.SharedBookmarkListContext(request)
tag_cloud = contexts.SharedTagCloudContext(request)
bookmark_details = contexts.get_details_context(
request, contexts.SharedBookmarkDetailsContext
)
public_only = not request.user.is_authenticated
users = queries.query_shared_bookmark_users(
request.user_profile, bookmark_list.search, public_only
)
return render(
return render_bookmarks_view(
request,
"bookmarks/shared.html",
{"bookmark_list": bookmark_list, "tag_cloud": tag_cloud, "users": users},
{
"bookmark_list": bookmark_list,
"tag_cloud": tag_cloud,
"details": bookmark_details,
"users": users,
},
)
def render_bookmarks_view(request, template_name, context):
if turbo.is_frame(request, "details-modal"):
return render(
request,
"bookmarks/updates/details-modal-frame.html",
context,
)
return render(
request,
template_name,
context,
)
@ -111,76 +144,6 @@ def search_action(request):
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")
if request.method == "POST":
if not is_owner:
raise Http404("Bookmark does not exist")
return_url = get_safe_return_url(
request.GET.get("return_url"),
reverse("bookmarks:details", args=[bookmark.id]),
)
if "remove_asset" in request.POST:
asset_id = request.POST["remove_asset"]
try:
asset = bookmark.bookmarkasset_set.get(pk=asset_id)
except BookmarkAsset.DoesNotExist:
raise Http404("Asset does not exist")
asset.delete()
if "create_snapshot" in request.POST:
tasks.create_html_snapshot(bookmark)
if "upload_asset" in request.POST:
file = request.FILES.get("upload_asset_file")
if not file:
return HttpResponseBadRequest("No file uploaded")
bookmark_actions.upload_asset(bookmark, file)
else:
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(return_url)
details_context = contexts.BookmarkDetailsContext(request, bookmark)
return render(
request,
template,
{
"details": details_context,
},
)
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 details_assets(request, bookmark_id: int):
return _details(request, bookmark_id, "bookmarks/details/assets.html")
def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings
@ -307,26 +270,87 @@ def mark_as_read(request, bookmark_id: int):
bookmark.save()
def create_html_snapshot(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404("Bookmark does not exist")
tasks.create_html_snapshot(bookmark)
def upload_asset(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404("Bookmark does not exist")
file = request.FILES.get("upload_asset_file")
if not file:
raise ValueError("No file uploaded")
bookmark_actions.upload_asset(bookmark, file)
def remove_asset(request, asset_id: int):
try:
asset = BookmarkAsset.objects.get(pk=asset_id, bookmark__owner=request.user)
except BookmarkAsset.DoesNotExist:
raise Http404("Asset does not exist")
asset.delete()
def update_state(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
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()
@login_required
def index_action(request):
search = BookmarkSearch.from_request(request.GET)
query = queries.query_bookmarks(request.user, request.user_profile, search)
return action(request, query)
handle_action(request, query)
if turbo.accept(request):
return partials.active_bookmark_update(request)
return utils.redirect_with_query(request, reverse("bookmarks:index"))
@login_required
def archived_action(request):
search = BookmarkSearch.from_request(request.GET)
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
return action(request, query)
handle_action(request, query)
if turbo.accept(request):
return partials.archived_bookmark_update(request)
return utils.redirect_with_query(request, reverse("bookmarks:archived"))
@login_required
def shared_action(request):
return action(request)
if "bulk_execute" in request.POST:
return HttpResponseBadRequest("View does not support bulk actions")
handle_action(request)
if turbo.accept(request):
return partials.shared_bookmark_update(request)
return utils.redirect_with_query(request, reverse("bookmarks:shared"))
def action(request, query: QuerySet[Bookmark] = None):
def handle_action(request, query: QuerySet[Bookmark] = None):
# Single bookmark actions
if "archive" in request.POST:
archive(request, request.POST["archive"])
@ -338,11 +362,21 @@ def action(request, query: QuerySet[Bookmark] = None):
mark_as_read(request, request.POST["mark_as_read"])
if "unshare" in request.POST:
unshare(request, request.POST["unshare"])
if "create_html_snapshot" in request.POST:
create_html_snapshot(request, request.POST["create_html_snapshot"])
if "upload_asset" in request.POST:
upload_asset(request, request.POST["upload_asset"])
if "remove_asset" in request.POST:
remove_asset(request, request.POST["remove_asset"])
# State updates
if "update_state" in request.POST:
update_state(request, request.POST["update_state"])
# Bulk actions
if "bulk_execute" in request.POST:
if query is None:
return HttpResponseBadRequest("View does not support bulk actions")
raise ValueError("Query must be provided for bulk actions")
bulk_action = request.POST["bulk_action"]
@ -375,11 +409,6 @@ def action(request, query: QuerySet[Bookmark] = None):
if "bulk_unshare" == bulk_action:
unshare_bookmarks(bookmark_ids, request.user)
return_url = get_safe_return_url(
request.GET.get("return_url"), reverse("bookmarks:index")
)
return HttpResponseRedirect(return_url)
@login_required
def close(request):

View file

@ -6,6 +6,7 @@ from django.conf import settings
from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator
from django.db import models
from django.http import Http404
from django.urls import reverse
from bookmarks import queries
@ -27,17 +28,11 @@ CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
class RequestContext:
index_view = "bookmarks:index"
action_view = "bookmarks:index.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
def __init__(self, request: WSGIRequest):
self.request = request
self.index_url = reverse(self.index_view)
self.action_url = reverse(self.action_view)
self.bookmark_list_partial_url = reverse(self.bookmark_list_partial_view)
self.tag_cloud_partial_url = reverse(self.tag_cloud_partial_view)
self.tag_modal_partial_url = reverse(self.tag_modal_partial_view)
self.query_params = request.GET.copy()
self.query_params.pop("details", None)
@ -51,34 +46,25 @@ class RequestContext:
encoded_params = query_params.urlencode()
return view_url + "?" + encoded_params if encoded_params else view_url
def index(self) -> str:
return self.get_url(self.index_url)
def index(self, add: dict = None, remove: dict = None) -> str:
return self.get_url(self.index_url, add=add, remove=remove)
def action(self, return_url: str) -> str:
return self.get_url(self.action_url, add={"return_url": return_url})
def action(self, add: dict = None, remove: dict = None) -> str:
return self.get_url(self.action_url, add=add, remove=remove)
def bookmark_list_partial(self) -> str:
return self.get_url(self.bookmark_list_partial_url)
def tag_cloud_partial(self) -> str:
return self.get_url(self.tag_cloud_partial_url)
def tag_modal_partial(self) -> str:
return self.get_url(self.tag_modal_partial_url)
def details(self, bookmark_id: int) -> str:
return self.get_url(self.index_url, add={"details": bookmark_id})
def get_bookmark_query_set(self, search: BookmarkSearch):
raise Exception("Must be implemented by subclass")
raise NotImplementedError("Must be implemented by subclass")
def get_tag_query_set(self, search: BookmarkSearch):
raise Exception("Must be implemented by subclass")
raise NotImplementedError("Must be implemented by subclass")
class ActiveBookmarksContext(RequestContext):
index_view = "bookmarks:index"
action_view = "bookmarks:index.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
def get_bookmark_query_set(self, search: BookmarkSearch):
return queries.query_bookmarks(
@ -94,9 +80,6 @@ class ActiveBookmarksContext(RequestContext):
class ArchivedBookmarksContext(RequestContext):
index_view = "bookmarks:archived"
action_view = "bookmarks:archived.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.archived"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.archived"
tag_modal_partial_view = "bookmarks:partials.tag_modal.archived"
def get_bookmark_query_set(self, search: BookmarkSearch):
return queries.query_archived_bookmarks(
@ -112,9 +95,6 @@ class ArchivedBookmarksContext(RequestContext):
class SharedBookmarksContext(RequestContext):
index_view = "bookmarks:shared"
action_view = "bookmarks:shared.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.shared"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.shared"
tag_modal_partial_view = "bookmarks:partials.tag_modal.shared"
def get_bookmark_query_set(self, search: BookmarkSearch):
user = User.objects.filter(username=search.user).first()
@ -132,7 +112,13 @@ class SharedBookmarksContext(RequestContext):
class BookmarkItem:
def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None:
def __init__(
self,
context: RequestContext,
bookmark: Bookmark,
user: User,
profile: UserProfile,
) -> None:
self.bookmark = bookmark
is_editable = bookmark.owner == user
@ -154,6 +140,7 @@ class BookmarkItem:
self.is_archived = bookmark.is_archived
self.unread = bookmark.unread
self.owner = bookmark.owner
self.details_url = context.details(bookmark.id)
css_classes = []
if bookmark.unread:
@ -200,16 +187,15 @@ class BookmarkListContext:
models.prefetch_related_objects(bookmarks_page.object_list, "owner", "tags")
self.items = [
BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page
BookmarkItem(request_context, bookmark, user, user_profile)
for bookmark in bookmarks_page
]
self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page
self.bookmarks_total = paginator.count
self.return_url = request_context.index()
self.action_url = request_context.action(return_url=self.return_url)
self.refresh_url = request_context.bookmark_list_partial()
self.tag_modal_url = request_context.tag_modal_partial()
self.action_url = request_context.action()
self.link_target = user_profile.bookmark_link_target
self.date_display = user_profile.bookmark_date_display
@ -344,8 +330,6 @@ class TagCloudContext:
self.selected_tags = unique_selected_tags
self.has_selected_tags = has_selected_tags
self.refresh_url = request_context.tag_cloud_partial()
def get_selected_tags(self, tags: List[Tag]):
parsed_query = queries.parse_query_string(self.search.q)
tag_names = parsed_query["tag_names"]
@ -396,17 +380,18 @@ class BookmarkAssetItem:
class BookmarkDetailsContext:
request_context = RequestContext
def __init__(self, request: WSGIRequest, bookmark: Bookmark):
request_context = self.request_context(request)
user = request.user
user_profile = request.user_profile
self.edit_return_url = utils.get_safe_return_url(
request.GET.get("return_url"),
reverse("bookmarks:details", args=[bookmark.id]),
)
self.delete_return_url = utils.get_safe_return_url(
request.GET.get("return_url"), reverse("bookmarks:index")
)
self.edit_return_url = request_context.details(bookmark.id)
self.action_url = request_context.action(add={"details": bookmark.id})
self.delete_url = request_context.action()
self.close_url = request_context.index()
self.bookmark = bookmark
self.profile = request.user_profile
@ -438,3 +423,44 @@ class BookmarkDetailsContext:
),
None,
)
class ActiveBookmarkDetailsContext(BookmarkDetailsContext):
request_context = ActiveBookmarksContext
class ArchivedBookmarkDetailsContext(BookmarkDetailsContext):
request_context = ArchivedBookmarksContext
class SharedBookmarkDetailsContext(BookmarkDetailsContext):
request_context = SharedBookmarksContext
def get_details_context(
request: WSGIRequest, context_type
) -> BookmarkDetailsContext | None:
bookmark_id = request.GET.get("details")
if not bookmark_id:
return None
try:
bookmark = Bookmark.objects.get(pk=int(bookmark_id))
except Bookmark.DoesNotExist:
# just ignore, might end up in a situation where the bookmark was deleted
# in between navigating back and forth
return None
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")
if request.method == "POST" and not is_owner:
raise Http404("Bookmark does not exist")
return context_type(request, bookmark)

View file

@ -0,0 +1,40 @@
from bookmarks.views import contexts, turbo
def render_bookmark_update(request, bookmark_list, tag_cloud, details):
return turbo.stream(
request,
"bookmarks/updates/bookmark_view_stream.html",
{
"bookmark_list": bookmark_list,
"tag_cloud": tag_cloud,
"details": details,
},
)
def active_bookmark_update(request):
bookmark_list = contexts.ActiveBookmarkListContext(request)
tag_cloud = contexts.ActiveTagCloudContext(request)
details = contexts.get_details_context(
request, contexts.ActiveBookmarkDetailsContext
)
return render_bookmark_update(request, bookmark_list, tag_cloud, details)
def archived_bookmark_update(request):
bookmark_list = contexts.ArchivedBookmarkListContext(request)
tag_cloud = contexts.ArchivedTagCloudContext(request)
details = contexts.get_details_context(
request, contexts.ArchivedBookmarkDetailsContext
)
return render_bookmark_update(request, bookmark_list, tag_cloud, details)
def shared_bookmark_update(request):
bookmark_list = contexts.SharedBookmarkListContext(request)
tag_cloud = contexts.SharedTagCloudContext(request)
details = contexts.get_details_context(
request, contexts.SharedBookmarkDetailsContext
)
return render_bookmark_update(request, bookmark_list, tag_cloud, details)

View file

@ -1,76 +0,0 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from bookmarks.views.partials import contexts
@login_required
def active_bookmark_list(request):
bookmark_list_context = contexts.ActiveBookmarkListContext(request)
return render(
request,
"bookmarks/bookmark_list.html",
{"bookmark_list": bookmark_list_context},
)
@login_required
def active_tag_cloud(request):
tag_cloud_context = contexts.ActiveTagCloudContext(request)
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
@login_required
def active_tag_modal(request):
tag_cloud_context = contexts.ActiveTagCloudContext(request)
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
@login_required
def archived_bookmark_list(request):
bookmark_list_context = contexts.ArchivedBookmarkListContext(request)
return render(
request,
"bookmarks/bookmark_list.html",
{"bookmark_list": bookmark_list_context},
)
@login_required
def archived_tag_cloud(request):
tag_cloud_context = contexts.ArchivedTagCloudContext(request)
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
@login_required
def archived_tag_modal(request):
tag_cloud_context = contexts.ArchivedTagCloudContext(request)
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
def shared_bookmark_list(request):
bookmark_list_context = contexts.SharedBookmarkListContext(request)
return render(
request,
"bookmarks/bookmark_list.html",
{"bookmark_list": bookmark_list_context},
)
def shared_tag_cloud(request):
tag_cloud_context = contexts.SharedTagCloudContext(request)
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
def shared_tag_modal(request):
tag_cloud_context = contexts.SharedTagCloudContext(request)
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})

19
bookmarks/views/turbo.py Normal file
View file

@ -0,0 +1,19 @@
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render as django_render
def accept(request: HttpRequest):
is_turbo_request = "text/vnd.turbo-stream.html" in request.headers.get("Accept", "")
disable_turbo = request.POST.get("disable_turbo", "false") == "true"
return is_turbo_request and not disable_turbo
def is_frame(request: HttpRequest, frame: str) -> bool:
return request.headers.get("Turbo-Frame") == frame
def stream(request: HttpRequest, template_name: str, context: dict) -> HttpResponse:
response = django_render(request, template_name, context)
response["Content-Type"] = "text/vnd.turbo-stream.html"
return response

View file

@ -10,8 +10,8 @@ from .base import *
DEBUG = True
# Enable debug toolbar
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
# INSTALLED_APPS.append("debug_toolbar")
# MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = [
"127.0.0.1",