From 9d77fca0090a53a0b74e62aa415d1fcf303f156e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Mon, 15 Jun 2026 09:22:29 +0200 Subject: [PATCH] Add TypeScript types to utils.ts (issue #17) - Add types to all function parameters and return values - Replace getEl() with native document.querySelector() throughout - Fix removeEventListener bug (was removing wrong function reference) - Fix disabled property: use boolean true/false instead of strings - Add ElementHandlerConfig labeled tuple type for conditionalElementHandler Co-Authored-By: Claude Sonnet 4.6 --- games/static/js/add_purchase.js | 6 +- ts/utils.ts | 237 ++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 ts/utils.ts diff --git a/games/static/js/add_purchase.js b/games/static/js/add_purchase.js index 05a9253..6b99380 100644 --- a/games/static/js/add_purchase.js +++ b/games/static/js/add_purchase.js @@ -1,4 +1,4 @@ -import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js"; +import { disableElementsWhenTrue, onSwap } from "./utils.js"; const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game"; @@ -11,7 +11,7 @@ document.addEventListener("search-select:change", (event) => { const last = event.detail.last; const platformId = last && last.data ? last.data.platform : ""; if (platformId) { - const platformEl = getEl("#id_platform"); + const platformEl = document.querySelector("#id_platform"); if (platformEl) platformEl.value = platformId; } @@ -26,7 +26,7 @@ document.addEventListener("search-select:change", (event) => { }) .then((html) => { if (html === null) return; - const target = getEl("#id_related_purchase"); + const target = document.querySelector("#id_related_purchase"); if (target) target.outerHTML = html; }); }); diff --git a/ts/utils.ts b/ts/utils.ts new file mode 100644 index 0000000..c4e19be --- /dev/null +++ b/ts/utils.ts @@ -0,0 +1,237 @@ +declare const htmx: any; + + +/** + * @description Runs initializeElement once for each element matching selector, + * on initial page load and inside every htmx-swapped fragment (a port of + * FastHTML's proc_htmx). htmx fires htmx:load for the initial document and for + * each swapped-in element, so a single registration covers both; the WeakSet + * guarantees once-per-element initialization, replacing the old + * DOMContentLoaded + htmx:afterSwap + per-element guard-flag pattern. + * @param {string} selector + * @param {function(Element): void} initializeElement + */ +function onSwap(selector: string, initializeElement: (element: Element) => void) { + const initialized = new WeakSet(); + htmx.onLoad((swappedElement: Element) => { + const elements: Element[] = Array.from(htmx.findAll(swappedElement, selector)); + if (swappedElement.matches && swappedElement.matches(selector)) { + elements.unshift(swappedElement); + } + for (const element of elements) { + if (initialized.has(element)) continue; + initialized.add(element); + initializeElement(element); + } + }); +} + +/** + * @description Formats Date to a UTC string accepted by the datetime-local input field. + * @param {Date} date + * @returns {string} + */ +function toISOUTCString(date: Date): string { + function stringAndPad(number: number) { + return number.toString().padStart(2, "0"); + } + const year = date.getFullYear(); + const month = stringAndPad(date.getMonth() + 1); + const day = stringAndPad(date.getDate()); + const hours = stringAndPad(date.getHours()); + const minutes = stringAndPad(date.getMinutes()); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} + +/** + * @description Sync values between source and target elements based on syncData configuration. + * @param {Array} syncData - Array of objects to define source and target elements with their respective value types. + */ +function syncSelectInputUntilChanged(syncData: Array<{ source: string; target: string; source_value: string; target_value: string }>, parentSelector: string | Document = document) { + const parentElement = + parentSelector === document + ? document + : document.querySelector(parentSelector as string); + + if (!parentElement) { + console.error(`The parent selector "${parentSelector}" is not valid.`); + return; + } + // Set up a single change event listener on the document for handling all source changes + parentElement.addEventListener("change", function (event) { + // Loop through each sync configuration item + syncData.forEach((syncItem: { source: string; target: string; source_value: string; target_value: string }) => { + // Check if the change event target matches the source selector + if ((event.target as HTMLElement).matches(syncItem.source)) { + if (!event.target) return; + const sourceElement = event.target; + const valueToSync = getValueFromProperty( + sourceElement, + syncItem.source_value + ); + const targetElement = document.querySelector(syncItem.target); + + if (targetElement && valueToSync !== null) { + console.log(`Changing value of ${syncItem.target} to ${valueToSync}`); + (targetElement as unknown as Record)[syncItem.target_value] = valueToSync; + } + } + }); + }); + + // Set up a single focus event listener on the document for handling all target focuses + const syncListener = (event: Event) => { + // Loop through each sync configuration item + syncData.forEach((syncItem: { source: string; target: string; source_value: string; target_value: string }) => { + // Check if the focus event target matches the target selector + if ((event.target as HTMLElement).matches(syncItem.target)) { + // Remove the change event listener to stop syncing + // This assumes you want to stop syncing once any target receives focus + // You may need a more sophisticated way to remove listeners if you want to stop + // syncing selectively based on other conditions + document.removeEventListener("change", syncListener); + } + }); + } + parentElement.addEventListener( + "focus", + syncListener, + true + ); // Use capture phase to ensure the event is captured during focus, not bubble +} + +/** + * @description Retrieve the value from the source element based on the provided property. + * @param {Element} sourceElement - The source HTML element. + * @param {string} property - The property to retrieve the value from. + */ +function getValueFromProperty(sourceElement: EventTarget, property: string): any { + let source: HTMLElement | HTMLOptionElement = + sourceElement instanceof HTMLSelectElement + ? sourceElement.selectedOptions[0] + : sourceElement as HTMLElement; + if (property.startsWith("dataset.")) { + let datasetKey = property.slice(8); // Remove 'dataset.' part + return source.dataset[datasetKey]; + } else if (property in source) { + return (source as unknown as Record)[property]; + } else { + console.error(`Property ${property} is not valid for the option element.`); + return null; + } +} + +type ElementHandlerConfig = [ + condition: () => boolean, // condition function + targetElements: string[], // array of target element selectors + callbackfn1: (el: HTMLElement) => void, // callback function for matched condition + callbackfn2: (el: HTMLElement) => void // callback function for unmatched condition +]; + +/** + * @description Applies different behaviors to elements based on multiple conditional configurations. + * Each configuration is an array containing a condition function, an array of target element selectors, + * and two callback functions for handling matched and unmatched conditions. + * @param {...Array} configs Each configuration is an array of the form: + * - 0: {function(): boolean} condition - Function that returns true or false based on a condition. + * - 1: {string[]} targetElements - Array of CSS selectors for target elements. + * - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true. + * - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false. + */ +function conditionalElementHandler(...configs: ElementHandlerConfig[]) { + configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => { + if (condition()) { + targetElements.forEach((elementName) => { + let el = document.querySelector(elementName); + if (el === null) { + console.error(`Element ${elementName} doesn't exist.`); + } else { + callbackfn1(el); + } + }); + } else { + targetElements.forEach((elementName) => { + let el = document.querySelector(elementName); + if (el === null) { + console.error(`Element ${elementName} doesn't exist.`); + } else { + callbackfn2(el); + } + }); + } + }); +} + +function disableElementsWhenValueNotEqual( + targetSelect: string, + targetValue: string | string[], + elementList: string[] +) { + return conditionalElementHandler([ + () => { + let target = document.querySelector(targetSelect); + if (!target) return false; + console.debug( + `${disableElementsWhenTrue.name}: triggered on ${target.id}` + ); + console.debug(` + ${disableElementsWhenTrue.name}: matching against value(s): ${targetValue}`); + if (targetValue instanceof Array) { + if (targetValue.every((value) => target.value != value)) { + console.debug( + `${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.` + ); + return true; + } + return false; + } else { + console.debug( + `${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.` + ); + return target.value != targetValue; + } + }, + elementList, + (el) => { + console.debug( + `${disableElementsWhenTrue.name}: evaluated true, disabling ${el.id}.` + ); + (el as HTMLInputElement).disabled = true; + }, + (el) => { + console.debug( + `${disableElementsWhenTrue.name}: evaluated false, NOT disabling ${el.id}.` + ); + (el as HTMLInputElement).disabled = false; + }, + ]); +} + +function disableElementsWhenTrue(targetSelect: string, targetValue: string | string[], elementList: string[]) { + return conditionalElementHandler([ + () => { + console.log(`${disableElementsWhenTrue.name}: triggered on ${targetSelect}`) + console.log(`Value of ${targetSelect} is ${targetValue}: ${document.querySelector(targetSelect)?.value == targetValue}`) + return document.querySelector(targetSelect)?.value == targetValue; + }, + elementList, + (el) => { + console.log(`${disableElementsWhenTrue.name}: disabling ${el.id}`); + (el as HTMLInputElement).disabled = true; + }, + (el) => { + console.log(`${disableElementsWhenTrue.name}: enabling ${el.id}`); + (el as HTMLInputElement).disabled = false; + }, + ]); +} + +export { + onSwap, + toISOUTCString, + syncSelectInputUntilChanged, + conditionalElementHandler, + disableElementsWhenValueNotEqual, + disableElementsWhenTrue, + getValueFromProperty, +};