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
65 changed files with 1419 additions and 1444 deletions

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);