Refactor client-side fetch logic (#693)

* extract generic behaviors

* preserve query string when refreshing content

* refactor details modal refresh

* refactor bulk edit

* update tests

* restore tag modal

* Make IntelliJ aware of custom attributes

* improve e2e test coverage
This commit is contained in:
Sascha Ißbrücker
2024-04-11 19:07:20 +02:00
committed by GitHub
parent 82f86bf537
commit 65f0eb2a04
32 changed files with 630 additions and 418 deletions

View File

@@ -1,63 +1,4 @@
import { registerBehavior, swapContent } from "./index";
class BookmarkPage {
constructor(element) {
this.element = element;
this.form = element.querySelector("form.bookmark-actions");
this.form.addEventListener("submit", this.onFormSubmit.bind(this));
this.bookmarkList = element.querySelector(".bookmark-list-container");
this.tagCloud = element.querySelector(".tag-cloud-container");
document.addEventListener("bookmark-page-refresh", () => {
this.refresh();
});
}
async onFormSubmit(event) {
event.preventDefault();
const url = this.form.action;
const formData = new FormData(this.form);
formData.append(event.submitter.name, event.submitter.value);
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
await this.refresh();
}
async refresh() {
const query = window.location.search;
const bookmarksUrl = this.element.getAttribute("bookmarks-url");
const tagsUrl = this.element.getAttribute("tags-url");
Promise.all([
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
]).then(([bookmarkListHtml, tagCloudHtml]) => {
swapContent(this.bookmarkList, bookmarkListHtml);
swapContent(this.tagCloud, tagCloudHtml);
// Dispatch list updated event
const listElement = this.bookmarkList.querySelector(
"ul[data-bookmarks-total]",
);
const bookmarksTotal =
(listElement && listElement.dataset.bookmarksTotal) || 0;
this.bookmarkList.dispatchEvent(
new CustomEvent("bookmark-list-updated", {
bubbles: true,
detail: { bookmarksTotal },
}),
);
});
}
}
registerBehavior("ld-bookmark-page", BookmarkPage);
import { registerBehavior } from "./index";
class BookmarkItem {
constructor(element) {

View File

@@ -4,43 +4,56 @@ class BulkEdit {
constructor(element) {
this.element = element;
this.active = false;
this.actionSelect = element.querySelector("select[name='bulk_action']");
this.tagAutoComplete = element.querySelector(".tag-autocomplete");
this.selectAcross = element.querySelector("label.select-across");
element.addEventListener(
"bulk-edit-toggle-active",
this.onToggleActive.bind(this),
);
element.addEventListener(
"bulk-edit-toggle-all",
this.onToggleAll.bind(this),
);
element.addEventListener(
"bulk-edit-toggle-bookmark",
this.onToggleBookmark.bind(this),
);
element.addEventListener(
"bookmark-list-updated",
this.onListUpdated.bind(this),
);
this.onToggleActive = this.onToggleActive.bind(this);
this.onToggleAll = this.onToggleAll.bind(this);
this.onToggleBookmark = this.onToggleBookmark.bind(this);
this.onActionSelected = this.onActionSelected.bind(this);
this.actionSelect.addEventListener(
"change",
this.onActionSelected.bind(this),
);
this.init();
// Reset when bookmarks are refreshed
document.addEventListener("refresh-bookmark-list-done", () => this.init());
}
get allCheckbox() {
return this.element.querySelector("[ld-bulk-edit-checkbox][all] input");
}
init() {
// Update elements
this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
this.actionSelect = this.element.querySelector(
"select[name='bulk_action']",
);
this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
this.selectAcross = this.element.querySelector("label.select-across");
this.allCheckbox = this.element.querySelector(
".bulk-edit-checkbox.all input",
);
this.bookmarkCheckboxes = Array.from(
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
);
get bookmarkCheckboxes() {
return [
...this.element.querySelectorAll(
"[ld-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);
});
// Reset checkbox states
this.reset();
// Update total number of bookmarks
const totalHolder = this.element.querySelector("[data-bookmarks-total]");
const total = totalHolder?.dataset.bookmarksTotal || 0;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
// Add new listeners
this.activeToggle.addEventListener("click", this.onToggleActive);
this.actionSelect.addEventListener("change", this.onActionSelected);
this.allCheckbox.addEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", this.onToggleBookmark);
});
}
onToggleActive() {
@@ -81,16 +94,6 @@ class BulkEdit {
}
}
onListUpdated(event) {
// Reset checkbox states
this.reset();
// Update total number of bookmarks
const total = event.detail.bookmarksTotal;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
}
updateSelectAcross(allChecked) {
if (allChecked) {
this.selectAcross.classList.remove("d-none");
@@ -109,33 +112,4 @@ class BulkEdit {
}
}
class BulkEditActiveToggle {
constructor(element) {
this.element = element;
element.addEventListener("click", this.onClick.bind(this));
}
onClick() {
this.element.dispatchEvent(
new CustomEvent("bulk-edit-toggle-active", { bubbles: true }),
);
}
}
class BulkEditCheckbox {
constructor(element) {
this.element = element;
element.addEventListener("change", this.onChange.bind(this));
}
onChange() {
const type = this.element.hasAttribute("all") ? "all" : "bookmark";
this.element.dispatchEvent(
new CustomEvent(`bulk-edit-toggle-${type}`, { bubbles: true }),
);
}
}
registerBehavior("ld-bulk-edit", BulkEdit);
registerBehavior("ld-bulk-edit-active-toggle", BulkEditActiveToggle);
registerBehavior("ld-bulk-edit-checkbox", BulkEditCheckbox);

View File

@@ -19,7 +19,7 @@ class ConfirmButtonBehavior {
const container = document.createElement("span");
container.className = "confirmation";
const icon = this.button.getAttribute("confirm-icon");
const icon = this.button.getAttribute("ld-confirm-icon");
if (icon) {
const iconElement = document.createElementNS(
"http://www.w3.org/2000/svg",
@@ -31,7 +31,7 @@ class ConfirmButtonBehavior {
container.append(iconElement);
}
const question = this.button.getAttribute("confirm-question");
const question = this.button.getAttribute("ld-confirm-question");
if (question) {
const questionElement = document.createElement("span");
questionElement.innerText = question;

View File

@@ -0,0 +1,25 @@
import { fireEvents, registerBehavior, swap } from "./index";
class FetchBehavior {
constructor(element) {
this.element = element;
const eventName = element.getAttribute("ld-on");
element.addEventListener(eventName, this.onFetch.bind(this));
}
async onFetch(event) {
event.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);
}
}
registerBehavior("ld-fetch", FetchBehavior);

View File

@@ -1,12 +1,12 @@
import { registerBehavior, swap } from "./index";
import { fireEvents, registerBehavior } from "./index";
class FormBehavior {
constructor(element) {
this.element = element;
element.addEventListener("submit", this.onFormSubmit.bind(this));
element.addEventListener("submit", this.onSubmit.bind(this));
}
async onFormSubmit(event) {
async onSubmit(event) {
event.preventDefault();
const url = this.element.action;
@@ -21,34 +21,21 @@ class FormBehavior {
redirect: "manual", // ignore redirect
});
// Dispatch refresh events
const refreshEvents = this.element.getAttribute("refresh-events");
if (refreshEvents) {
refreshEvents.split(",").forEach((eventName) => {
document.dispatchEvent(new CustomEvent(eventName));
});
const events = this.element.getAttribute("ld-fire");
if (fireEvents) {
fireEvents(events);
}
// Refresh form
await this.refresh();
}
async refresh() {
const refreshUrl = this.element.getAttribute("refresh-url");
const html = await fetch(refreshUrl).then((response) => response.text());
swap(this.element, html);
}
}
class FormAutoSubmitBehavior {
class AutoSubmitBehavior {
constructor(element) {
this.element = element;
this.element.addEventListener("change", () => {
const form = this.element.closest("form");
element.addEventListener("change", () => {
const form = element.closest("form");
form.dispatchEvent(new Event("submit", { cancelable: true }));
});
}
}
registerBehavior("ld-form", FormBehavior);
registerBehavior("ld-form-auto-submit", FormAutoSubmitBehavior);
registerBehavior("ld-auto-submit", AutoSubmitBehavior);

View File

@@ -37,14 +37,49 @@ export function applyBehaviors(container, behaviorNames = null) {
});
}
export function swap(element, html) {
export function swap(element, html, options) {
const dom = new DOMParser().parseFromString(html, "text/html");
const newElement = dom.body.firstChild;
element.replaceWith(newElement);
applyBehaviors(newElement);
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:
targetElement.innerHTML = "";
targetElement.append(...contents);
}
contents.forEach((content) => applyBehaviors(content));
}
export function swapContent(element, html) {
element.innerHTML = html;
applyBehaviors(element);
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,97 +1,20 @@
import { applyBehaviors, registerBehavior } from "./index";
import { registerBehavior } from "./index";
class ModalBehavior {
constructor(element) {
const toggle = element;
toggle.addEventListener("click", this.onToggleClick.bind(this));
this.toggle = toggle;
}
this.element = element;
async onToggleClick(event) {
// Ignore Ctrl + click
if (event.ctrlKey || event.metaKey) {
return;
}
event.preventDefault();
event.stopPropagation();
// Create modal either by teleporting existing content or fetching from URL
const modal = this.toggle.hasAttribute("modal-content")
? this.createFromContent()
: await this.createFromUrl();
if (!modal) {
return;
}
// Register close handlers
const modalOverlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector("button.close");
const modalOverlay = element.querySelector(".modal-overlay");
const closeButton = element.querySelector("button.close");
modalOverlay.addEventListener("click", this.onClose.bind(this));
closeButton.addEventListener("click", this.onClose.bind(this));
document.body.append(modal);
applyBehaviors(document.body);
this.modal = modal;
}
async createFromUrl() {
const url = this.toggle.getAttribute("modal-url");
const modalHtml = await fetch(url).then((response) => response.text());
const parser = new DOMParser();
const doc = parser.parseFromString(modalHtml, "text/html");
return doc.querySelector(".modal");
}
createFromContent() {
const contentSelector = this.toggle.getAttribute("modal-content");
const content = document.querySelector(contentSelector);
if (!content) {
return;
}
// Todo: make title configurable, only used for tag cloud for now
const modal = document.createElement("div");
modal.classList.add("modal", "active");
modal.innerHTML = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header d-flex justify-between align-center">
<div class="modal-title h5">Tags</div>
<button class="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 contentOwner = content.parentElement;
const contentContainer = modal.querySelector(".content");
contentContainer.append(content);
this.content = content;
this.contentOwner = contentOwner;
return modal;
}
onClose() {
// Teleport content back
if (this.content && this.contentOwner) {
this.contentOwner.append(this.content);
}
// Remove modal
this.modal.classList.add("closing");
this.modal.addEventListener("animationend", (event) => {
this.element.classList.add("closing");
this.element.addEventListener("animationend", (event) => {
if (event.animationName === "fade-out") {
this.modal.remove();
this.element.remove();
}
});
}

View File

@@ -2,6 +2,7 @@ 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/global-shortcuts";