From 757bb1dcc50fe41b608c76e1de86e51858232ffe Mon Sep 17 00:00:00 2001 From: Sebastiano Bellinzis Date: Mon, 25 Nov 2024 17:22:58 +0100 Subject: [PATCH 1/3] js refactor --- examples/form/templates/form.gohtml | 2 +- js/partial.js | 480 +++++++++++++++++++--------- 2 files changed, 335 insertions(+), 147 deletions(-) diff --git a/examples/form/templates/form.gohtml b/examples/form/templates/form.gohtml index c32a375..a48f217 100644 --- a/examples/form/templates/form.gohtml +++ b/examples/form/templates/form.gohtml @@ -1,6 +1,6 @@
-
+ diff --git a/js/partial.js b/js/partial.js index d36a5bf..8ddfaa6 100644 --- a/js/partial.js +++ b/js/partial.js @@ -12,7 +12,16 @@ */ /** - * Class representing Partial. + * @typedef {Object} SseMessage + * @property {string} content - The HTML content to insert. + * @property {string} [xTarget] - The CSS selector for the target element. + * @property {string} [xFocus] - Whether to focus the target element ('true' or 'false'). + * @property {string} [xSwap] - The swap method ('outerHTML' or 'innerHTML'). + * @property {string} [xEvent] - Custom events to dispatch. + */ + +/** + * Class representing Partial.js. */ class Partial { /** @@ -21,7 +30,26 @@ class Partial { */ constructor(options = {}) { // Define the custom action attributes - this.actionAttributes = ['x-get', 'x-post', 'x-put', 'x-delete']; + this.ATTRIBUTES = { + ACTIONS: { + GET: 'x-get', + POST: 'x-post', + PUT: 'x-put', + DELETE: 'x-delete', + }, + TARGET: 'x-target', + TRIGGER: 'x-trigger', + SERIALIZE: 'x-serialize', + JSON: 'x-json', + PARAMS: 'x-params', + SWAP_OOB: 'x-swap-oob', + PUSH_STATE: 'x-push-state', + FOCUS: 'x-focus', + DEBOUNCE: 'x-debounce', + BEFORE: 'x-before', + AFTER: 'x-after', + SSE: 'x-sse', + }; // Store options with default values this.onError = options.onError || null; @@ -35,9 +63,13 @@ class Partial { this.eventTarget = new EventTarget(); this.eventListeners = {}; + // Map to store SSE connections per element + this.sseConnections = new Map(); + // Bind methods to ensure correct 'this' context this.scanForElements = this.scanForElements.bind(this); this.setupElement = this.setupElement.bind(this); + this.setupSSEElement = this.setupSSEElement.bind(this); this.handleAction = this.handleAction.bind(this); this.handleOobSwapping = this.handleOobSwapping.bind(this); this.handlePopState = this.handlePopState.bind(this); @@ -54,9 +86,113 @@ class Partial { * @param {HTMLElement | Document} [container=document] */ scanForElements(container = document) { - const selector = this.actionAttributes.map(attr => `[${attr}]`).join(','); - const elements = container.querySelectorAll(selector); - elements.forEach(this.setupElement); + const actionSelector = Object.values(this.ATTRIBUTES.ACTIONS).map(attr => `[${attr}]`).join(','); + const sseSelector = `[${this.ATTRIBUTES.SSE}]`; + const combinedSelector = `${actionSelector}, ${sseSelector}`; + const elements = container.querySelectorAll(combinedSelector); + + elements.forEach(element => { + if (element.hasAttribute(this.ATTRIBUTES.SSE)) { + this.setupSSEElement(element); + } else { + this.setupElement(element); + } + }); + } + + /** + * Sets up an element with x-sse attribute to handle SSE connections. + * @param {HTMLElement} element + */ + setupSSEElement(element) { + // Avoid attaching multiple listeners + if (element.__xSSEInitialized) return; + + const sseUrl = element.getAttribute(this.ATTRIBUTES.SSE); + if (!sseUrl) { + console.error('No URL specified in x-sse attribute on element:', element); + return; + } + + const eventSource = new EventSource(sseUrl); + + eventSource.onmessage = (event) => { + this.handleSSEMessage(event, element); + }; + + eventSource.onerror = (error) => { + console.error('SSE connection error on element:', element, error); + if (typeof this.onError === 'function') { + this.onError(error, element); + } + }; + + // Store the connection to manage it later if needed + this.sseConnections.set(element, eventSource); + + // Mark the element as initialized + element.__xSSEInitialized = true; + } + + /** + * Handles incoming SSE messages for a specific element. + * @param {MessageEvent} event + * @param {HTMLElement} element + */ + async handleSSEMessage(event, element) { + try { + /** @type {SseMessage} */ + const data = JSON.parse(event.data); + + const targetSelector = data.xTarget; + const targetElement = document.querySelector(targetSelector); + + if (!targetElement) { + console.error(`No element found with selector '${targetSelector}' for SSE message.`); + return; + } + + // Decide swap method + const swapOption = data.xSwap || this.defaultSwapOption; + + if (swapOption === 'outerHTML') { + targetElement.outerHTML = data.content; + } else if (swapOption === 'innerHTML') { + targetElement.innerHTML = data.content; + } else { + console.error(`Invalid x-swap option '${swapOption}' in SSE message. Use 'outerHTML' or 'innerHTML'.`); + return; + } + + // Optionally focus the target element + const focusEnabled = data.xFocus !== 'false'; + if (this.autoFocus && focusEnabled) { + const newTargetElement = document.querySelector(targetSelector); + if (newTargetElement) { + if (newTargetElement.getAttribute('tabindex') === null) { + newTargetElement.setAttribute('tabindex', '-1'); + } + newTargetElement.focus(); + } + } + + // Re-scan the updated content for Partial elements + this.scanForElements(); + + // Dispatch custom events if specified + if (data.xEvent) { + await this.dispatchCustomEvents(data.xEvent, { element, event, data }); + } + + // Dispatch an event after the content is replaced + this.dispatchEvent('sseContentReplaced', { targetElement, data, element }); + + } catch (error) { + console.error('Error processing SSE message:', error); + if (typeof this.onError === 'function') { + this.onError(error, element); + } + } } /** @@ -70,14 +206,14 @@ class Partial { // Set a default trigger based on the element type let trigger; if (element.tagName === 'FORM') { - trigger = element.getAttribute('x-trigger') || 'submit'; + trigger = element.getAttribute(this.ATTRIBUTES.TRIGGER) || 'submit'; } else { - trigger = element.getAttribute('x-trigger') || 'click'; + trigger = element.getAttribute(this.ATTRIBUTES.TRIGGER) || 'click'; } // Get custom debounce time from x-debounce attribute let elementDebounceTime = this.debounceTime; // Default to global debounce time - const xDebounce = element.getAttribute('x-debounce'); + const xDebounce = element.getAttribute(this.ATTRIBUTES.DEBOUNCE); if (xDebounce !== null) { const parsedDebounce = parseInt(xDebounce, 10); if (!isNaN(parsedDebounce) && parsedDebounce >= 0) { @@ -110,11 +246,13 @@ class Partial { * @param {HTMLElement} element */ async handleAction(event, element) { - const method = this.getMethod(element); - let url = element.getAttribute(`x-${method.toLowerCase()}`); + const requestParams = this.extractRequestParams(element); - if (!url) { - const error = new Error(`No URL specified for method ${method} on element.`); + // Ensure 'element' is included in the request parameters + requestParams.element = element; + + if (!requestParams.url) { + const error = new Error(`No URL specified for method ${requestParams.method} on element.`); console.error(error.message, element); if (typeof this.onError === 'function') { this.onError(error, element); @@ -122,15 +260,9 @@ class Partial { return; } - const headers = this.getHeaders(element); - let partialSelector = element.getAttribute('x-target'); - if (!partialSelector) { - partialSelector = window.body; - } - - const targetElement = document.querySelector(partialSelector); + const targetElement = document.querySelector(requestParams.targetSelector); if (!targetElement) { - const error = new Error(`No element found with selector '${partialSelector}' for 'x-target' targeting.`); + const error = new Error(`No element found with selector '${requestParams.targetSelector}' for 'x-target' targeting.`); console.error(error.message); if (typeof this.onError === 'function') { this.onError(error, element); @@ -138,8 +270,7 @@ class Partial { return; } - const partialId = targetElement.getAttribute('id'); - if (!partialId) { + if (!requestParams.partialId) { const error = new Error(`Target element does not have an 'id' attribute.`); console.error(error.message, targetElement); if (typeof this.onError === 'function') { @@ -149,94 +280,48 @@ class Partial { } // Set the X-Target header to the request - headers["X-Target"] = partialId; - - // Handle x-params for GET requests - if (method === 'GET') { - const xParams = element.getAttribute('x-params'); - if (xParams) { - try { - const paramsObject = JSON.parse(xParams); - const urlParams = new URLSearchParams(paramsObject).toString(); - url += (url.includes('?') ? '&' : '?') + urlParams; - } catch (e) { - console.error('Invalid JSON in x-params attribute:', e); - const error = new Error('Invalid JSON in x-params attribute'); - if (typeof this.onError === 'function') { - this.onError(error, element); - } - return; - } - } - } + requestParams.headers["X-Target"] = requestParams.partialId; try { // Dispatch x-before event(s) if specified - const beforeEvents = element.getAttribute('x-before'); + const beforeEvents = element.getAttribute(this.ATTRIBUTES.BEFORE); if (beforeEvents) { await this.dispatchCustomEvents(beforeEvents, { element, event }); } // Before request hook if (typeof this.beforeRequest === 'function') { - await this.beforeRequest({ method, url, headers, element }); + await this.beforeRequest({ ...requestParams, element }); } - const responseText = await this.performRequest(method, url, headers, element); + // Dispatch beforeSend event + this.dispatchEvent('beforeSend', { ...requestParams, element }); + + const responseText = await this.performRequest(requestParams); // After response hook if (typeof this.afterResponse === 'function') { await this.afterResponse({ response: this.lastResponse, element }); } - // Parse the response HTML - const parser = new DOMParser(); - const doc = parser.parseFromString(responseText, 'text/html'); + // Dispatch afterReceive event + this.dispatchEvent('afterReceive', { response: this.lastResponse, element }); - // Extract OOB elements - const oobElements = Array.from(doc.querySelectorAll('[x-swap-oob]')); - oobElements.forEach(el => el.parentNode.removeChild(el)); - - // Dispatch an event before the content is replaced - this.dispatchEvent('xBeforeContentReplace', { targetElement, doc }); - - // Replace the target's innerHTML with the main content - targetElement.innerHTML = doc.body.innerHTML; - - // Dispatch an event after the content is replaced - this.dispatchEvent('xAfterContentReplace', { targetElement, doc }); + // Process and update the DOM with the response + await this.processResponse(responseText, targetElement, element); // After successfully updating content - const shouldPushState = element.getAttribute('x-push-state') !== 'false'; + const shouldPushState = element.getAttribute(this.ATTRIBUTES.PUSH_STATE) !== 'false'; if (shouldPushState) { - const newUrl = new URL(url, window.location.origin); + const newUrl = new URL(requestParams.url, window.location.origin); history.pushState({ xPartial: true }, '', newUrl); } - // Auto-focus if enabled - const focusEnabled = element.getAttribute('x-focus') !== 'false'; - if (this.autoFocus && focusEnabled) { - if (targetElement.getAttribute('tabindex') === null) { - targetElement.setAttribute('tabindex', '-1'); - } - targetElement.focus(); - } - - // Re-scan the newly added content for Partial elements - this.scanForElements(targetElement); - - // Handle OOB swapping with the extracted OOB elements - this.handleOobSwapping(oobElements); - - // Handle any x-event-* headers from the response - await this.handleResponseEvents(); - // Dispatch x-after event(s) if specified - const afterEvents = element.getAttribute('x-after'); + const afterEvents = element.getAttribute(this.ATTRIBUTES.AFTER); if (afterEvents) { await this.dispatchCustomEvents(afterEvents, { element, event }); } - } catch (error) { console.error('Request failed:', error); @@ -250,68 +335,43 @@ class Partial { } /** - * Dispatches custom events specified in a comma-separated string. - * @param {string} events - Comma-separated event names. - * @param {Object} detail - Detail object to pass with the event. - */ - async dispatchCustomEvents(events, detail) { - const eventNames = events.split(',').map(e => e.trim()); - for (const eventName of eventNames) { - const event = new CustomEvent(eventName, { detail }); - this.eventTarget.dispatchEvent(event); - } - } - - /** - * Handles the popstate event for browser navigation. - * @param {PopStateEvent} event + * Extracts request parameters from the element. + * @param {HTMLElement} element + * @returns {Object} Parameters including method, url, headers, body, etc. */ - async handlePopState(event) { - if (event.state && event.state.xPartial) { - const url = window.location.href; - try { - const responseText = await this.performRequest('GET', url, {}, null); + extractRequestParams(element) { + const method = this.getMethod(element); + let url = element.getAttribute(`x-${method.toLowerCase()}`); - // Parse the response HTML - const parser = new DOMParser(); - const doc = parser.parseFromString(responseText, 'text/html'); + const headers = this.getHeaders(element); - // Replace the body content - document.body.innerHTML = doc.body.innerHTML; + let targetSelector = element.getAttribute(this.ATTRIBUTES.TARGET); + if (!targetSelector) { + targetSelector = 'body'; + } - // Re-scan the entire document - this.scanForElements(); + const targetElement = document.querySelector(targetSelector); + const partialId = targetElement ? targetElement.getAttribute('id') : null; - // Optionally, focus the body - if (this.autoFocus) { - document.body.focus(); - } - - } catch (error) { - console.error('PopState request failed:', error); - if (typeof this.onError === 'function') { - this.onError(error, document.body); + // Handle x-params for GET requests + if (method === 'GET') { + const xParams = element.getAttribute(this.ATTRIBUTES.PARAMS); + if (xParams) { + try { + const paramsObject = JSON.parse(xParams); + const urlParams = new URLSearchParams(paramsObject).toString(); + url += (url.includes('?') ? '&' : '?') + urlParams; + } catch (e) { + console.error('Invalid JSON in x-params attribute:', e); + const error = new Error('Invalid JSON in x-params attribute'); + if (typeof this.onError === 'function') { + this.onError(error, element); + } } } } - } - /** - * Debounce function to limit the rate at which a function can fire. - * @param {Function} func - The function to debounce. - * @param {number} wait - The number of milliseconds to wait. - * @returns {Function} - */ - debounce(func, wait) { - let timeout; - return (...args) => { - const later = () => { - clearTimeout(timeout); - func.apply(this, args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; + return { method, url, headers, targetSelector, partialId }; } /** @@ -320,7 +380,7 @@ class Partial { * @returns {string} HTTP method */ getMethod(element) { - for (const attr of this.actionAttributes) { + for (const attr of Object.values(this.ATTRIBUTES.ACTIONS)) { if (element.hasAttribute(attr)) { return attr.replace('x-', '').toUpperCase(); } @@ -347,7 +407,7 @@ class Partial { // Collect all x-* attributes that are not actionAttributes for (const attr of element.attributes) { const name = attr.name; - if (name.startsWith('x-') && !this.actionAttributes.includes(name)) { + if (name.startsWith('x-') && !Object.values(this.ATTRIBUTES.ACTIONS).includes(name)) { const headerName = 'X-' + this.capitalize(name.substring(2)); // Remove 'x-' prefix and capitalize headers[headerName] = attr.value; } @@ -367,13 +427,11 @@ class Partial { /** * Performs the HTTP request using Fetch API. - * @param {string} method - * @param {string} url - * @param {Object} headers - * @param {HTMLElement|null} element + * @param {Object} requestParams - Parameters including method, url, headers, body, etc. * @returns {Promise} Response text */ - performRequest(method, url, headers, element) { + performRequest(requestParams) { + const { method, url, headers, element } = requestParams; const options = { method, headers, @@ -385,11 +443,11 @@ class Partial { let body = null; // Check if the element or the closest form has x-serialize="json" - const serializeAsJson = element && (element.getAttribute('x-serialize') === 'json' || - (element.closest('form') && element.closest('form').getAttribute('x-serialize') === 'json')); + const serializeAsJson = element && (element.getAttribute(this.ATTRIBUTES.SERIALIZE) === 'json' || + (element.closest('form') && element.closest('form').getAttribute(this.ATTRIBUTES.SERIALIZE) === 'json')); // Check for x-json attribute - const xJson = element && element.getAttribute('x-json'); + const xJson = element && element.getAttribute(this.ATTRIBUTES.JSON); if (xJson) { // Parse x-json attribute @@ -444,6 +502,58 @@ class Partial { }); } + /** + * Processes the response text and updates the DOM accordingly. + * @param {string} responseText + * @param {HTMLElement} targetElement + * @param {HTMLElement} element + */ + async processResponse(responseText, targetElement, element) { + // Dispatch beforeUpdate event + this.dispatchEvent('beforeUpdate', { targetElement, element }); + + // Parse the response HTML + const parser = new DOMParser(); + const doc = parser.parseFromString(responseText, 'text/html'); + + // Extract OOB elements + const oobElements = Array.from(doc.querySelectorAll(`[${this.ATTRIBUTES.SWAP_OOB}]`)); + oobElements.forEach(el => el.parentNode.removeChild(el)); + + // Replace the target's content + this.updateTargetElement(targetElement, doc); + + // Dispatch afterUpdate event + this.dispatchEvent('afterUpdate', { targetElement, element }); + + // Re-scan the newly added content for Partial elements + this.scanForElements(targetElement); + + // Handle OOB swapping with the extracted OOB elements + this.handleOobSwapping(oobElements); + + // Handle any x-event-* headers from the response + await this.handleResponseEvents(); + + // Auto-focus if enabled + const focusEnabled = element.getAttribute(this.ATTRIBUTES.FOCUS) !== 'false'; + if (this.autoFocus && focusEnabled) { + if (targetElement.getAttribute('tabindex') === null) { + targetElement.setAttribute('tabindex', '-1'); + } + targetElement.focus(); + } + } + + /** + * Updates the target element with new content. + * @param {HTMLElement} targetElement + * @param {Document} doc + */ + updateTargetElement(targetElement, doc) { + targetElement.innerHTML = doc.body.innerHTML; + } + /** * Handles Out-of-Band (OOB) swapping by processing an array of OOB elements. * Replaces existing elements in the document with the new content based on matching IDs. @@ -457,7 +567,7 @@ class Partial { return; } - const swapOption = oobElement.getAttribute('x-swap-oob') || this.defaultSwapOption; + const swapOption = oobElement.getAttribute(this.ATTRIBUTES.SWAP_OOB) || this.defaultSwapOption; const existingElement = document.getElementById(targetId); if (!existingElement) { @@ -504,6 +614,71 @@ class Partial { }); } + /** + * Dispatches custom events specified in a comma-separated string. + * @param {string} events - Comma-separated event names. + * @param {Object} detail - Detail object to pass with the event. + */ + async dispatchCustomEvents(events, detail) { + const eventNames = events.split(',').map(e => e.trim()); + for (const eventName of eventNames) { + const event = new CustomEvent(eventName, { detail }); + this.eventTarget.dispatchEvent(event); + } + } + + /** + * Handles the popstate event for browser navigation. + * @param {PopStateEvent} event + */ + async handlePopState(event) { + if (event.state && event.state.xPartial) { + const url = window.location.href; + try { + const responseText = await this.performRequest({ method: 'GET', url, headers: {}, element: null }); + + // Parse the response HTML + const parser = new DOMParser(); + const doc = parser.parseFromString(responseText, 'text/html'); + + // Replace the body content + document.body.innerHTML = doc.body.innerHTML; + + // Re-scan the entire document + this.scanForElements(); + + // Optionally, focus the body + if (this.autoFocus) { + document.body.focus(); + } + + } catch (error) { + console.error('PopState request failed:', error); + if (typeof this.onError === 'function') { + this.onError(error, document.body); + } + } + } + } + + /** + * Debounce function to limit the rate at which a function can fire. + * @param {Function} func - The function to debounce. + * @param {number} wait - The number of milliseconds to wait. + * @returns {Function} + */ + debounce(func, wait) { + let timeout; + return (...args) => { + const later = () => { + clearTimeout(timeout); + func.apply(this, args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + /** * Listens for a custom event and executes the callback when the event is dispatched. * @param {string} eventName - The name of the event to listen for @@ -574,4 +749,17 @@ class Partial { refresh(container = document) { this.scanForElements(container); } + + /** + * Clean up SSE connections when elements are removed. + * @param {HTMLElement} element + */ + cleanupSSEElement(element) { + if (this.sseConnections.has(element)) { + const eventSource = this.sseConnections.get(element); + eventSource.close(); + this.sseConnections.delete(element); + element.__xSSEInitialized = false; + } + } } From 7c1cc1eff0c1be3f379f35cc018239107c25e8a8 Mon Sep 17 00:00:00 2001 From: Sebastiano Bellinzis Date: Mon, 25 Nov 2024 18:33:53 +0100 Subject: [PATCH 2/3] javascript the fun way --- js/README.md | 160 +++++++++++++++++++---- js/partial.js | 354 ++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 418 insertions(+), 96 deletions(-) diff --git a/js/README.md b/js/README.md index f929e0c..1e16f9b 100644 --- a/js/README.md +++ b/js/README.md @@ -24,31 +24,141 @@ This will automatically scan the document for elements with x-* attributes and s ## Attributes partial.js leverages custom HTML attributes to define actions and behaviors: -- Action Attributes: - - **x-get**: Defines a GET request. - - **x-post**: Defines a POST request. - - **x-put**: Defines a PUT request. - - **x-delete**: Defines a DELETE request. -- Targeting: - - **x-target**: Specifies the selector of the element where the response content will be injected. -- Trigger Events: - - **x-trigger**: Defines the event that triggers the action (e.g., click, submit). Defaults to click for most elements and submit for forms. -- Serialization: - - **x-serialize**: When set to json, form data will be serialized as JSON. -- Custom Headers and Data: - - **x-json**: Provides a JSON string to be sent as the request body. - - **x-params**: Provides JSON parameters to be appended to the URL for GET requests. -- Out-of-Band Swapping: - - **x-swap-oob**: Indicates elements that should be swapped out-of-band (e.g., updating elements outside the main content area). -- Browser History: - - **x-push-state**: When set to 'false', disables updating the browser history. Defaults to updating history. -- Focus Management: - - **x-focus**: When set to 'false', disables auto-focus on the target element after content update. -- Debouncing: - - **x-debounce**: Specifies the debounce time in milliseconds for the event handler. -- Before and after hooks: - - **x-before**: Specifies an event to be triggered before the request is sent. - - **x-after**: Specifies an event to be triggered after the response is received. +### Action Attributes +Define the HTTP method and URL for the request. + +- `x-get`: Defines a GET request. + - Usage: `` +- `x-post`: Defines a POST request. + - Usage: `...` +- `x-put`: Defines a PUT request. +- `x-delete`: Defines a DELETE request. +### Targeting: +Specify where the response content should be injected. + +- `x-target`: Specifies the CSS selector of the element where the response content will be injected. + - Usage: `` + +### Trigger Events +Define the event that triggers the action. + +- `x-trigger`: Specifies the event that triggers the action (e.g., `click`, `submit`, `input`). + - Defaults to `click` for most elements and `submit` for forms. + - Usage: `` + +### Serialization +Control how form data is serialized in the request. + +- `x-serialize`: When set to `json`, `nested-json` or `xml`, form data will be serialized to the selected format. + - Usage: `
...
` + +### Custom Request Data +Provide custom data to include in the request. + +- `x-json`: Provides a JSON string to be sent as the request body. + - Usage: `` +- `x-params`: Provides JSON parameters to be appended to the URL for GET requests. + - Usage: `` + +### Out-of-Band Swapping +Update elements outside the main content area. + +- `x-swap-oob`: Indicates elements that should be swapped out-of-band. + - When included in a response, elements with x-swap-oob will replace elements with the same id in the current document. + - Usage: In the server response: `
New Notification
` + +### Browser History Management +Control how the browser history is updated. + +- `x-push-state`: When set to 'false', disables updating the browser history. Defaults to updating history. + - Usage: `` + +### Focus Management +Control focus behavior after content updates. + +- `x-focus`: When set to 'true', enables auto-focus on the target element after content update. + - Usage: `` + +### Debouncing +Limit how frequently an event handler can fire. + +- `x-debounce`: Specifies the debounce time in milliseconds for the event handler. + - Usage: `` + +### Before and After Hooks +Trigger custom events before and after the request. + +- `x-before`: Specifies one or more events (comma-separated) to be dispatched before the request is sent. + - Usage: `` +- `x-after`: Specifies one or more events (comma-separated) to be dispatched after the response is received. + - Usage: `` + +### Server-Sent Events (SSE) +Establish a connection to receive real-time updates from the server. + +- `x-sse`: Specifies a URL to establish a Server-Sent Events (SSE) connection. + - The element will handle incoming SSE messages from the specified URL. + - Usage: `
` + +### Loading Indicators +Display an indicator during the request. + +- `x-indicator`: Specifies a selector for an element to show before the request is sent and hide after the response is received. + - Useful for displaying a loading spinner or message. + - Usage: + ```html + + + ``` + +### Confirmation Prompt +Prompt the user for confirmation before proceeding. + +- `x-confirm`: Specifies a confirmation message to display before proceeding with the action. + - If the user cancels, the action is aborted. + - Usage: `` + +### Request Timeout +Set a maximum time to wait for a response. + +- `x-timeout`: Specifies a timeout in milliseconds for the request. + - If the request does not complete within this time, it will be aborted. + - Usage: `` + +### Request Retries +Automatically retry failed requests. + +- `x-retry`: Specifies the number of times to retry the request if it fails. + - Usage: `` + +### Custom Error Handling +Define custom behavior when an error occurs. + +- `x-on-error`: Specifies the name of a global function to call if an error occurs during the request. + - Usage: + ```javascript + + + ``` + + +### Loading Classes +Apply CSS classes to elements during the request. +- `x-loading-class`: Specifies a CSS class to add to the target element during the request. The class is removed after the request completes. + - Useful for adding styles like opacity changes or loading animations. + - Usage: + ```html + + + ``` ## Configuration Options When instantiating partial.js, you can provide a configuration object to customize its behavior: diff --git a/js/partial.js b/js/partial.js index 8ddfaa6..ad33a15 100644 --- a/js/partial.js +++ b/js/partial.js @@ -37,42 +37,54 @@ class Partial { PUT: 'x-put', DELETE: 'x-delete', }, - TARGET: 'x-target', - TRIGGER: 'x-trigger', - SERIALIZE: 'x-serialize', - JSON: 'x-json', - PARAMS: 'x-params', - SWAP_OOB: 'x-swap-oob', - PUSH_STATE: 'x-push-state', - FOCUS: 'x-focus', - DEBOUNCE: 'x-debounce', - BEFORE: 'x-before', - AFTER: 'x-after', - SSE: 'x-sse', + TARGET: 'x-target', + TRIGGER: 'x-trigger', + SERIALIZE: 'x-serialize', + JSON: 'x-json', + PARAMS: 'x-params', + SWAP_OOB: 'x-swap-oob', + PUSH_STATE: 'x-push-state', + FOCUS: 'x-focus', + DEBOUNCE: 'x-debounce', + BEFORE: 'x-before', + AFTER: 'x-after', + SSE: 'x-sse', + INDICATOR: 'x-indicator', + CONFIRM: 'x-confirm', + TIMEOUT: 'x-timeout', + RETRY: 'x-retry', + ON_ERROR: 'x-on-error', + LOADING_CLASS: 'x-loading-class', + }; + + this.SERIALIZE_TYPES = { + JSON: 'json', + NESTED_JSON: 'nested-json', + XML: 'xml', }; // Store options with default values - this.onError = options.onError || null; - this.csrfToken = options.csrfToken || null; + this.onError = options.onError || null; + this.csrfToken = options.csrfToken || null; this.defaultSwapOption = options.defaultSwapOption || 'outerHTML'; - this.beforeRequest = options.beforeRequest || null; - this.afterResponse = options.afterResponse || null; - this.autoFocus = options.autoFocus !== undefined ? options.autoFocus : false; - this.debounceTime = options.debounceTime || 0; + this.beforeRequest = options.beforeRequest || null; + this.afterResponse = options.afterResponse || null; + this.autoFocus = options.autoFocus !== undefined ? options.autoFocus : false; + this.debounceTime = options.debounceTime || 0; - this.eventTarget = new EventTarget(); + this.eventTarget = new EventTarget(); this.eventListeners = {}; // Map to store SSE connections per element this.sseConnections = new Map(); // Bind methods to ensure correct 'this' context - this.scanForElements = this.scanForElements.bind(this); - this.setupElement = this.setupElement.bind(this); - this.setupSSEElement = this.setupSSEElement.bind(this); - this.handleAction = this.handleAction.bind(this); + this.scanForElements = this.scanForElements.bind(this); + this.setupElement = this.setupElement.bind(this); + this.setupSSEElement = this.setupSSEElement.bind(this); + this.handleAction = this.handleAction.bind(this); this.handleOobSwapping = this.handleOobSwapping.bind(this); - this.handlePopState = this.handlePopState.bind(this); + this.handlePopState = this.handlePopState.bind(this); // Initialize the handler on DOMContentLoaded document.addEventListener('DOMContentLoaded', () => this.scanForElements()); @@ -246,9 +258,16 @@ class Partial { * @param {HTMLElement} element */ async handleAction(event, element) { - const requestParams = this.extractRequestParams(element); + // Get confirmation message from x-confirm + const confirmMessage = element.getAttribute(this.ATTRIBUTES.CONFIRM); + if (confirmMessage) { + const confirmed = window.confirm(confirmMessage); + if (!confirmed) { + return; // Abort the action + } + } - // Ensure 'element' is included in the request parameters + const requestParams = this.extractRequestParams(element); requestParams.element = element; if (!requestParams.url) { @@ -282,7 +301,41 @@ class Partial { // Set the X-Target header to the request requestParams.headers["X-Target"] = requestParams.partialId; + // Get the indicator selector from x-indicator + const indicatorSelector = element.getAttribute(this.ATTRIBUTES.INDICATOR); + let indicatorElement = null; + if (indicatorSelector) { + indicatorElement = document.querySelector(indicatorSelector); + } + + // Get loading class from x-loading-class + const loadingClass = element.getAttribute(this.ATTRIBUTES.LOADING_CLASS); + + // Handle x-focus + const focusEnabled = element.getAttribute(this.ATTRIBUTES.FOCUS) !== 'false'; + + // Handle x-push-state + const shouldPushState = element.getAttribute(this.ATTRIBUTES.PUSH_STATE) !== 'false'; + + // Handle x-timeout + const timeoutValue = element.getAttribute(this.ATTRIBUTES.TIMEOUT); + const timeout = parseInt(timeoutValue, 10); + + // Handle x-retry + const retryValue = element.getAttribute(this.ATTRIBUTES.RETRY); + const maxRetries = parseInt(retryValue, 10) || 0; + try { + // Show the indicator before the request + if (indicatorElement) { + indicatorElement.style.display = ''; // Or apply a CSS class to show + } + + // Add loading class to target element + if (loadingClass && targetElement) { + targetElement.classList.add(loadingClass); + } + // Dispatch x-before event(s) if specified const beforeEvents = element.getAttribute(this.ATTRIBUTES.BEFORE); if (beforeEvents) { @@ -297,7 +350,12 @@ class Partial { // Dispatch beforeSend event this.dispatchEvent('beforeSend', { ...requestParams, element }); - const responseText = await this.performRequest(requestParams); + // Call performRequest with the correct parameters + const responseText = await this.performRequest({ + ...requestParams, + timeout, + maxRetries, + }); // After response hook if (typeof this.afterResponse === 'function') { @@ -311,7 +369,6 @@ class Partial { await this.processResponse(responseText, targetElement, element); // After successfully updating content - const shouldPushState = element.getAttribute(this.ATTRIBUTES.PUSH_STATE) !== 'false'; if (shouldPushState) { const newUrl = new URL(requestParams.url, window.location.origin); history.pushState({ xPartial: true }, '', newUrl); @@ -322,15 +379,36 @@ class Partial { if (afterEvents) { await this.dispatchCustomEvents(afterEvents, { element, event }); } - } catch (error) { - console.error('Request failed:', error); - if (typeof this.onError === 'function') { + // Auto-focus if enabled + if (this.autoFocus && focusEnabled) { + if (targetElement.getAttribute('tabindex') === null) { + targetElement.setAttribute('tabindex', '-1'); + } + targetElement.focus(); + } + + } catch (error) { + const onErrorAttr = element.getAttribute(this.ATTRIBUTES.ON_ERROR); + if (onErrorAttr && typeof window[onErrorAttr] === 'function') { + window[onErrorAttr](error, element); + } else if (typeof this.onError === 'function') { this.onError(error, element); } else { - // Optionally, handle error display in the UI + // Default error handling + console.error('Request failed:', error); targetElement.innerHTML = `
An error occurred: ${error.message}
`; } + } finally { + // Hide the indicator after the request completes or fails + if (indicatorElement) { + indicatorElement.style.display = 'none'; // Or remove the CSS class + } + + // Remove loading class from target element + if (loadingClass && targetElement) { + targetElement.classList.remove(loadingClass); + } } } @@ -430,21 +508,24 @@ class Partial { * @param {Object} requestParams - Parameters including method, url, headers, body, etc. * @returns {Promise} Response text */ - performRequest(requestParams) { - const { method, url, headers, element } = requestParams; + async performRequest(requestParams) { + const { method, url, headers, element, timeout, maxRetries } = requestParams; + + const controller = new AbortController(); const options = { method, headers, credentials: 'same-origin', + signal: controller.signal, }; // Handle request body if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { let body = null; - // Check if the element or the closest form has x-serialize="json" - const serializeAsJson = element && (element.getAttribute(this.ATTRIBUTES.SERIALIZE) === 'json' || - (element.closest('form') && element.closest('form').getAttribute(this.ATTRIBUTES.SERIALIZE) === 'json')); + // Check for x-serialize attribute + const serializeType = element && (element.getAttribute(this.ATTRIBUTES.SERIALIZE) || + (element.closest('form') && element.closest('form').getAttribute(this.ATTRIBUTES.SERIALIZE))); // Check for x-json attribute const xJson = element && element.getAttribute(this.ATTRIBUTES.JSON); @@ -460,24 +541,18 @@ class Partial { } } else if (element && (element.tagName === 'FORM' || element.closest('form'))) { const form = element.tagName === 'FORM' ? element : element.closest('form'); - if (serializeAsJson) { - // Serialize form data as JSON - const formData = new FormData(form); - const jsonObject = {}; - formData.forEach((value, key) => { - // Handle multiple values per key (e.g., checkboxes) - if (jsonObject[key]) { - if (Array.isArray(jsonObject[key])) { - jsonObject[key].push(value); - } else { - jsonObject[key] = [jsonObject[key], value]; - } - } else { - jsonObject[key] = value; - } - }); - body = JSON.stringify(jsonObject); + if (serializeType === this.SERIALIZE_TYPES.JSON) { + // Serialize form data as flat JSON + body = this.serializeFormToJson(form); + headers['Content-Type'] = 'application/json'; + } else if (serializeType === this.SERIALIZE_TYPES.NESTED_JSON) { + // Serialize form data as nested JSON + body = this.serializeFormToNestedJson(form); headers['Content-Type'] = 'application/json'; + } else if (serializeType === this.SERIALIZE_TYPES.XML) { + // Serialize form data as XML + body = this.serializeFormToXml(form); + headers['Content-Type'] = 'application/xml'; } else { // Use FormData body = new FormData(form); @@ -489,16 +564,162 @@ class Partial { } } - return fetch(url, options).then(response => { - // Store the response for event handling - this.lastResponse = response; + // Start the timeout if specified + let timeoutId; + if (!isNaN(timeout) && timeout > 0) { + timeoutId = setTimeout(() => { + controller.abort(); + }, timeout); + } + + let attempts = 0; + const maxAttempts = maxRetries + 1; + + while (attempts < maxAttempts) { + attempts++; + try { + const response = await fetch(url, options); + clearTimeout(timeoutId); - if (!response.ok) { - return response.text().then(text => { + this.lastResponse = response; + + if (!response.ok) { + const text = await response.text(); throw new Error(`HTTP error ${response.status}: ${text}`); - }); + } + return response.text(); + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Request timed out'); + } + + if (attempts >= maxAttempts) { + throw error; + } + // Optionally, implement a delay before retrying + } + } + } + + /** + * Serializes form data to a flat JSON string. + * @param {HTMLFormElement} form + * @returns {string} JSON string + */ + serializeFormToJson(form) { + const formData = new FormData(form); + const jsonObject = {}; + formData.forEach((value, key) => { + if (jsonObject[key]) { + if (Array.isArray(jsonObject[key])) { + jsonObject[key].push(value); + } else { + jsonObject[key] = [jsonObject[key], value]; + } + } else { + jsonObject[key] = value; + } + }); + return JSON.stringify(jsonObject); + } + + /** + * Serializes form data to a nested JSON string. + * @param {HTMLFormElement} form + * @returns {string} Nested JSON string + */ + serializeFormToNestedJson(form) { + const formData = new FormData(form); + const serializedData = {}; + + for (let [name, value] of formData) { + const inputElement = form.querySelector(`[name="${name}"]`); + const checkBoxCustom = form.querySelector(`[data-custom="true"]`); + const inputType = inputElement ? inputElement.type : null; + const inputStep = inputElement ? inputElement.step : null; + + // Check if the input type is number and convert the value if so + if (inputType === 'number') { + if (inputStep && inputStep !== "any" && Number(inputStep) % 1 === 0) { + value = parseInt(value, 10); + } else if (inputStep === "any") { + value = value.includes('.') ? parseFloat(value) : parseInt(value, 10); + } else { + value = parseFloat(value); + } + } + + // Check if the input type is checkbox and convert the value to boolean + if (inputType === 'checkbox' && !checkBoxCustom) { + value = inputElement.checked; // value will be true if checked, false otherwise + } + + // Check if the input type is select-one and has data-bool attribute + if (inputType === 'select-one' && inputElement.getAttribute('data-bool') === 'true') { + value = value === "true"; // Value will be true if selected, false otherwise + } + + // Attempt to parse JSON strings + try { + value = JSON.parse(value); + } catch (e) { + // If parsing fails, treat as a simple string + } + + const keys = name.split(/[.[\]]+/).filter(Boolean); // split by dot or bracket notation + let obj = serializedData; + + for (let i = 0; i < keys.length - 1; i++) { + if (!obj[keys[i]]) { + obj[keys[i]] = /^\d+$/.test(keys[i + 1]) ? [] : {}; // create an array if the next key is an index + } + obj = obj[keys[i]]; + } + + const lastKey = keys[keys.length - 1]; + if (lastKey in obj && Array.isArray(obj[lastKey])) { + obj[lastKey].push(value); // add to array if the key already exists + } else if (lastKey in obj) { + obj[lastKey] = [obj[lastKey], value]; + } else { + obj[lastKey] = value; // set value for key + } + } + + return JSON.stringify(serializedData); + } + + /** + * Serializes form data to an XML string. + * @param {HTMLFormElement} form + * @returns {string} XML string + */ + serializeFormToXml(form) { + const formData = new FormData(form); + let xmlString = '
'; + + formData.forEach((value, key) => { + xmlString += `<${key}>${this.escapeXml(value)}`; + }); + + xmlString += '
'; + return xmlString; + } + + /** + * Escapes XML special characters. + * @param {string} unsafe + * @returns {string} + */ + escapeXml(unsafe) { + return unsafe.replace(/[<>&'"]/g, function (c) { + switch (c) { + case '<': return '<'; + case '>': return '>'; + case '&': return '&'; + case '\'': return '''; + case '"': return '"'; } - return response.text(); }); } @@ -534,15 +755,6 @@ class Partial { // Handle any x-event-* headers from the response await this.handleResponseEvents(); - - // Auto-focus if enabled - const focusEnabled = element.getAttribute(this.ATTRIBUTES.FOCUS) !== 'false'; - if (this.autoFocus && focusEnabled) { - if (targetElement.getAttribute('tabindex') === null) { - targetElement.setAttribute('tabindex', '-1'); - } - targetElement.focus(); - } } /** @@ -762,4 +974,4 @@ class Partial { element.__xSSEInitialized = false; } } -} +} \ No newline at end of file From 74466558621fb8b721d3cb6ac05116bda42cc440 Mon Sep 17 00:00:00 2001 From: Sebastiano Bellinzis Date: Mon, 25 Nov 2024 19:35:03 +0100 Subject: [PATCH 3/3] additional fixes --- examples/form/main.go | 5 +- examples/form/templates/form.gohtml | 2 +- js/README.md | 10 ++- js/partial.js | 96 ++++++++++++++++++----------- 4 files changed, 73 insertions(+), 40 deletions(-) diff --git a/examples/form/main.go b/examples/form/main.go index 7c74561..e050382 100644 --- a/examples/form/main.go +++ b/examples/form/main.go @@ -16,8 +16,9 @@ type ( } FormData struct { - Username string `json:"username"` - Password string `json:"password"` + Username string `json:"username"` + Password string `json:"password"` + HiddenField string `json:"hiddenField"` } ) diff --git a/examples/form/templates/form.gohtml b/examples/form/templates/form.gohtml index a48f217..1a7a866 100644 --- a/examples/form/templates/form.gohtml +++ b/examples/form/templates/form.gohtml @@ -1,6 +1,6 @@
-
+ diff --git a/js/README.md b/js/README.md index 1e16f9b..5e859bf 100644 --- a/js/README.md +++ b/js/README.md @@ -57,8 +57,14 @@ Provide custom data to include in the request. - `x-json`: Provides a JSON string to be sent as the request body. - Usage: `` -- `x-params`: Provides JSON parameters to be appended to the URL for GET requests. - - Usage: `` +- `x-params`: Provides JSON parameters to be included in the request. + - Usage: + - With GET requests: Parameters are appended to the URL. + - With other methods: Parameters are merged into the request body. + - Example: + ```html + + ``` ### Out-of-Band Swapping Update elements outside the main content area. diff --git a/js/partial.js b/js/partial.js index ad33a15..7dab576 100644 --- a/js/partial.js +++ b/js/partial.js @@ -431,25 +431,22 @@ class Partial { const targetElement = document.querySelector(targetSelector); const partialId = targetElement ? targetElement.getAttribute('id') : null; - // Handle x-params for GET requests - if (method === 'GET') { - const xParams = element.getAttribute(this.ATTRIBUTES.PARAMS); - if (xParams) { - try { - const paramsObject = JSON.parse(xParams); - const urlParams = new URLSearchParams(paramsObject).toString(); - url += (url.includes('?') ? '&' : '?') + urlParams; - } catch (e) { - console.error('Invalid JSON in x-params attribute:', e); - const error = new Error('Invalid JSON in x-params attribute'); - if (typeof this.onError === 'function') { - this.onError(error, element); - } + const xParams = element.getAttribute(this.ATTRIBUTES.PARAMS); + let paramsObject = {}; + + if (xParams) { + try { + paramsObject = JSON.parse(xParams); + } catch (e) { + console.error('Invalid JSON in x-params attribute:', e); + const error = new Error('Invalid JSON in x-params attribute'); + if (typeof this.onError === 'function') { + this.onError(error, element); } } } - return { method, url, headers, targetSelector, partialId }; + return { method, url, headers, targetSelector, partialId, paramsObject }; } /** @@ -509,7 +506,8 @@ class Partial { * @returns {Promise} Response text */ async performRequest(requestParams) { - const { method, url, headers, element, timeout, maxRetries } = requestParams; + const { method, url, headers, element, timeout, maxRetries, paramsObject } = requestParams; + let requestUrl = url; const controller = new AbortController(); const options = { @@ -519,22 +517,23 @@ class Partial { signal: controller.signal, }; - // Handle request body - if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { - let body = null; + // Handle x-serialize attribute + const serializeType = element && ( + element.getAttribute(this.ATTRIBUTES.SERIALIZE) || + (element.closest('form') && element.closest('form').getAttribute(this.ATTRIBUTES.SERIALIZE)) + ); - // Check for x-serialize attribute - const serializeType = element && (element.getAttribute(this.ATTRIBUTES.SERIALIZE) || - (element.closest('form') && element.closest('form').getAttribute(this.ATTRIBUTES.SERIALIZE))); + // Check for x-json attribute + const xJson = element && element.getAttribute(this.ATTRIBUTES.JSON); - // Check for x-json attribute - const xJson = element && element.getAttribute(this.ATTRIBUTES.JSON); + // Handle request body + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + let bodyData = {}; if (xJson) { // Parse x-json attribute try { - body = JSON.stringify(JSON.parse(xJson)); - headers['Content-Type'] = 'application/json'; + bodyData = JSON.parse(xJson); } catch (e) { console.error('Invalid JSON in x-json attribute:', e); throw new Error('Invalid JSON in x-json attribute'); @@ -543,24 +542,51 @@ class Partial { const form = element.tagName === 'FORM' ? element : element.closest('form'); if (serializeType === this.SERIALIZE_TYPES.JSON) { // Serialize form data as flat JSON - body = this.serializeFormToJson(form); - headers['Content-Type'] = 'application/json'; + bodyData = JSON.parse(this.serializeFormToJson(form)); } else if (serializeType === this.SERIALIZE_TYPES.NESTED_JSON) { // Serialize form data as nested JSON - body = this.serializeFormToNestedJson(form); - headers['Content-Type'] = 'application/json'; + bodyData = JSON.parse(this.serializeFormToNestedJson(form)); } else if (serializeType === this.SERIALIZE_TYPES.XML) { // Serialize form data as XML - body = this.serializeFormToXml(form); + bodyData = this.serializeFormToXml(form); headers['Content-Type'] = 'application/xml'; } else { // Use FormData - body = new FormData(form); + bodyData = new FormData(form); } } - if (body) { - options.body = body; + // Merge paramsObject with bodyData + console.log(paramsObject) + if (paramsObject && Object.keys(paramsObject).length > 0) { + if (bodyData instanceof FormData) { + // Append params to FormData + for (const key in paramsObject) { + bodyData.append(key, paramsObject[key]); + } + } else if (typeof bodyData === 'string') { + // Parse existing bodyData and merge + bodyData = { ...JSON.parse(bodyData), ...paramsObject }; + } else { + // Merge objects + bodyData = { ...bodyData, ...paramsObject }; + } + } + + if (bodyData instanceof FormData) { + options.body = bodyData; + } else if (typeof bodyData === 'string') { + options.body = bodyData; + headers['Content-Type'] = headers['Content-Type'] || 'application/json'; + } else { + options.body = JSON.stringify(bodyData); + headers['Content-Type'] = headers['Content-Type'] || 'application/json'; + } + } else { + // For GET requests, append params to URL + if (paramsObject && Object.keys(paramsObject).length > 0) { + const urlParams = new URLSearchParams(paramsObject).toString(); + requestUrl += (requestUrl.includes('?') ? '&' : '?') + urlParams; } } @@ -578,7 +604,7 @@ class Partial { while (attempts < maxAttempts) { attempts++; try { - const response = await fetch(url, options); + const response = await fetch(requestUrl, options); clearTimeout(timeoutId); this.lastResponse = response;