Refresh file list when there are queued snapshots (#697)

* add destroy hook

* refresh details modal in interval

* refactor to refresh assets list

* disable create snapshot button when there is a pending snapshot
This commit is contained in:
Sascha Ißbrücker 2024-04-14 14:41:22 +02:00 committed by GitHub
parent df9f0095cc
commit 1b7731e506
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 267 additions and 85 deletions

View file

@ -1,8 +1,8 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class BookmarkItem { class BookmarkItem extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
// Toggle notes // Toggle notes
const notesToggle = element.querySelector(".toggle-notes"); const notesToggle = element.querySelector(".toggle-notes");
@ -13,9 +13,11 @@ class BookmarkItem {
// Add tooltip to title if it is truncated // Add tooltip to title if it is truncated
const titleAnchor = element.querySelector(".title > a"); const titleAnchor = element.querySelector(".title > a");
const titleSpan = titleAnchor.querySelector("span"); const titleSpan = titleAnchor.querySelector("span");
requestAnimationFrame(() => {
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) { if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
titleAnchor.dataset.tooltip = titleSpan.textContent; titleAnchor.dataset.tooltip = titleSpan.textContent;
} }
});
} }
onToggleNotes(event) { onToggleNotes(event) {

View file

@ -1,8 +1,9 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class BulkEdit { class BulkEdit extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
this.active = false; this.active = false;
this.onToggleActive = this.onToggleActive.bind(this); this.onToggleActive = this.onToggleActive.bind(this);

View file

@ -1,25 +1,29 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class ConfirmButtonBehavior { class ConfirmButtonBehavior extends Behavior {
constructor(element) { constructor(element) {
const button = element; super(element);
button.dataset.type = button.type; element.dataset.type = element.type;
button.dataset.name = button.name; element.dataset.name = element.name;
button.dataset.value = button.value; element.dataset.value = element.value;
button.removeAttribute("type"); element.removeAttribute("type");
button.removeAttribute("name"); element.removeAttribute("name");
button.removeAttribute("value"); element.removeAttribute("value");
button.addEventListener("click", this.onClick.bind(this)); element.addEventListener("click", this.onClick.bind(this));
this.button = button; }
destroy() {
Behavior.interacting = false;
} }
onClick(event) { onClick(event) {
event.preventDefault(); event.preventDefault();
Behavior.interacting = true;
const container = document.createElement("span"); const container = document.createElement("span");
container.className = "confirmation"; container.className = "confirmation";
const icon = this.button.getAttribute("ld-confirm-icon"); const icon = this.element.getAttribute("ld-confirm-icon");
if (icon) { if (icon) {
const iconElement = document.createElementNS( const iconElement = document.createElementNS(
"http://www.w3.org/2000/svg", "http://www.w3.org/2000/svg",
@ -31,27 +35,27 @@ class ConfirmButtonBehavior {
container.append(iconElement); container.append(iconElement);
} }
const question = this.button.getAttribute("ld-confirm-question"); const question = this.element.getAttribute("ld-confirm-question");
if (question) { if (question) {
const questionElement = document.createElement("span"); const questionElement = document.createElement("span");
questionElement.innerText = question; questionElement.innerText = question;
container.append(question); container.append(question);
} }
const buttonClasses = Array.from(this.button.classList.values()) const buttonClasses = Array.from(this.element.classList.values())
.filter((cls) => cls.startsWith("btn")) .filter((cls) => cls.startsWith("btn"))
.join(" "); .join(" ");
const cancelButton = document.createElement(this.button.nodeName); const cancelButton = document.createElement(this.element.nodeName);
cancelButton.type = "button"; cancelButton.type = "button";
cancelButton.innerText = question ? "No" : "Cancel"; cancelButton.innerText = question ? "No" : "Cancel";
cancelButton.className = `${buttonClasses} mr-1`; cancelButton.className = `${buttonClasses} mr-1`;
cancelButton.addEventListener("click", this.reset.bind(this)); cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.button.nodeName); const confirmButton = document.createElement(this.element.nodeName);
confirmButton.type = this.button.dataset.type; confirmButton.type = this.element.dataset.type;
confirmButton.name = this.button.dataset.name; confirmButton.name = this.element.dataset.name;
confirmButton.value = this.button.dataset.value; confirmButton.value = this.element.dataset.value;
confirmButton.innerText = question ? "Yes" : "Confirm"; confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = buttonClasses; confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this)); confirmButton.addEventListener("click", this.reset.bind(this));
@ -59,14 +63,15 @@ class ConfirmButtonBehavior {
container.append(cancelButton, confirmButton); container.append(cancelButton, confirmButton);
this.container = container; this.container = container;
this.button.before(container); this.element.before(container);
this.button.classList.add("d-none"); this.element.classList.add("d-none");
} }
reset() { reset() {
setTimeout(() => { setTimeout(() => {
Behavior.interacting = false;
this.container.remove(); this.container.remove();
this.button.classList.remove("d-none"); this.element.classList.remove("d-none");
}); });
} }
} }

View file

@ -1,8 +1,8 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class DropdownBehavior { class DropdownBehavior extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
this.opened = false; this.opened = false;
this.onOutsideClick = this.onOutsideClick.bind(this); this.onOutsideClick = this.onOutsideClick.bind(this);

View file

@ -1,15 +1,31 @@
import { fireEvents, registerBehavior, swap } from "./index"; import { Behavior, fireEvents, registerBehavior, swap } from "./index";
class FetchBehavior { class FetchBehavior extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
const eventName = element.getAttribute("ld-on");
element.addEventListener(eventName, this.onFetch.bind(this)); 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);
}
} }
async onFetch(event) { destroy() {
event.preventDefault(); if (this.intervalId) {
clearInterval(this.intervalId);
}
}
async onFetch(maybeEvent) {
if (maybeEvent) {
maybeEvent.preventDefault();
}
const url = this.element.getAttribute("ld-fetch"); const url = this.element.getAttribute("ld-fetch");
const html = await fetch(url).then((response) => response.text()); const html = await fetch(url).then((response) => response.text());
@ -20,6 +36,13 @@ class FetchBehavior {
const events = this.element.getAttribute("ld-fire"); const events = this.element.getAttribute("ld-fire");
fireEvents(events); fireEvents(events);
} }
onInterval() {
if (Behavior.interacting) {
return;
}
this.onFetch();
}
} }
registerBehavior("ld-fetch", FetchBehavior); registerBehavior("ld-fetch", FetchBehavior);

View file

@ -1,8 +1,9 @@
import { fireEvents, registerBehavior } from "./index"; import { Behavior, fireEvents, registerBehavior } from "./index";
class FormBehavior { class FormBehavior extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
element.addEventListener("submit", this.onSubmit.bind(this)); element.addEventListener("submit", this.onSubmit.bind(this));
} }
@ -28,8 +29,10 @@ class FormBehavior {
} }
} }
class AutoSubmitBehavior { class AutoSubmitBehavior extends Behavior {
constructor(element) { constructor(element) {
super(element);
element.addEventListener("change", () => { element.addEventListener("change", () => {
const form = element.closest("form"); const form = element.closest("form");
form.dispatchEvent(new Event("submit", { cancelable: true })); form.dispatchEvent(new Event("submit", { cancelable: true }));

View file

@ -1,7 +1,9 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class GlobalShortcuts extends Behavior {
constructor(element) {
super(element);
class GlobalShortcuts {
constructor() {
document.addEventListener("keydown", this.onKeyDown.bind(this)); document.addEventListener("keydown", this.onKeyDown.bind(this));
} }

View file

@ -1,4 +1,35 @@
const behaviorRegistry = {}; const behaviorRegistry = {};
const debug = false;
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.removedNodes.forEach((node) => {
if (node instanceof HTMLElement && !node.isConnected) {
destroyBehaviors(node);
}
});
mutation.addedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.isConnected) {
applyBehaviors(node);
}
});
});
});
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
});
export class Behavior {
constructor(element) {
this.element = element;
}
destroy() {}
}
Behavior.interacting = false;
export function registerBehavior(name, behavior) { export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior; behaviorRegistry[name] = behavior;
@ -33,6 +64,34 @@ export function applyBehaviors(container, behaviorNames = null) {
const behaviorInstance = new behavior(element); const behaviorInstance = new behavior(element);
element.__behaviors.push(behaviorInstance); element.__behaviors.push(behaviorInstance);
if (debug) {
console.log(
`[Behavior] ${behaviorInstance.constructor.name} initialized`,
);
}
});
});
}
export function destroyBehaviors(element) {
const behaviorNames = Object.keys(behaviorRegistry);
behaviorNames.forEach((behaviorName) => {
const elements = Array.from(element.querySelectorAll(`[${behaviorName}]`));
elements.push(element);
elements.forEach((element) => {
if (!element.__behaviors) {
return;
}
element.__behaviors.forEach((behavior) => {
behavior.destroy();
if (debug) {
console.log(`[Behavior] ${behavior.constructor.name} destroyed`);
}
});
delete element.__behaviors;
}); });
}); });
} }
@ -63,10 +122,11 @@ export function swap(element, html, options) {
break; break;
case "innerHTML": case "innerHTML":
default: default:
targetElement.innerHTML = ""; Array.from(targetElement.children).forEach((child) => {
child.remove();
});
targetElement.append(...contents); targetElement.append(...contents);
} }
contents.forEach((content) => applyBehaviors(content));
} }
export function fireEvents(events) { export function fireEvents(events) {

View file

@ -1,8 +1,8 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class ModalBehavior { class ModalBehavior extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
const modalOverlay = element.querySelector(".modal-overlay"); const modalOverlay = element.querySelector(".modal-overlay");
const closeButton = element.querySelector("button.close"); const closeButton = element.querySelector("button.close");

View file

@ -1,9 +1,10 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte"; import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
import { ApiClient } from "../api"; import { ApiClient } from "../api";
class TagAutocomplete { class TagAutocomplete extends Behavior {
constructor(element) { constructor(element) {
super(element);
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || ""; const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
const apiClient = new ApiClient(apiBaseUrl); const apiClient = new ApiClient(apiBaseUrl);

View file

@ -1,3 +1,7 @@
<div {% if details.has_pending_assets %}
ld-fetch="{% url 'bookmarks:details_assets' details.bookmark.id %}"
ld-interval="5" ld-target="self|outerHTML"
{% endif %}>
{% if details.assets %} {% if details.assets %}
<div class="assets"> <div class="assets">
{% for asset in details.assets %} {% for asset in details.assets %}
@ -32,6 +36,9 @@
{% if details.is_editable %} {% if details.is_editable %}
<div class="assets-actions"> <div class="assets-actions">
<button type="submit" name="create_snapshot" class="btn btn-link">Create HTML snapshot</button> <button type="submit" name="create_snapshot" class="btn btn-link"
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
</button>
</div> </div>
{% endif %} {% endif %}
</div>

View file

@ -1,3 +1,4 @@
import re
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
@ -105,6 +106,12 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_access_with_sharing(self): def test_access_with_sharing(self):
self.details_route_sharing_access_test(self.get_view_name(), True) 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)
def test_displays_title(self): def test_displays_title(self):
# with title # with title
bookmark = self.setup_bookmark(title="Test title") bookmark = self.setup_bookmark(title="Test title")
@ -753,6 +760,27 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists()) 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) @override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot(self): def test_create_snapshot(self):
with patch.object( with patch.object(
@ -765,3 +793,27 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(bookmark.bookmarkasset_set.count(), 1) 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)
files_section = self.find_section(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)
self.assertFalse(create_button.has_attr("disabled"))
# with pending asset
asset.status = BookmarkAsset.STATUS_PENDING
asset.save()
soup = self.get_details(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"))

View file

@ -44,6 +44,11 @@ urlpatterns = [
views.bookmarks.details_modal, views.bookmarks.details_modal,
name="details_modal", name="details_modal",
), ),
path(
"bookmarks/<int:bookmark_id>/details_assets",
views.bookmarks.details_assets,
name="details_assets",
),
# Assets # Assets
path( path(
"assets/<int:asset_id>", "assets/<int:asset_id>",

View file

@ -172,6 +172,10 @@ def details_modal(request, bookmark_id: int):
return _details(request, bookmark_id, "bookmarks/details_modal.html") 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): def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings # strings

View file

@ -390,3 +390,6 @@ class BookmarkDetailsContext:
self.assets = [ self.assets = [
BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all() BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()
] ]
self.has_pending_assets = any(
asset.status == BookmarkAsset.STATUS_PENDING for asset in self.assets
)

View file

@ -26,6 +26,20 @@
"required": false "required": false
} }
}, },
{
"name": "ld-select",
"description": "The content element(s) to select from the fetched content, for example `#main-content`",
"value": {
"required": false
}
},
{
"name": "ld-interval",
"description": "Automatically fetches the content of the given URL at the given interval, in seconds",
"value": {
"required": false
}
},
{ {
"name": "ld-fire", "name": "ld-fire",
"description": "Fires one or more events once a behavior, such as ld-fetch or ld-form, is finished", "description": "Fires one or more events once a behavior, such as ld-fetch or ld-form, is finished",