From fe6b1d57e23ecdda48aac500d99a5721aa33dfa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20Ste=CC=A8pien=CC=81?= Date: Sat, 21 Oct 2023 02:36:27 +0200 Subject: [PATCH] Dynamic import for bootstrap components --- .../dynamic-bootstrap-components.js | 14 - .../dynamicImports/componentProxyFactory.js | 26 ++ .../useBootstrapComponentDynamicImport.js | 278 ++++++++---------- .../useDynamicImportEventsHandler.js | 65 ++++ .../dynamicImports/useFunctionCallstackMap.js | 136 +++++++++ .../utils/dynamicImports/useInstanceMap.js | 97 ++++++ templates/index.tpl | 18 +- 7 files changed, 461 insertions(+), 173 deletions(-) create mode 100644 _dev/js/theme/utils/dynamicImports/componentProxyFactory.js create mode 100644 _dev/js/theme/utils/dynamicImports/useDynamicImportEventsHandler.js create mode 100644 _dev/js/theme/utils/dynamicImports/useFunctionCallstackMap.js create mode 100644 _dev/js/theme/utils/dynamicImports/useInstanceMap.js diff --git a/_dev/js/theme/components/dynamic-bootstrap-components.js b/_dev/js/theme/components/dynamic-bootstrap-components.js index d91da502..71dba35e 100644 --- a/_dev/js/theme/components/dynamic-bootstrap-components.js +++ b/_dev/js/theme/components/dynamic-bootstrap-components.js @@ -2,8 +2,6 @@ import DOMReady from '../utils/DOMReady'; import useBootstrapComponentDynamicImport from '../utils/dynamicImports/useBootstrapComponentDynamicImport'; DOMReady(() => { - /* eslint no-unused-vars: ["error", { "varsIgnorePattern": "import" }] */ - const { init: initDynamicImportForModal } = useBootstrapComponentDynamicImport( () => [ import('bootstrap/js/src/modal'), @@ -22,18 +20,6 @@ DOMReady(() => { initDynamicImportForModal(); - // const modal = new bootstrap.Modal('#testModal', { - // keyboard: false, - // }); - // - // const handleTestModal = () => { - // modal.toggle(); - // console.log('toggle'); - // setTimeout(handleTestModal, 4000); - // } - // - // handleTestModal(); - const { init: initDynamicImportForOffcanvas } = useBootstrapComponentDynamicImport( () => [ import('bootstrap/js/src/offcanvas'), diff --git a/_dev/js/theme/utils/dynamicImports/componentProxyFactory.js b/_dev/js/theme/utils/dynamicImports/componentProxyFactory.js new file mode 100644 index 00000000..4921ed41 --- /dev/null +++ b/_dev/js/theme/utils/dynamicImports/componentProxyFactory.js @@ -0,0 +1,26 @@ +const handleProxyGet = (element, proxyMethodHandler) => (target, prop, receiver) => { + if (target[prop] !== undefined) { + return target[prop]; + } + + return (...args) => proxyMethodHandler(target, prop, receiver, element, args); +}; + +const componentProxyFactory = ( + element, + options, + proxyMethodHandler = () => {}, +) => { + const pluginObject = { + _element: element, + _options: options, + }; + + const proxyHandler = { + get: handleProxyGet(element, proxyMethodHandler), + }; + + return new Proxy(pluginObject, proxyHandler); +}; + +export default componentProxyFactory; diff --git a/_dev/js/theme/utils/dynamicImports/useBootstrapComponentDynamicImport.js b/_dev/js/theme/utils/dynamicImports/useBootstrapComponentDynamicImport.js index 75984358..cac07bec 100644 --- a/_dev/js/theme/utils/dynamicImports/useBootstrapComponentDynamicImport.js +++ b/_dev/js/theme/utils/dynamicImports/useBootstrapComponentDynamicImport.js @@ -1,210 +1,188 @@ -import useEvent from '../../components/event/useEvent'; - -const { on, off } = useEvent(); +import useInstanceMap from './useInstanceMap'; +import useDynamicImportEventsHandler from './useDynamicImportEventsHandler'; +import useFunctionCallstackMap from './useFunctionCallstackMap'; +import componentProxyFactory from './componentProxyFactory'; + +const isJQueryEnabled = () => { + try { + return !!window.jQuery; + } catch (e) { + return false; + } +}; const useBootstrapComponentDynamicImport = (importFiles, { - componentName = '', + componentName, events = [], - onLoad = () => {}, }) => { - let filesLoaded = false; - const callStack = []; - const jQueryCallStack = []; - const instancesMap = new Map(); - - const setInstanceInMap = (element, instance) => { - if (!instancesMap.has(element)) { - instancesMap.set(element, instance); - } - }; - - const getInstanceFromMap = (element) => { - if (!instancesMap.has(element)) { - return null; - } - - return instancesMap.get(element); - }; - if (!componentName) { throw new Error('Component name is required'); } - const getJQueryComponentName = () => componentName.charAt(0).toLowerCase() + componentName.slice(1); - - const isJQueryEnabled = () => { - try { - return !!window.jQuery; - } catch (e) { - return false; + let filesLoaded = false; + let filesLoading = false; + const { + getCallbacksForElement, + setCallbackForElement, + removeCallbacksForElement, + createCallbackObject, + } = useFunctionCallstackMap(componentName); + const { + setCallbackForElement: jQuerySetCallbackForElement, + removeCallbacksForElement: jQueryRemoveCallbacksForElement, + createCallbackObject: jQueryCreateCallbackObject, + getAllCallbacksForComponent: jQueryGetAllCallbacksForComponent, + } = useFunctionCallstackMap(`jQuery_${componentName}`); + const { + getInstanceFromMap, + setInstanceInMap, + removeInstanceFromMap, + getAllInstancesForComponent, + } = useInstanceMap(componentName); + + const getJQueryComponentName = () => componentName.toLowerCase(); + + const loadFiles = () => Promise.all(importFiles()).then((files) => { + for (let i = 0; i < files.length; i += 1) { + const file = files[i]; + + if (file.default) { + window.bootstrap[componentName] = file.default; + break; + } } - }; - const loadFiles = () => { filesLoaded = true; + filesLoading = false; + }); - return Promise.all(importFiles()).then((files) => { - files.forEach((file) => { - if (file.default) { - window.bootstrap[componentName] = file.default; - } - }); - }); - }; + const executeComponentInitializationAndCallbackCalls = () => { + const instances = getAllInstancesForComponent(); - const executeCallStack = () => { - callStack.forEach(({ args, instanceMethodCall, componentInstance }, i) => { - componentInstance = new window.bootstrap[componentName](args); + if (instances) { + instances.forEach((proxy, element) => { + const { + _options: options, + } = proxy; - callStack[i].componentInstance = componentInstance; + const instance = new window.bootstrap[componentName](element, options); + setInstanceInMap(element, instance); - setInstanceInMap(componentInstance._element, componentInstance); + const callbacks = getCallbacksForElement(element); - instanceMethodCall.forEach(({ prop, methodArgs }) => { - componentInstance[prop](...methodArgs); - }); - }); + if (callbacks) { + callbacks.forEach((callbackObject) => { + const { + prop, + args, + } = callbackObject; - if (isJQueryEnabled()) { - jQueryCallStack.forEach(({ elem, args }) => { - window.jQuery(elem)[getJQueryComponentName()](args); + instance[prop](...args); + }); + + removeCallbacksForElement(element); + } }); } - }; - const handleEvent = async (e) => { - e.preventDefault(); - - // DISABLE FOR NOW BEFORE REFACTORING - /* eslint-disable */ - await handleComponentLoad(); - /* eslint-enable */ - - const { currentTarget, type } = e; - - currentTarget.dispatchEvent(new Event(type)); - }; + if (isJQueryEnabled()) { + const allCallbacks = jQueryGetAllCallbacksForComponent(); - const unbindEvents = () => { - events.forEach(({ - name = '', - selector = '', - }) => { - if (!name || !selector) { - throw new Error('Event name and selector are required'); - } + if (allCallbacks) { + allCallbacks.forEach((callbacks, jqueryObject) => { + callbacks.forEach((callbackObject) => { + const { + prop, + args, + } = callbackObject; - off( - document, - name, - selector, - handleEvent, - ); - }); - }; + jqueryObject[prop](...args); + }); - const bindEvents = () => { - events.forEach(({ - name = '', - selector = '', - }) => { - if (!name || !selector) { - throw new Error('Event name and selector are required'); + jQueryRemoveCallbacksForElement(jqueryObject); + }); } - - on( - document, - name, - selector, - handleEvent, - ); - }); + } }; const handleComponentLoad = async () => { - if (filesLoaded) { + if (filesLoaded || filesLoading) { return; } + filesLoading = true; + + // eslint-disable-next-line no-use-before-define unbindEvents(); await loadFiles(); - onLoad(); - executeCallStack(); + executeComponentInitializationAndCallbackCalls(); }; - const proxyFactory = (pluginInstance) => { - const pluginObject = {}; - const proxyHandler = { - get(target, prop, receiver) { - return (...args) => { - if (!filesLoaded) { - pluginInstance.instanceMethodCall.push({ - prop, - args, - }); - - handleComponentLoad(); - } + const handleEvent = async (e) => { + e.preventDefault(); - if (pluginInstance.componentInstance !== null) { - pluginInstance.componentInstance[prop](...args); - } + await handleComponentLoad(); - return receiver; - }; - }, - }; + const { currentTarget, type } = e; - return new Proxy(pluginObject, proxyHandler); + currentTarget.dispatchEvent(new Event(type)); }; - const getComponentInstance = (element) => getInstanceFromMap(element); - - function ComponentObjectConstructorFunction(args) { - const pluginInstance = { - args, - instanceMethodCall: [], - componentInstance: null, - }; + const { bindEvents, unbindEvents } = useDynamicImportEventsHandler(events, handleEvent); - pluginInstance.proxyInstance = proxyFactory(pluginInstance); + const getComponentInstance = (element) => getInstanceFromMap(element); - callStack.push(pluginInstance); + const proxyMethodCallHandler = (target, prop, receiver, element, args) => { + const instance = getInstanceFromMap(element); - return pluginInstance.proxyInstance; - } + if (!filesLoaded) { + setCallbackForElement(element, createCallbackObject(prop, args)); - const getOrCreateInstance = (element) => { - const pluginInstance = getComponentInstance(element); + handleComponentLoad(); + } else { + if (instance !== null) { + instance[prop](...args); + } - if (pluginInstance) { - return pluginInstance.proxyInstance; + if (prop === 'dispose' && instance !== null) { + removeInstanceFromMap(element); + } } - const proxyInstance = new ComponentObjectConstructorFunction(element); + return receiver; + }; - setInstanceInMap(element, proxyInstance); + function ComponentObjectConstructorFunction(selectorOrElement, options = {}) { + const element = selectorOrElement instanceof Element ? selectorOrElement : document.querySelector(selectorOrElement); + const componentInstanceProxy = componentProxyFactory(element, options, proxyMethodCallHandler); - return proxyInstance; - }; + setInstanceInMap(element, componentInstanceProxy); - ComponentObjectConstructorFunction.getOrCreateInstance = getOrCreateInstance; - ComponentObjectConstructorFunction.getInstance = getComponentInstance; + return componentInstanceProxy; + } - const handleJQueryPluginCall = (args) => { - jQueryCallStack.push({ - elem: this, - args, - }); + ComponentObjectConstructorFunction.getOrCreateInstance = (element, options) => { + const componentInstance = getComponentInstance(element); + + if (componentInstance) { + return componentInstance; + } - handleComponentLoad(); + return new ComponentObjectConstructorFunction(element, options); }; + ComponentObjectConstructorFunction.getInstance = getComponentInstance; + window.bootstrap = window.bootstrap || {}; window.bootstrap[componentName] = ComponentObjectConstructorFunction; + window.bootstrap[componentName].NAME = componentName.toLowerCase(); if (isJQueryEnabled()) { - window.jQuery.fn[getJQueryComponentName()] = handleJQueryPluginCall; + window.jQuery.fn[getJQueryComponentName()] = function jqueryFunctionCallback(...args) { + jQuerySetCallbackForElement(this, jQueryCreateCallbackObject(getJQueryComponentName(), args)); + + handleComponentLoad(); + }; } const init = () => { diff --git a/_dev/js/theme/utils/dynamicImports/useDynamicImportEventsHandler.js b/_dev/js/theme/utils/dynamicImports/useDynamicImportEventsHandler.js new file mode 100644 index 00000000..85d757d7 --- /dev/null +++ b/_dev/js/theme/utils/dynamicImports/useDynamicImportEventsHandler.js @@ -0,0 +1,65 @@ +import useEvent from '../../components/event/useEvent'; + +const { on, off } = useEvent(); + +/** + * Use dynamic import events handler + * @param {array} events - Array of events + * @param {function} handler - Event handler + * @return {{ + * bindEvents: function(): (void), + * unbindEvents: (function(): (void)), + * }} + */ +const useDynamicImportEventsHandler = (events, handler) => { + /** + * Bind events + * @return {void} + */ + const bindEvents = () => { + events.forEach(({ + name = '', + selector = '', + }) => { + if (!name || !selector) { + throw new Error('Event name and selector are required'); + } + + on( + document, + name, + selector, + handler, + ); + }); + }; + + /** + * Unbind events + * @return {void} + */ + const unbindEvents = () => { + events.forEach(({ + name = '', + selector = '', + }) => { + if (!name || !selector) { + throw new Error('Event name and selector are required'); + } + + off( + document, + name, + selector, + handler, + ); + }); + }; + + return { + bindEvents, + unbindEvents, + }; +}; + +export default useDynamicImportEventsHandler; diff --git a/_dev/js/theme/utils/dynamicImports/useFunctionCallstackMap.js b/_dev/js/theme/utils/dynamicImports/useFunctionCallstackMap.js new file mode 100644 index 00000000..df40b20b --- /dev/null +++ b/_dev/js/theme/utils/dynamicImports/useFunctionCallstackMap.js @@ -0,0 +1,136 @@ +const callbackMap = new Map(); + +/** + * @module useFunctionCallstackMap + * @description create instance map + * @param {string} key - map key + * @return {{ + * getCallbacksForElement: (function(any): (null|array)), + * setCallbackForElement: (function(any, object): (void)), + * removeCallbacksForElement: (function(any): (void)), + * getAllCallbacksForComponent: (function(): (any|null)), + * createCallbackObject: (function(string, ...args): (CallbackObject)) + * }} + */ +const useFunctionCallstackMap = (key) => { + if (!key) { + throw new Error('Key is required'); + } + + /** + * @class CallbackObject + * @param prop + * @param args + * @constructor + */ + function CallbackObject(prop, args) { + this.prop = prop; + this.args = args; + } + + /** + * @method getCallbacksForElement + * @description get functions callback map for elementKey + * @param {any} elementKey - map elementKey + * @public + * @return {array|null} - functions callback map for key or null + */ + const getCallbacksForElement = (elementKey) => { + if (!callbackMap.has(key)) { + return null; + } + + const functionsCallMap = callbackMap.get(key); + + if (!functionsCallMap.has(elementKey)) { + return null; + } + + return functionsCallMap.get(elementKey); + }; + + /** + * @method setCallbackForElement + * @description set callback for elementKey + * @public + * @param {any} elementKey - component elementKey + * @param {CallbackObject} callback - callback object + * @throws {Error} - if callback is not instance of CallbackObject + * @return {void} + */ + const setCallbackForElement = (elementKey, callback) => { + if (!(callback instanceof CallbackObject)) { + throw new Error('Callback must be instance of CallbackObject, use createCallbackObject function to create it.'); + } + + if (!callbackMap.has(key)) { + callbackMap.set(key, new Map()); + } + + callbackMap.set(key, new Map()); + + const functionsCallMap = callbackMap.get(key); + const currentCallbacks = functionsCallMap.get(elementKey) || []; + const callbacks = [...currentCallbacks, callback]; + + functionsCallMap.set(elementKey, callbacks); + }; + + /** + * @method removeCallbacksForElement + * @description remove component instance from map + * @public + * @param {any} elementKey - component elementKey + */ + const removeCallbacksForElement = (elementKey) => { + if (!callbackMap.has(key)) { + return; + } + + const functionsCallMap = callbackMap.get(key); + + if (!functionsCallMap.has(elementKey)) { + return; + } + + functionsCallMap.delete(elementKey); + + if (functionsCallMap.size === 0) { + callbackMap.delete(key); + } + }; + + /** + * @method getAllCallbacksForComponent + * @description get all callbacks for component + * @public + * @return {any|null} + */ + const getAllCallbacksForComponent = () => { + if (!callbackMap.has(key)) { + return null; + } + + return callbackMap.get(key); + }; + + /** + * @method createCallbackObject + * @description create callback object + * @public + * @param prop {string} - callback function + * @param args {...args} - callback arguments + * @return {CallbackObject} - callback object + */ + const createCallbackObject = (prop, args) => new CallbackObject(prop, args); + + return { + getAllCallbacksForComponent, + getCallbacksForElement, + setCallbackForElement, + removeCallbacksForElement, + createCallbackObject, + }; +}; + +export default useFunctionCallstackMap; diff --git a/_dev/js/theme/utils/dynamicImports/useInstanceMap.js b/_dev/js/theme/utils/dynamicImports/useInstanceMap.js new file mode 100644 index 00000000..f295ccbf --- /dev/null +++ b/_dev/js/theme/utils/dynamicImports/useInstanceMap.js @@ -0,0 +1,97 @@ +const componentsInstancesMap = new Map(); + +/** + * @module useInstanceMap + * @description create instance map + * @param {string} key - map key + * @return {{ + * getInstanceFromMap: (function(HTMLElement): (null|object)), + * setInstanceInMap: (function(HTMLElement, object): (void)), + * removeInstanceFromMap: (function(HTMLElement): (void)) + * }} + */ +const useInstanceMap = (key) => { + if (!key) { + throw new Error('Key is required'); + } + + /** + * @method getInstanceFromMap + * @description get component instance from map + * @public + * @param {HTMLElement} element - component element + * @return {object|null} - component instance or null + */ + const getInstanceFromMap = (element) => { + if (!componentsInstancesMap.has(key)) { + return null; + } + + const instancesMap = componentsInstancesMap.get(key); + + if (!instancesMap.has(element)) { + return null; + } + + return instancesMap.get(element); + }; + + /** + * @method setInstanceInMap + * @description set component instance in map + * @public + * @param {HTMLElement} element - component element + * @param {object} instance - component instance + * @return {void} + */ + const setInstanceInMap = (element, instance) => { + if (!componentsInstancesMap.has(key)) { + componentsInstancesMap.set(key, new Map()); + } + + const instancesMap = componentsInstancesMap.get(key); + + instancesMap.set(element, instance); + }; + + /** + * @method removeInstanceFromMap + * @description remove component instance from map + * @public + * @param {HTMLElement} element - component element + */ + const removeInstanceFromMap = (element) => { + if (!componentsInstancesMap.has(key)) { + return; + } + + const instancesMap = componentsInstancesMap.get(key); + + if (!instancesMap.has(element)) { + return; + } + + instancesMap.delete(element); + + if (instancesMap.size === 0) { + componentsInstancesMap.delete(key); + } + }; + + const getAllInstancesForComponent = () => { + if (!componentsInstancesMap.has(key)) { + return null; + } + + return componentsInstancesMap.get(key); + }; + + return { + getInstanceFromMap, + setInstanceInMap, + removeInstanceFromMap, + getAllInstancesForComponent, + }; +}; + +export default useInstanceMap; diff --git a/templates/index.tpl b/templates/index.tpl index c17d03e9..82091b28 100644 --- a/templates/index.tpl +++ b/templates/index.tpl @@ -24,14 +24,14 @@ *} {extends file='page.tpl'} - {block name='page_content_container'} -
- {block name='page_content_top'}{/block} +{block name='page_content_container'} +
+ {block name='page_content_top'}{/block} - {block name='page_content'} - {block name='hook_home'} - {$HOOK_HOME nofilter} - {/block} - {/block} -
+ {block name='page_content'} + {block name='hook_home'} + {$HOOK_HOME nofilter} + {/block} {/block} +
+{/block}