commercetools-8.x-1.2-alpha1/modules/commercetools_content/js/ajaxify_blocks.js
modules/commercetools_content/js/ajaxify_blocks.js
(function (Drupal) {
// Global data if selector
const productIndexSelector = 'data-ct-product-index';
const dataBlockId = 'data-ct-block-id';
const systemBlockSelector = '[data-ct-is-system-block]';
const systemBlockForceUpdateSelector = 'data-ct-ajaxify-system-block';
const containerSelector = '[data-ct-ajaxify]';
const behaviors = {
attach(context) {
// Universal container selector and derived link selector.
const ajaxifySelector = `${containerSelector} a:not(.ct-ajaxify-ignore)`;
// Attach form handlers
this.attachFormHandlers(containerSelector, context);
// Attach link handlers
this.attachLinksHandler(ajaxifySelector, context);
},
/**
* Links handler attachment checks only suitable links
* @param {string} selector - Selector for links
* @param context - context
*/
attachLinksHandler(selector, context) {
if (document.__ctAjaxified) {
return;
}
document.__ctAjaxified = true;
document.addEventListener('click', (event) => {
if (event.target.tagName !== 'A') {
return;
}
const href = event.target.getAttribute('href');
if (!this.isUrlAjaxifiable(href)) return;
// Find the closest container
const container =
event.target.closest(`${containerSelector}`) || document;
this.handleClick(event.target, container);
event.preventDefault();
});
},
/**
* Determines if a link should be ajaxified.
* @param {string} href - The link URL.
* @return {boolean} True if the link should be ajaxified, false otherwise.
*/
isUrlAjaxifiable(href) {
const currentPath = this.getFullCurrentPath();
// Resolve relative paths (e.g., "?page=...") into absolute paths for comparison.
if (href.startsWith('?')) {
href = `${currentPath}${href}`;
}
// Extracts only the path parameter from the url without GET parameters.
const ajaxLinkPattern = /(\/[^?]*)(\?.*)?/;
const result = ajaxLinkPattern.exec(href);
if (result !== null && result.at(1) === currentPath) {
return true;
}
// Exclude links that do not meet criteria.
return false;
},
/**
* Gets full current path including baseUrl.
*/
getFullCurrentPath() {
return drupalSettings.path.baseUrl + drupalSettings.path.currentPath;
},
/**
* Performs the click action on an ajaxified link
* @param {HTMLElement} clickedElement - The clicked link
* @param {HTMLElement} context - element from context
*/
handleClick(clickedElement, context) {
let targetUrl = clickedElement.getAttribute('href');
// Add currentPath to relative links
if (targetUrl.startsWith('?')) {
targetUrl = this.getFullCurrentPath() + targetUrl;
}
const productIndex = this.getCtProductIndex(clickedElement);
// Collect block IDs for the current product index
const blockIds = this.getBlockIds(productIndex);
// Check if force update system block required.
if (context.hasAttribute(systemBlockForceUpdateSelector)) {
blockIds.push(this.getSystemMainBlockId());
}
// Build ajax query with targetUrl and blocks
this.updateList(targetUrl, blockIds);
// Update browser history and trigger AJAX
this.updateBrowserHistory(targetUrl);
},
/**
* Attach form handlers for filters and search
*/
attachFormHandlers(selector, context) {
const formSelector = `${selector} form`;
const forms = context.querySelectorAll(formSelector, context);
forms.forEach((form) => {
form.addEventListener('submit', (e) => {
e.preventDefault();
const container = form.closest(selector) || document;
this.handleFormSubmit([`${selector} form`], container);
});
});
},
/**
* Handle form submission
*/
handleFormSubmit(formSelectors, scope) {
const formsData = this.getFormsData(formSelectors, scope);
const filterParams = new URLSearchParams(formsData);
// todo re-think approach if there a few forms on the page.
const controlledKeys = this.getFormKeys(formSelectors[0]);
// Merge with current URL parameters, preserving all existing filters
const urlParams = this.mergeQueryParams(filterParams, controlledKeys);
// Get current product index and collect block IDs
const index = this.getCtProductIndex(scope);
const blockIds = this.getBlockIds(index);
// Check if force update system block required.
if (scope.hasAttribute(systemBlockForceUpdateSelector)) {
blockIds.push(this.getSystemMainBlockId());
}
// Construct the finalized URL
const currentPath = this.getFullCurrentPath();
const targetUrl = `${currentPath}?${urlParams.toString()}`;
this.updateBrowserHistory(targetUrl);
this.updateList(targetUrl, blockIds);
},
/**
* Get system main block ID by data attribute.
*/
getSystemMainBlockId() {
const el = document.querySelector(`${systemBlockSelector}`);
if (el) {
return el.getAttribute(dataBlockId);
}
},
/**
* Merge new query parameters into existing query parameters.
* If a parameter exists in both, the new value takes precedence.
* @param {URLSearchParams} newParams - New parameters to merge (e.g., from form inputs).
* @param controlledKeys
* @return {URLSearchParams} - The merged query parameters.
*/
mergeQueryParams(newParams, controlledKeys) {
const merged = new URLSearchParams(window.location.search);
const controlled = new Set(controlledKeys);
const keysToDelete = [];
// Identifies params to remove
merged.forEach((value, key) => {
const baseKey = key.replace(/\[\d+\]$/, '');
if (controlled.has(baseKey)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach((key) => merged.delete(key));
// Re-add params form url
newParams.forEach((value, key) => {
if (value !== '') {
merged.append(key, value);
}
});
// Clean up the pager parameter from the url to jump to the first page
// on the submit of the filter form.
merged.delete('page');
return merged;
},
/**
* Gets unique form elements by name.
* @param form Selector
* @return {Set<any>}
*/
getFormKeys(form) {
const names = new Set();
const ignoreFields = ['form_build_id', 'form_token', 'form_id', 'op'];
const elements = document.querySelectorAll(`${form} [name]`);
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!ignoreFields.includes(el.name)) {
const uniqueName = el.name.replace(/\[\d+]$/, '');
names.add(uniqueName);
}
}
return names;
},
/**
* Get product index.
* @param context
* @return {*}
*/
getCtProductIndex(context) {
const wrapperWithIndex = context.closest(`[${productIndexSelector}]`);
return wrapperWithIndex?.getAttribute(productIndexSelector) || 0;
},
/**
* Get block IDs for a given product index.
* If no index provided, finds the index from DOM context.
* @param {string} index - The data-ct-product-index value.
* @return {Array} Array of block ID strings.
*/
getBlockIds(index) {
// If no index provided, find it from DOM context
if (!index) {
const indexElement = document.querySelector(
`[${productIndexSelector}]`,
);
index = indexElement?.getAttribute(productIndexSelector) || '0';
}
return Array.from(
document.querySelectorAll(`[${productIndexSelector}="${index}"]`),
)
.filter((el) => el.hasAttribute(dataBlockId))
.map((el) => el.getAttribute(dataBlockId))
.filter(Boolean);
},
/**
* Get form data from multiple forms
*/
getFormsData(formSelectors, scope) {
const root = scope || document;
const forms = root.querySelectorAll(formSelectors.join(', '));
const formsData = new FormData();
const ignoreFields = ['form_build_id', 'form_token', 'form_id'];
forms.forEach((form) => {
const formData = new FormData(form);
formData.forEach((value, key) => {
if (value.trim() !== '' && !ignoreFields.includes(key)) {
formsData.set(key, value);
}
});
});
return formsData;
},
/**
* Update browser history
*/
updateBrowserHistory(targetUrl) {
const url = new URL(targetUrl, window.location.origin);
const path = url.pathname + url.search;
window.history.pushState({ path }, '', path);
},
/**
* Send AJAX request to update content
*/
updateList(url, blocks) {
const blocksQuery = encodeURIComponent(blocks.join(','));
// Encode the targetUrl
const encodedTargetUrl = encodeURIComponent(url);
const ajaxSettings = {
url: Drupal.url(
`api/commercetools/ajax?target_url=${encodedTargetUrl}&blocks=${blocksQuery}`,
),
progress: {
type: 'fullscreen',
message: Drupal.t('Processing...'),
},
};
const ajax = Drupal.ajax(ajaxSettings);
ajax.execute();
},
};
// Attach the behavior to Drupal
if (typeof Drupal !== 'undefined') {
Drupal.behaviors.commercetoolsAjaxifyBlocks = behaviors;
}
})(Drupal);
