diff --git a/CHANGELOG.md b/CHANGELOG.md index eba6b5c1e..8f05e0878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# v6.1.6-alpha1 + +- Add Health Check buttons to the JCB Compiler and Component list view to verify system PHP configuration. +- Add the Toolbar Custom Buttons subform to the Global Subform settings for improved usability and presentation. +- Fix the Field edit view to ensure full compliance with Joomla 6 standards and updated UI/UX conventions. +- Begin migration from UIkit to Bootstrap 5 to align JCB with Joomla 6+ standards. +- Complete Bootstrap 5 refactor of all primary views and core UI. +- Continue phased removal of UIkit with full transition planned for upcoming releases. +- Fix deprecated JText usage in Admin Custom Tabs area for Joomla 5+ compatibility. #1302 + # v6.1.5 - Fix variable name mismatch for organization in the Network Resolve class. #1291 @@ -125,138 +135,29 @@ - Update the SQL file dump generator to handle large SQL dumps in batches, ensuring safe and reliable database imports. - Update example hints in the field expert mode options. -# v5.1.4 +# v5.1.5 -- Fix the linked [new] button to use the correct init_default params. -- Fix the Components list view to better display long website urls. -- Fix language text area css. #1264 -- Refactor the JS selText method. -- Add fix for empty currentFullPath in the Structuresingle class. -- Fix the buildSelectPart method to not access an empty select value in the Queries class. -- Fix that ensure that we always load and int into loadUserById. -- Fix the subform permission behaviour to use removeField. -- Add version 6 to the version 5 update server, for easier upgrade path. -- Fixed linked admin views with New and New & Close buttons to correctly initialize fields using global unique IDs and default conventions. -- Resolved missing language strings when linked admin views are also configured as site edit views. -- Corrected misconfiguration of the isModal property for site-linked views to default to false since modals are not yet supported on the site area. -- Enhanced security in edit views by moving access validation into the model, ensuring protection even when Joomla bypasses the controller layer. -- Added user access verification logic in the model to prevent unauthorized direct item loading. -- Refactored the view class to introduce a class constructor that initializes key dependencies more reliably. -- Began aligning the view class structure with Joomla 6's updated MVC architecture for future compatibility. -- Improved initialization handling in the view to reduce dependency errors during rendering. -- Fixed a bug caused by missing option settings in the model configuration. -- Updated and refined the site view class for greater consistency with backend view logic. -- Continued progressive improvements across view classes to ensure compatibility with Joomla's evolving MVC patterns. -- Fix permission issue in the list view. #1266 -- Refactor the list view buttons. -- Refactor the getActions helper method. -- Rename Custom Buttons tab to Toolbar for clearer grouping of toolbar functions. #1268 -- Add options for overriding default toolbars in all Admin, Site, and Custom Admin Views. #1268 -- Refactored toolbar into version specific classes to resolve getToolbar errors. #1267 -- Added method to unify getDatabase handling across Joomla versions and prevent undefined method calls. #1270 -- Fix php8.3 deprecation warnings in compiler templates. #1265 -- Refactor and enhance the JCB Email Helper class for a more robust and maintainable design. -- Fix an issue where the Email Helper send() function was executed twice. -- Fix a issue where layouts added to the PHP Ajax Model were not loaded during compilation. -- Add new functionality to the toolbar override system, enabling the inclusion of custom buttons via placeholders. -- Fix the Compiler success message to display both build seconds and project weeks. -- Add Development Valuation Model to calculate JCB component pricing. -- Add functionality to push JCB packages to Self hosted Git system that uses a self signed SSL. -- Add functionality that will include Validation Rules linked to fields in JCB packages. #1273 -- Refactor and Improve Router Helper Class to new standard Site Router practice. -- Refactor and Improve JCB Search engine for better searching of JCB areas. -- Fix bug in the search editor in JCB Search Engine. -- Fix issue where Custom Gets linked to a Site view did not ship correctly with JCB packages. #1272 -- Fix issue where the Icon linked to a Custom Admin Menu did not ship with JCB packages. #1271 -- Fix and Improve Normalize Class to better handle different files Linked to JCB packages. #1274 -- Remove batch feature from JCB compiler Templates due to lack of use and outdated code. -- Refactor Utilities/Response classes for Joomla 6+ compatibility and improved version-agnostic behavior. -- Refactor the File Upload Manager to achieve improved maintainability, greater customization flexibility, and easier extensibility. -- Refactor the Item Importer to improve data management, structural clarity, and long-term maintainability of the import process. -- Add a Pull button to all main JCB entities, allowing a complete reset of a selected entity together with all entities linked to it from a selected remote repository. -- Add functionality to the compiler to automatically pull missing entities from a remote repository when they are referenced by a component but not present locally in JCB. -- Refactor the Packaging Engine into separate set and get classes to improve separation of concerns, readability, and overall maintainability. -- Refactor and restructure the demo console item import plugin to improve usability, maintainability, and reusability across other areas of JCB. -- Refactor the Compiler to remove deprecated code paths and legacy classes that are no longer maintained. -- Add new Compiler Classes to modernise ancient tech previously embedded in the compiler and replace it with a clean, modern architecture. -- Add a dedicated CLI plugin to the JCB core, enabling direct interaction with JCB through the Joomla CLI console. -- Add structured CLI commands and options to Init, Reset, Push, and Pull JCB entities via the Joomla CLI. -- Add full support for compiling Components directly via the CLI, enabling automated and headless build workflows. -- Add functionality to place Super Power classes into the component src folder for both Admin and Site areas, using the correct namespaced structure. -- Add functionality to the CLI compiler to directly install compiled components and apply compiler options used during compilation. -- Add functionality to the compiler to automatically pull a missing component from a remote repository when a compilation is triggered and the component is not locally available. -- Add functionality to add and replace classes in module and plugin src folders that are linked to a component, when using correct namespace resolution. -- Add a Documentation tab to the Joomla Component Builder dashboard. -- Fix an issue with the JCB version notice on Joomla 5. -- Fix an issue where components without a component image failed to compile successfully. #1282 -- Add functionality for importing translated language strings directly into the Language Translations area of JCB. -- Fix issue where the Language Translations export button was not displayed due to recent toolbar changes. -- Fix a bug that caused language filters to malfunction because of a broken field configuration. #1278 -- Fix issue where components without a layout added to the Component failed to compile correctly. #1132 +- Fix variable name mismatch for organization in the Network Resolve class. #1291 +- Fix issue where Admin Field relations using custom code did not automatically include multiple fields as expected. #1293 +- Fix issue where Admin Field relations did not correctly apply concatenation/glue options. #1294 +- Refactor Compiler functions to improve maintainability and align with modern PHP coding best practices. +- Refactor the JCB Dashboard, introducing a modernized layout, improved usability, and full responsiveness across desktop, tablet, and mobile devices. +- Fix deprecated warnings triggered in the JCB CLI. +- Fix resolve PHP syntax errors and PHP 8.4 deprecations across codebase. +- Fix four fatal parse/compile errors. +- Fix implicit nullable parameter deprecations. -# v4.1.3 +# v4.1.4 -- Fix the linked [new] button to use the correct init_default params. -- Fix the Components list view to better display long website urls. -- Fix language text area css. #1264 -- Refactor the JS selText method. -- Add fix for empty currentFullPath in the Structuresingle class. -- Fix the buildSelectPart method to not access an empty select value in the Queries class. -- Fix that ensure that we always load and int into loadUserById. -- Fix the subform permission behaviour to use removeField. -- Add version 5 to the version 4 update server, for easier upgrade path. -- Fixed linked admin views with New and New & Close buttons to correctly initialize fields using global unique IDs and default conventions. -- Resolved missing language strings when linked admin views are also configured as site edit views. -- Corrected misconfiguration of the isModal property for site-linked views to default to false since modals are not yet supported on the site area. -- Enhanced security in edit views by moving access validation into the model, ensuring protection even when Joomla bypasses the controller layer. -- Added user access verification logic in the model to prevent unauthorized direct item loading. -- Refactored the view class to introduce a class constructor that initializes key dependencies more reliably. -- Began aligning the view class structure with Joomla 6's updated MVC architecture for future compatibility. -- Improved initialization handling in the view to reduce dependency errors during rendering. -- Fixed a bug caused by missing option settings in the model configuration. -- Updated and refined the site view class for greater consistency with backend view logic. -- Continued progressive improvements across view classes to ensure compatibility with Joomla's evolving MVC patterns. -- Fix permission issue in the list view. #1266 -- Refactor the list view buttons -- Refactor the getActions helper method -- Refactor toolbar into version-specific classes to resolve getToolbar errors. #1267 -- Add method to unify getDatabase handling across Joomla versions and prevent undefined method calls. #1270 -- Refactor and enhance the JCB Email Helper class for a more robust and maintainable design. -- Fix an issue where the Email Helper send() function was executed twice. -- Fix a issue where layouts added to the PHP Ajax Model were not loaded during compilation. -- Fix the Compiler success message to display both build seconds and project weeks. -- Add Development Valuation Model to calculate JCB component pricing. -- Add functionality to push JCB packages to Self hosted Git system that uses a self signed SSL. -- Add functionality that will include Validation Rules linked to fields in JCB packages. #1273 -- Refactor and Improve Router Helper Class to new standard Site Router practice. -- Refactor and Improve JCB Search engine for better searching of JCB areas. -- Fix bug in the search editor in JCB Search Engine. -- Fix issue where Custom Gets linked to a Site view did not ship correctly with JCB packages. #1272 -- Fix issue where the Icon linked to a Custom Admin Menu did not ship with JCB packages. #1271 -- Fix and Improve Normalize Class to better handle different files Linked to JCB packages. #1274 -- Remove batch feature from JCB compiler Templates due to lack of use and outdated code. -- Refactor Utilities/Response classes for Joomla 6+ compatibility and improved version-agnostic behavior. -- Refactor the File Upload Manager to achieve improved maintainability, greater customization flexibility, and easier extensibility. -- Refactor the Item Importer to improve data management, structural clarity, and long-term maintainability of the import process. -- Add a Pull button to all main JCB entities, allowing a complete reset of a selected entity together with all entities linked to it. -- Add functionality to the Compiler to automatically pull missing entities from a remote repository when they are referenced by a component but not present locally in JCB. -- Refactor the Packaging Engine into separate set and get classes to improve separation of concerns, readability, and overall maintainability. -- Refactor and restructure the demo console item import plugin to improve usability, maintainability, and reusability across other areas of JCB. -- Refactor the Compiler to remove deprecated code paths and legacy classes that are no longer maintained. -- Add new Compiler Classes to modernise ancient tech previously embedded in the compiler and replace it with a clean, modern architecture. -- Add a dedicated CLI plugin to the JCB core, enabling direct interaction with JCB through the Joomla CLI console. -- Add structured CLI commands and options to Init, Reset, Push, and Pull JCB entities via the Joomla CLI. -- Add full support for compiling Components directly via the CLI, enabling automated and headless build workflows. -- Add functionality to place Super Power classes into the component src folder for both Admin and Site areas, using the correct namespaced structure. -- Add functionality to the CLI compiler to directly install compiled components and apply compiler options used during compilation. -- Add functionality to the compiler to automatically pull a missing component from a remote repository when a compilation is triggered and the component is not locally available. -- Add functionality to add and replace classes in module and plugin src folders that are linked to a component, when using correct namespace resolution. -- Add a Documentation tab to the Joomla Component Builder dashboard. -- Fix an issue with the JCB version notice on Joomla 5. -- Fix an issue where components without a component image failed to compile successfully. #1282 -- Fix issue where the Language Translations export button was not displayed due to recent toolbar changes. -- Fix a bug that caused language filters to malfunction because of a broken field configuration. #1278 -- Fix issue where components without a layout added to the Component failed to compile correctly. #1132 +- Fix variable name mismatch for organization in the Network Resolve class. #1291 +- Fix issue where Admin Field relations using custom code did not automatically include multiple fields as expected. #1293 +- Fix issue where Admin Field relations did not correctly apply concatenation/glue options. #1294 +- Refactor Compiler functions to improve maintainability and align with modern PHP coding best practices. +- Refactor the JCB Dashboard, introducing a modernized layout, improved usability, and full responsiveness across desktop, tablet, and mobile devices. +- Fix deprecated warnings triggered in the JCB CLI. +- Fix resolve PHP syntax errors and PHP 8.4 deprecations across codebase. +- Fix four fatal parse/compile errors. +- Fix implicit nullable parameter deprecations. # v3.2.5 diff --git a/ComponentbuilderInstallerScript.php b/ComponentbuilderInstallerScript.php index b64cf8c9b..bb6092333 100644 --- a/ComponentbuilderInstallerScript.php +++ b/ComponentbuilderInstallerScript.php @@ -1517,10 +1517,10 @@ class Com_ComponentbuilderInstallerScript implements InstallerScriptInterface // Fix the assets table rules column size. - $this->setDatabaseAssetsRulesFix(106880, "MEDIUMTEXT"); + $this->setDatabaseAssetsRulesFix(107200, "MEDIUMTEXT"); // Install the global extension params. $this->setExtensionsParams( - '{"autorName":"Llewellyn van der Merwe","autorEmail":"joomla@vdm.io","subform_layouts":{"subform_layouts0":{"view_field":"default","layout":"repeatablejcbjfive"}},"editor":"none","allow_selfsigned_certificates":"0","set_browser_storage":"1","storage_time_to_live":"global","builder_gif_size":"480-272","compiler_plugin":["componentbuilderactionlogcompiler","componentbuilderfieldorderingcompiler","componentbuilderheaderscompiler","componentbuilderpowersautoloadercompiler","componentbuilderprivacycompiler"],"add_menu_prefix":"1","menu_prefix":"»","namespace_prefix":"JCB","minify":"0","language":"en-GB","percentagelanguageadd":"30","assets_table_fix":"2","compiler_field_builder_type":"2","field_name_builder":"2","type_name_builder":"2","avg_seconds_per_folder":"3.0","avg_seconds_per_file":"5.0","avg_seconds_per_line":"9.5","avg_lines_per_page":"56","avg_hourly_rate_usd":"75.0","debugging_factor":"0.25","planning_factor":"0.14","mapping_factor":"0.10","office_factor":"0.16","license_seat_annual_cost":"3000.0","assumed_developer_count":"1","workdays_per_year":"240","assumed_subscription_per_month":"100","market_multiplier_low":"0.9","market_multiplier_medium":"1.2","market_multiplier_high":"2.0","market_multiplier_revolutionary":"4.0","profit_margin_factor":"0.6","blueprint_replication_potential":"50","blueprint_risk_factor":"0.2","export_company":"Vast Development Method","export_owner":"Llewellyn van der Merwe","export_email":"joomla@vdm.io","export_website":"https://dev.vdm.io/","export_license":"GNU/GPL Version 2 or later - http://www.gnu.org/licenses/gpl-2.0.html","export_copyright":"Copyright (C) 2015. All Rights Reserved","check_in":"-1 day","save_history":"1","history_limit":"10","add_jquery_framework":"1","uikit_load":"1","uikit_min":"","uikit_style":""}' + '{"autorName":"Llewellyn van der Merwe","autorEmail":"joomla@vdm.io","subform_layouts":{"subform_layouts0":{"view_field":"default","layout":"repeatablejcbjsix"}},"editor":"none","allow_selfsigned_certificates":"0","set_browser_storage":"1","storage_time_to_live":"global","builder_gif_size":"480-272","compiler_plugin":["componentbuilderactionlogcompiler","componentbuilderfieldorderingcompiler","componentbuilderheaderscompiler","componentbuilderpowersautoloadercompiler","componentbuilderprivacycompiler"],"add_menu_prefix":"1","menu_prefix":"»","namespace_prefix":"JCB","minify":"0","language":"en-GB","percentagelanguageadd":"30","assets_table_fix":"2","compiler_field_builder_type":"2","field_name_builder":"2","type_name_builder":"2","avg_seconds_per_folder":"3.0","avg_seconds_per_file":"5.0","avg_seconds_per_line":"9.5","avg_lines_per_page":"56","avg_hourly_rate_usd":"75.0","debugging_factor":"0.25","planning_factor":"0.14","mapping_factor":"0.10","office_factor":"0.16","license_seat_annual_cost":"3000.0","assumed_developer_count":"1","workdays_per_year":"240","assumed_subscription_per_month":"100","market_multiplier_low":"0.9","market_multiplier_medium":"1.2","market_multiplier_high":"2.0","market_multiplier_revolutionary":"4.0","profit_margin_factor":"0.6","blueprint_replication_potential":"50","blueprint_risk_factor":"0.2","export_company":"Vast Development Method","export_owner":"Llewellyn van der Merwe","export_email":"joomla@vdm.io","export_website":"https://dev.vdm.io/","export_license":"GNU/GPL Version 2 or later - http://www.gnu.org/licenses/gpl-2.0.html","export_copyright":"Copyright (C) 2015. All Rights Reserved","check_in":"-1 day","save_history":"1","history_limit":"10","add_jquery_framework":"1","uikit_load":"1","uikit_min":"","uikit_style":""}' ); @@ -3293,7 +3293,7 @@ class Com_ComponentbuilderInstallerScript implements InstallerScriptInterface echo '
-

Upgrade to Version 6.1.5 Was Successful! Let us know if anything is not working as expected.

'; +

Upgrade to Version 6.1.6-alpha1 Was Successful! Let us know if anything is not working as expected.

'; // Add/Update component in the action logs extensions table. $this->setActionLogsExtensions(); diff --git a/README.md b/README.md index b44842487..4fa94ef47 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This is a professional-grade [Joomla 6.x](https://extensions.joomla.org/extensio JCB generates native Joomla components, plugins, and modules for Joomla 3.x, 4.x, and 5.x - and is already prepared for Joomla 6. Every compiled project is tailored for the specific version without needing backward compatibility plugins. With integrated version-aware compiling, smart boilerplating, and Git-powered project syncing, JCB is much more than a code generator-it's a **full-stack development pipeline for Joomla extensions**. -You can install this component easily. The latest release (**6.1.5**) is available on [Releases](https://git.vdm.dev/joomla/pkg-component-builder/releases) and updated frequently with full source access. +You can install this component easily. The latest release (**6.1.6-alpha1**) is available on [Releases](https://git.vdm.dev/joomla/pkg-component-builder/releases) and updated frequently with full source access. Upgrades are seamless through Joomla's built-in extension update mechanism. @@ -196,9 +196,9 @@ JCB is developed by developers for developers. Its purpose is to democratize hig * **Company:** [Vast Development Method](https://dev.vdm.io) * **Author:** [Llewellyn van der Merwe](mailto:joomla@vdm.io) * **Component:** [Component Builder](https://git.vdm.dev/joomla/Component-Builder) -* **Created:** 30th April, 2015 · **Last Build:** 24th March, 2026 · **Version:** 6.1.5 +* **Created:** 30th April, 2015 · **Last Build:** 9th April, 2026 · **Version:** 6.1.6-alpha1 * **License:** GNU General Public License version 2 or later; see LICENSE.txt · **Copyright:** Copyright (C) 2015 Vast Development Method. All rights reserved. -* **Lines:** 1347496 · **Fields:** 2129 · **Files:** 8831 · **Folders:** 810 +* **Lines:** 1350169 · **Fields:** 2129 · **Files:** 8832 · **Folders:** 809 > Generated with [JCB](https://www.joomlacomponentbuilder.com) — The Smartest Way to Build Joomla Extensions. diff --git a/admin/README.txt b/admin/README.txt index b44842487..4fa94ef47 100644 --- a/admin/README.txt +++ b/admin/README.txt @@ -9,7 +9,7 @@ This is a professional-grade [Joomla 6.x](https://extensions.joomla.org/extensio JCB generates native Joomla components, plugins, and modules for Joomla 3.x, 4.x, and 5.x - and is already prepared for Joomla 6. Every compiled project is tailored for the specific version without needing backward compatibility plugins. With integrated version-aware compiling, smart boilerplating, and Git-powered project syncing, JCB is much more than a code generator-it's a **full-stack development pipeline for Joomla extensions**. -You can install this component easily. The latest release (**6.1.5**) is available on [Releases](https://git.vdm.dev/joomla/pkg-component-builder/releases) and updated frequently with full source access. +You can install this component easily. The latest release (**6.1.6-alpha1**) is available on [Releases](https://git.vdm.dev/joomla/pkg-component-builder/releases) and updated frequently with full source access. Upgrades are seamless through Joomla's built-in extension update mechanism. @@ -196,9 +196,9 @@ JCB is developed by developers for developers. Its purpose is to democratize hig * **Company:** [Vast Development Method](https://dev.vdm.io) * **Author:** [Llewellyn van der Merwe](mailto:joomla@vdm.io) * **Component:** [Component Builder](https://git.vdm.dev/joomla/Component-Builder) -* **Created:** 30th April, 2015 · **Last Build:** 24th March, 2026 · **Version:** 6.1.5 +* **Created:** 30th April, 2015 · **Last Build:** 9th April, 2026 · **Version:** 6.1.6-alpha1 * **License:** GNU General Public License version 2 or later; see LICENSE.txt · **Copyright:** Copyright (C) 2015 Vast Development Method. All rights reserved. -* **Lines:** 1347496 · **Fields:** 2129 · **Files:** 8831 · **Folders:** 810 +* **Lines:** 1350169 · **Fields:** 2129 · **Files:** 8832 · **Folders:** 809 > Generated with [JCB](https://www.joomlacomponentbuilder.com) — The Smartest Way to Build Joomla Extensions. diff --git a/admin/access.xml b/admin/access.xml index dd8eff7fc..facb37ba4 100644 --- a/admin/access.xml +++ b/admin/access.xml @@ -115,6 +115,7 @@ + @@ -330,6 +331,7 @@ + diff --git a/admin/assets/css/layout.css b/admin/assets/css/layout.css index c9fa7864a..c1cf583c2 100644 --- a/admin/assets/css/layout.css +++ b/admin/assets/css/layout.css @@ -23,6 +23,3 @@ width: 100% !important; .jform_dynamic_get_input_width { width: 90% !important; } -.uk-scrollable-box { -height: 380px !important; -} diff --git a/admin/assets/js/initialization_selection.js b/admin/assets/js/initialization_selection.js index 68cea2685..726a63ce7 100644 --- a/admin/assets/js/initialization_selection.js +++ b/admin/assets/js/initialization_selection.js @@ -10,77 +10,282 @@ /* JS Document */ -const memoryinitialization = []; +const memoryinitialization = {}; -function setSessionMemory(key, values, merge = true) { - if (merge) { - values = mergeSessionMemory(key, values); - } else { - values = JSON.stringify(values); +/** + * Check whether a value looks like a plain object. + * + * @param {*} value The value to inspect. + * + * @returns {boolean} True if the value is a plain object. + */ +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +/** + * Check whether a value is valid JSON text. + * + * @param {*} value The value to test. + * + * @returns {boolean} True if the value can be JSON parsed. + */ +function isJsonString(value) { + if (typeof value !== 'string' || value === '') { + return false; } - if (typeof Storage !== "undefined") { - sessionStorage.setItem(key, values); - } else { - memoryinitialization[key] = values; + try { + JSON.parse(value); + return true; + } catch (error) { + return false; } } -function mergeSessionMemory(key, values) { - const oldValues = getSessionMemory(key); - - if (oldValues) { - values = { ...oldValues, ...values }; +/** + * Convert a value to JSON text. + * + * @param {*} value The value to encode. + * + * @returns {string|null} The JSON text, or null on failure. + */ +function encodeSessionValue(value) { + try { + return JSON.stringify(value); + } catch (error) { + console.error('Failed to encode session value:', error); + return null; } - - return JSON.stringify(values); } +/** + * Decode JSON text from session memory. + * + * @param {*} value The stored JSON text. + * @param {*} defaultValue The fallback value. + * + * @returns {*} The decoded value or default. + */ +function decodeSessionValue(value, defaultValue = null) { + if (!isJsonString(value)) { + return defaultValue; + } + + try { + return JSON.parse(value); + } catch (error) { + console.error('Failed to decode session value:', error); + return defaultValue; + } +} + +/** + * Cached session storage availability flag. + * + * Evaluated once at load time to avoid repeated write-tests + * on every get/set call. + * + * @type {boolean} + */ +const _sessionStorageAvailable = (() => { + try { + if (typeof window === 'undefined' || !window.sessionStorage) { + return false; + } + + const testKey = '__jcb_session_test__'; + window.sessionStorage.setItem(testKey, '1'); + window.sessionStorage.removeItem(testKey); + + return true; + } catch (error) { + return false; + } +})(); + +/** + * Determine whether session storage is usable. + * + * @returns {boolean} True when sessionStorage is available. + */ +function hasSessionStorage() { + return _sessionStorageAvailable; +} + +/** + * Retrieve a parsed value from session storage. + * + * @param {string} key The storage key. + * @param {*} defaultValue The fallback value. + * + * @returns {*} The stored value or default. + */ function getSessionMemory(key, defaultValue = null) { - if (typeof Storage !== "undefined") { - const localValue = sessionStorage.getItem(key); + if (typeof key !== 'string' || key === '') { + return defaultValue; + } - if (isJsonString(localValue)) { - defaultValue = JSON.parse(localValue); + if (hasSessionStorage()) { + try { + return decodeSessionValue(window.sessionStorage.getItem(key), defaultValue); + } catch (error) { + console.error('Failed to read session storage:', error); + return defaultValue; } - } else if (typeof memoryinitialization[key] !== "undefined") { - const localValue = memoryinitialization[key]; + } - if (isJsonString(localValue)) { - defaultValue = JSON.parse(localValue); - } + if (typeof memoryinitialization[key] !== 'undefined') { + return decodeSessionValue(memoryinitialization[key], defaultValue); } return defaultValue; } -function isJsonString(str) { - try { - JSON.parse(str); - } catch (e) { +/** + * Merge new values into an existing session storage entry. + * + * @param {string} key The storage key. + * @param {*} values The values to merge. + * + * @returns {string|null} Encoded merged value. + */ +function mergeSessionMemory(key, values) { + const oldValues = getSessionMemory(key, null); + + if (isPlainObject(oldValues) && isPlainObject(values)) { + return encodeSessionValue({ ...oldValues, ...values }); + } + + return encodeSessionValue(values); +} + +/** + * Store a value in session storage. + * + * @param {string} key The storage key. + * @param {*} values The values to store. + * @param {boolean} merge Whether to merge plain objects with existing data. + * + * @returns {boolean} True on success. + */ +function setSessionMemory(key, values, merge = true) { + if (typeof key !== 'string' || key === '') { return false; } + const payload = merge ? mergeSessionMemory(key, values) : encodeSessionValue(values); + + if (payload === null) { + return false; + } + + if (hasSessionStorage()) { + try { + window.sessionStorage.setItem(key, payload); + return true; + } catch (error) { + console.error('Failed to write session storage:', error); + } + } + + memoryinitialization[key] = payload; + return true; } +/** + * Ensure a value is returned as an array. + * + * @param {*} items The value to normalize. + * + * @returns {Array} The normalized array. + */ function getArrayFormat(items) { - // Check if items is an object and not an array - if (typeof items === 'object' && !Array.isArray(items)) { + if (Array.isArray(items)) { + return items; + } + + if (isPlainObject(items)) { return Object.values(items); } - return items; + + return []; +} + +/** + * Get a translated string safely. + * + * @param {string} key The translation key. + * @param {string} fallbackText The fallback text. + * + * @returns {string} The translated or fallback text. + */ +function translate(key, fallbackText = '') { + if ( + typeof Joomla !== 'undefined' + && Joomla.Text + && typeof Joomla.Text._ === 'function' + ) { + const translated = Joomla.Text._(key); + + if (typeof translated === 'string' && translated !== '') { + return translated; + } + } + + return fallbackText || key; } class InitializationManager { + /** @type {HTMLElement|null} */ #repoArea = document.getElementById('select-repo-area'); - #powersArea = document.getElementById('select-powers-area'); - #initButton = document.getElementById('init-selected-powers'); - #backButton = document.getElementById('back-to-select-repo'); - #loadingDiv = window.loadingDiv || null; - #buildTable = typeof buildPowerSelectionTable === 'function' ? buildPowerSelectionTable : null; - #drawTable = typeof drawPowerSelectionTable === 'function' ? drawPowerSelectionTable : null; + /** @type {HTMLElement|null} */ + #powersArea = document.getElementById('select-powers-area'); + + /** @type {HTMLButtonElement|null} */ + #initButton = document.getElementById('init-selected-powers'); + + /** @type {HTMLButtonElement|null} */ + #backButton = document.getElementById('back-to-select-repo'); + + /** @type {HTMLElement|null} */ + #loadingDiv = window.loadingDiv || null; + + /** @type {Function|null} */ + #buildTable = typeof window.buildPowerSelectionTable === 'function' + ? window.buildPowerSelectionTable + : null; + + /** @type {Function|null} */ + #drawTable = typeof window.drawPowerSelectionTable === 'function' + ? window.drawPowerSelectionTable + : null; + + /** @type {AbortController|null} */ + #repoRequestController = null; + + /** @type {AbortController|null} */ + #initRequestController = null; + + /** @type {boolean} */ + #isLoadingRepo = false; + + /** @type {boolean} */ + #isInitializing = false; + + /** + * The current repository GUID. + * + * @type {string|null} + */ currentRepo = null; + + /** + * The current area key. + * + * @type {string|null} + */ currentArea = null; constructor() { @@ -89,29 +294,49 @@ class InitializationManager { this._updateInitButtonState(); } - /** Getter for selected items using global window reference. */ + /** + * Get the shared selected items collection. + * + * @returns {Array} The selected items. + */ get selectedItems() { if (!Array.isArray(window.selectedPowerItems)) { window.selectedPowerItems = []; } + return window.selectedPowerItems; } - /** Setter for selected items with sync to window and button state update. */ + /** + * Set the shared selected items collection. + * + * @param {Array} items The selected items. + * + * @returns {void} + */ set selectedItems(items) { - window.selectedPowerItems = items; + window.selectedPowerItems = Array.isArray(items) ? items : []; this._updateInitButtonState(); } - /** Add items to selection if not already selected. */ + /** + * Add items not already selected. + * + * @param {Array|Object} data The items to add. + * + * @returns {void} + */ addSelectedItems(data) { - if (!data || typeof data.length !== 'number') return; + const items = getArrayFormat(data); + + if (items.length === 0) { + return; + } const updated = [...this.selectedItems]; - for (let i = 0; i < data.length; i++) { - const item = data[i]; - if (!updated.some(existing => this.#isSameItem(existing, item))) { + for (const item of items) { + if (!updated.some((existing) => this.#isSameItem(existing, item))) { updated.push(item); } } @@ -119,284 +344,624 @@ class InitializationManager { this.selectedItems = updated; } - /** Remove items from selection based on identity comparison. */ + /** + * Remove items from the selection. + * + * @param {Array|Object} data The items to remove. + * + * @returns {void} + */ removeSelectedItems(data) { - if (!data || typeof data.length !== 'number') return; + const items = getArrayFormat(data); - const updated = this.selectedItems.filter(existing => { - for (let i = 0; i < data.length; i++) { - if (this.#isSameItem(existing, data[i])) { - return false; - } - } - return true; + if (items.length === 0) { + return; + } + + this.selectedItems = this.selectedItems.filter((existing) => { + return !items.some((item) => this.#isSameItem(existing, item)); }); - - this.selectedItems = updated; } - /** Check if two items are the same using GUID. */ + /** + * Compare two items by GUID. + * + * @param {Object} a First item. + * @param {Object} b Second item. + * + * @returns {boolean} True if both items match. + */ #isSameItem(a, b) { - return a?.guid && b?.guid && a.guid === b.guid; + return Boolean(a?.guid && b?.guid && a.guid === b.guid); } - /** Enable/disable the init button based on selection state. */ + /** + * Update action button state. + * + * @returns {void} + */ _updateInitButtonState() { if (this.#initButton) { - this.#initButton.disabled = this.selectedItems.length === 0; + this.#initButton.disabled = ( + this.selectedItems.length === 0 + || this.#isInitializing + || this.#isLoadingRepo + ); + } + + if (this.#backButton) { + this.#backButton.disabled = this.#isInitializing || this.#isLoadingRepo; } } - _bindRepoButtons() { - document.querySelectorAll('.select-repo-to-load').forEach(button => { - button.addEventListener('click', (e) => this._handleRepoClick(e)); + /** + * Toggle repository buttons disabled state. + * + * @param {boolean} disabled Whether buttons should be disabled. + * + * @returns {void} + */ + _setRepoButtonsDisabled(disabled) { + document.querySelectorAll('.select-repo-to-load').forEach((button) => { + button.disabled = disabled; }); } + /** + * Bind repository buttons. + * + * @returns {void} + */ + _bindRepoButtons() { + document.querySelectorAll('.select-repo-to-load').forEach((button) => { + button.addEventListener('click', (event) => this._handleRepoClick(event)); + }); + } + + /** + * Bind init and back buttons. + * + * @returns {void} + */ _bindInitSelectedPowers() { if (this.#initButton) { this.#initButton.addEventListener('click', () => this._handleInitSelectedPowers()); } + if (this.#backButton) { this.#backButton.addEventListener('click', () => this._handleBackToRepos()); } } + /** + * Handle repository click. + * + * @param {Event} event The click event. + * + * @returns {Promise} + */ async _handleRepoClick(event) { + if (this.#isLoadingRepo || this.#isInitializing) { + return; + } + const button = event.currentTarget; const repo = button?.dataset?.repo; const area = button?.dataset?.area; if (!repo || !area) { - this._notify(Joomla.Text._("COM_COMPONENTBUILDER_MISSING_REPOSITORY_OR_AREA_DATA"), "danger"); + this._notify( + translate('COM_COMPONENTBUILDER_MISSING_REPOSITORY_OR_AREA_DATA'), + 'danger' + ); return; } + this.#isLoadingRepo = true; + this._updateInitButtonState(); + this._setRepoButtonsDisabled(true); this._showLoading(); - clearPowerSelectionTable(); + this._abortRepoRequest(); + + // Reset previous selection when switching repositories. + this.selectedItems = []; + + if (typeof window.clearPowerSelectionTable === 'function') { + window.clearPowerSelectionTable(); + } + + this.#repoRequestController = new AbortController(); const url = `${UrlAjax}getRepoIndex&repo=${encodeURIComponent(repo)}&area=${encodeURIComponent(area)}`; try { - const response = await fetch(url); + const response = await fetch(url, { + method: 'GET', + signal: this.#repoRequestController.signal, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + } + }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } const data = await response.json(); - if (data.success && data.index && this.#buildTable) { - const repoData = data.index[0]; - const { - path = 'joomla/super-powers', - read_branch = 'master', - target, - base = 'https://git.vdm.dev' - } = repoData; + if (!data?.success || !data?.index || !this.#buildTable) { + this._notify( + data?.message || translate('COM_COMPONENTBUILDER_FAILED_TO_RETRIEVE_REPOSITORY_INDEX'), + 'danger' + ); + return; + } - const repo_base = (target === 'github') ? 'https://github.com' : base; - const repo_path = (target === 'github') ? 'tree' : 'src/branch'; + const repoData = Array.isArray(data.index) ? data.index[0] : data.index; - window.targetPowerRepoUrl = `${repo_base}/${path}/${repo_path}/${read_branch}/`; + if (!repoData || typeof repoData !== 'object') { + this._notify( + translate('COM_COMPONENTBUILDER_FAILED_TO_RETRIEVE_REPOSITORY_INDEX'), + 'danger' + ); + return; + } - this.#buildTable(repoData.index); + const { + path = 'joomla/super-powers', + read_branch = 'master', + target = '', + base = 'https://git.vdm.dev', + guid = null, + index = [] + } = repoData; - setTimeout(() => { - this._transitionTo(this.#repoArea, this.#powersArea); - this._hideLoading(); - }, 500); + const repoBase = target === 'github' ? 'https://github.com' : base; + const repoPath = target === 'github' ? 'tree' : 'src/branch'; - this.currentRepo = repoData.guid; - this.currentArea = area; - } else { - this._notify(data.message || Joomla.Text._("COM_COMPONENTBUILDER_FAILED_TO_RETRIEVE_REPOSITORY_INDEX"), "danger"); - this._hideLoading(); + window.targetPowerRepoUrl = `${repoBase}/${path}/${repoPath}/${read_branch}/`; + + // The index may be an array or an object keyed by GUID. + this.#buildTable(index); + + this.currentRepo = guid; + this.currentArea = area; + + await this._transitionTo(this.#repoArea, this.#powersArea); + + if (this.#drawTable) { + this.#drawTable(); } } catch (error) { - console.error("Fetch error:", error); + if (error.name !== 'AbortError') { + console.error('Fetch error:', error); + this._notify( + translate('COM_COMPONENTBUILDER_NETWORK_OR_SERVER_ERROR_OCCURRED_WHILE_FETCHING_INDEX'), + 'danger' + ); + } + } finally { + this.#isLoadingRepo = false; this._hideLoading(); - this._notify(Joomla.Text._("COM_COMPONENTBUILDER_NETWORK_OR_SERVER_ERROR_OCCURRED_WHILE_FETCHING_INDEX"), "danger"); + this._setRepoButtonsDisabled(false); + this._updateInitButtonState(); } } + /** + * Go back to repository selection. + * + * @returns {void} + */ _handleBackToRepos() { - this._transitionTo(this.#powersArea, this.#repoArea); - } - - async _handleInitSelectedPowers() { - if (!Array.isArray(this.selectedItems) || this.selectedItems.length === 0) { - this._notify(Joomla.Text._("COM_COMPONENTBUILDER_NO_ITEMS_SELECTED"), "warning"); + if (this.#isLoadingRepo || this.#isInitializing) { return; } + this._transitionTo(this.#powersArea, this.#repoArea) + .then(() => { + if (this.#drawTable) { + this.#drawTable(); + } + }) + .catch((error) => { + console.error('Transition error:', error); + }); + } + + /** + * Submit selected powers. + * + * @returns {Promise} + */ + async _handleInitSelectedPowers() { + if (this.#isInitializing || this.#isLoadingRepo) { + return; + } + + if (!Array.isArray(this.selectedItems) || this.selectedItems.length === 0) { + this._notify( + translate('COM_COMPONENTBUILDER_NO_ITEMS_SELECTED'), + 'warning' + ); + return; + } + + const area = this.currentArea; + const repo = this.currentRepo; + + if (!area || !repo) { + this._notify( + translate('COM_COMPONENTBUILDER_MISSING_REPOSITORY_OR_AREA_DATA'), + 'danger' + ); + return; + } + + this.#isInitializing = true; + this._updateInitButtonState(); + this._setRepoButtonsDisabled(true); this._showLoading(); + this._abortInitRequest(); - const area = this.currentArea || 'error'; - const repo = this.currentRepo || 'error'; - - const func = 'initSelectedPackages'; + this.#initRequestController = new AbortController(); try { - // Convert selected items to form data const formData = new FormData(); - // Assuming Joomla expects `selected[]` for multiple values for (const item of this.selectedItems) { - formData.append('selected[]', item.guid); // Only sending GUIDs + if (item?.guid) { + formData.append('selected[]', item.guid); + } } + formData.append('area', area); formData.append('repo', repo); - const response = await fetch(`${UrlAjax}${func}`, { + const response = await fetch(`${UrlAjax}initSelectedPackages`, { method: 'POST', - body: formData + body: formData, + signal: this.#initRequestController.signal, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + } }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } const data = await response.json(); - if (!data.success) { - this._notify(data.message || Joomla.Text._("COM_COMPONENTBUILDER_FAILED_TO_INITIALIZE_SELECTED_POWERS"), "danger"); - } else { - this._handleResultLog(data.result_log || {}); + if (!data?.success) { + this._notify( + data?.message || translate('COM_COMPONENTBUILDER_FAILED_TO_INITIALIZE_SELECTED_POWERS'), + 'danger' + ); + return; } - this._hideLoading(); - this._transitionTo(this.#powersArea, this.#repoArea); - } catch (error) { - console.error("Submission error:", error); - this._notify(Joomla.Text._("COM_COMPONENTBUILDER_ERROR_OCCURRED_WHILE_INITIALIZING_POWERS"), "danger"); + // Process result log before clearing selection, + // since _getNamesFromGuids reads from selectedItems. + this._handleResultLog(data.result_log || {}); + this.selectedItems = []; + await this._transitionTo(this.#powersArea, this.#repoArea); + } catch (error) { + if (error.name !== 'AbortError') { + console.error('Submission error:', error); + this._notify( + translate('COM_COMPONENTBUILDER_ERROR_OCCURRED_WHILE_INITIALIZING_POWERS'), + 'danger' + ); + } + } finally { + this.#isInitializing = false; this._hideLoading(); - this._transitionTo(this.#powersArea, this.#repoArea); + this._setRepoButtonsDisabled(false); + this._updateInitButtonState(); } } + /** + * Process init result log. + * + * @param {Object} resultLog The result log object. + * + * @returns {void} + */ _handleResultLog(resultLog) { const localGuids = this._normalizeGuids(resultLog.local); const notFoundGuids = this._normalizeGuids(resultLog.not_found); const addedGuids = this._normalizeGuids(resultLog.added); if (localGuids.length > 0) { - this._notify( - this._generateResultMessage(localGuids, 'local'), + this._notifyList( + translate('COM_COMPONENTBUILDER_THESE_ITEMS_WERE_ALREADY_PRESENT_LOCALLY_AND_WERE_NOT_INITIALIZED'), + this._getNamesFromGuids(localGuids), 'info' ); } if (notFoundGuids.length > 0) { - this._notify( - this._generateResultMessage(notFoundGuids, 'not_found'), - 'info' + this._notifyList( + translate('COM_COMPONENTBUILDER_THESE_ITEMS_COULD_NOT_BE_FOUND_IN_THE_REMOTE_REPOSITORY_AND_WERE_NOT_INITIALIZED'), + this._getNamesFromGuids(notFoundGuids), + 'warning' ); } if (addedGuids.length > 0) { - this._notify( - this._generateResultMessage(addedGuids, 'added'), + this._notifyList( + translate('COM_COMPONENTBUILDER_THESE_ITEMS_WERE_SUCCESSFULLY_INITIALIZED'), + this._getNamesFromGuids(addedGuids), 'success' ); } } + /** + * Normalize log values to GUID arrays. + * + * @param {*} value The raw value. + * + * @returns {Array} The GUID array. + */ _normalizeGuids(value) { - if (!value) return []; - - if (Array.isArray(value)) { - return value; + if (!value) { + return []; } - if (typeof value === 'object') { + if (Array.isArray(value)) { + return value.filter((item) => typeof item === 'string' && item !== ''); + } + + if (isPlainObject(value)) { return Object.keys(value); } return []; } - _generateResultMessage(guids, type) { - const messages = { - local: Joomla.Text._('COM_COMPONENTBUILDER_THESE_ITEMS_WERE_ALREADY_PRESENT_LOCALLY_AND_WERE_NOT_INITIALIZED'), - not_found: Joomla.Text._('COM_COMPONENTBUILDER_THESE_ITEMS_COULD_NOT_BE_FOUND_IN_THE_REMOTE_REPOSITORY_AND_WERE_NOT_INITIALIZED'), - added: Joomla.Text._('COM_COMPONENTBUILDER_THESE_ITEMS_WERE_SUCCESSFULLY_INITIALIZED') - }; - + /** + * Resolve item names from GUIDs. + * + * @param {Array} guids The GUIDs. + * + * @returns {Array} The item names. + */ + _getNamesFromGuids(guids) { const names = []; for (const guid of guids) { - const item = this.selectedItems.find(i => i.guid === guid); - if (item?.name) { + const item = this.selectedItems.find((entry) => entry?.guid === guid); + + if (typeof item?.name === 'string' && item.name !== '') { names.push(item.name); } } - if (names.length === 0) { - return null; - } - - return `${messages[type]}\n
- ${names.join('\n
- ')}`; + return names; } - _transitionTo(hideEl, showEl) { - if (hideEl && showEl) { - UIkit.util.ready(() => { - UIkit.util.removeClass(hideEl, 'uk-animation-slide-top-small'); - UIkit.util.removeClass(showEl, 'uk-animation-slide-bottom-small'); - UIkit.util.addClass(hideEl, 'uk-animation-slide-top-small'); + /** + * Abort any running repository request. + * + * @returns {void} + */ + _abortRepoRequest() { + if (this.#repoRequestController) { + this.#repoRequestController.abort(); + this.#repoRequestController = null; + } + } - setTimeout(() => { + /** + * Abort any running init request. + * + * @returns {void} + */ + _abortInitRequest() { + if (this.#initRequestController) { + this.#initRequestController.abort(); + this.#initRequestController = null; + } + } + + /** + * Transition between two elements. + * + * @param {HTMLElement|null} hideEl The element to hide. + * @param {HTMLElement|null} showEl The element to show. + * + * @returns {Promise} Resolves after transition completes. + */ + _transitionTo(hideEl, showEl) { + if (!hideEl || !showEl || hideEl === showEl) { + return Promise.resolve(); + } + + const duration = 250; + + return new Promise((resolve) => { + hideEl.style.transition = `opacity ${duration}ms ease`; + showEl.style.transition = `opacity ${duration}ms ease`; + + hideEl.style.opacity = '1'; + showEl.style.display = 'none'; + showEl.style.opacity = '0'; + + requestAnimationFrame(() => { + hideEl.style.opacity = '0'; + + window.setTimeout(() => { hideEl.style.display = 'none'; showEl.style.display = ''; - UIkit.util.addClass(showEl, 'uk-animation-slide-bottom-small'); - if (this.#drawTable) this.#drawTable(); - }, 300); + showEl.style.opacity = '0'; + + requestAnimationFrame(() => { + showEl.style.opacity = '1'; + + window.setTimeout(() => { + hideEl.style.transition = ''; + hideEl.style.opacity = ''; + showEl.style.transition = ''; + showEl.style.opacity = ''; + resolve(); + }, duration); + }); + }, duration); }); + }); + } + + /** + * Show the loading overlay. + * + * @returns {void} + */ + _showLoading() { + if (this.#loadingDiv) { + this.#loadingDiv.style.display = 'block'; } } - _showLoading() { - if (this.#loadingDiv) this.#loadingDiv.style.display = 'block'; - } - + /** + * Hide the loading overlay. + * + * @returns {void} + */ _hideLoading() { - if (this.#loadingDiv) this.#loadingDiv.style.display = 'none'; + if (this.#loadingDiv) { + this.#loadingDiv.style.display = 'none'; + } } + /** + * Notify with a simple message. + * + * @param {string} message The message. + * @param {string} type The alert type. + * + * @returns {void} + */ _notify(message, type = 'info') { - const alertTypes = { - primary: 'alert-primary', - info: 'alert-info', - success: 'alert-success', - warning: 'alert-warning', - danger: 'alert-danger', - }; + if (typeof message !== 'string' || message.trim() === '') { + return; + } - const alertClass = alertTypes[type] || alertTypes.primary; + const body = document.createElement('div'); + body.textContent = message; + + this._renderAlert(body, type); + } + + /** + * Notify with a heading and a list. + * + * @param {string} heading The heading text. + * @param {Array} items The item names. + * @param {string} type The alert type. + * + * @returns {void} + */ + _notifyList(heading, items, type = 'info') { + if (!heading && (!Array.isArray(items) || items.length === 0)) { + return; + } + + const fragment = document.createDocumentFragment(); + + if (heading) { + const headingNode = document.createElement('div'); + headingNode.textContent = heading; + fragment.appendChild(headingNode); + } + + if (Array.isArray(items) && items.length > 0) { + const list = document.createElement('ul'); + list.className = 'mb-0 mt-2'; + + for (const item of items) { + const li = document.createElement('li'); + li.textContent = item; + list.appendChild(li); + } + + fragment.appendChild(list); + } + + const wrapper = document.createElement('div'); + wrapper.appendChild(fragment); + + this._renderAlert(wrapper, type); + } + + /** + * Render a Bootstrap alert. + * + * @param {Node} contentNode The content node. + * @param {string} type The alert type. + * + * @returns {void} + */ + _renderAlert(contentNode, type = 'info') { + const allowedTypes = ['primary', 'info', 'success', 'warning', 'danger']; + const alertType = allowedTypes.includes(type) ? type : 'primary'; let container = document.getElementById('alert-container'); + if (!container) { container = document.createElement('div'); container.id = 'alert-container'; container.className = 'position-fixed top-0 start-50 translate-middle-x p-3'; container.style.zIndex = '1060'; + container.style.maxWidth = 'min(92vw, 720px)'; + container.style.width = '100%'; document.body.appendChild(container); } const alert = document.createElement('div'); - alert.className = `alert ${alertClass} alert-dismissible fade show`; + alert.className = `alert alert-${alertType} alert-dismissible fade show shadow`; alert.setAttribute('role', 'alert'); - alert.innerHTML = ` - ${message} - - `; + const body = document.createElement('div'); + body.appendChild(contentNode); + + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'btn-close'; + closeButton.setAttribute('data-bs-dismiss', 'alert'); + closeButton.setAttribute('aria-label', 'Close'); + + alert.appendChild(body); + alert.appendChild(closeButton); container.appendChild(alert); - setTimeout(() => { - alert.classList.remove('show'); - alert.classList.add('hide'); - alert.addEventListener('transitionend', () => { - alert.remove(); - }); + if ( + typeof bootstrap !== 'undefined' + && bootstrap.Alert + && typeof bootstrap.Alert.getOrCreateInstance === 'function' + ) { + const instance = bootstrap.Alert.getOrCreateInstance(alert); + + window.setTimeout(() => { + try { + instance.close(); + } catch (error) { + if (alert.parentNode) { + alert.remove(); + } + } + }, 5000); + + return; + } + + window.setTimeout(() => { + alert.remove(); }, 5000); } } \ No newline at end of file diff --git a/admin/assets/js/pull_selection.js b/admin/assets/js/pull_selection.js index 86dc8ab84..bd96fd143 100644 --- a/admin/assets/js/pull_selection.js +++ b/admin/assets/js/pull_selection.js @@ -10,77 +10,282 @@ /* JS Document */ -const memorypull = []; +const memorypull = {}; -function setSessionMemory(key, values, merge = true) { - if (merge) { - values = mergeSessionMemory(key, values); - } else { - values = JSON.stringify(values); +/** + * Check whether a value looks like a plain object. + * + * @param {*} value The value to inspect. + * + * @returns {boolean} True if the value is a plain object. + */ +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +/** + * Check whether a value is valid JSON text. + * + * @param {*} value The value to test. + * + * @returns {boolean} True if the value can be JSON parsed. + */ +function isJsonString(value) { + if (typeof value !== 'string' || value === '') { + return false; } - if (typeof Storage !== "undefined") { - sessionStorage.setItem(key, values); - } else { - memorypull[key] = values; + try { + JSON.parse(value); + return true; + } catch (error) { + return false; } } -function mergeSessionMemory(key, values) { - const oldValues = getSessionMemory(key); - - if (oldValues) { - values = { ...oldValues, ...values }; +/** + * Convert a value to JSON text. + * + * @param {*} value The value to encode. + * + * @returns {string|null} The JSON text, or null on failure. + */ +function encodeSessionValue(value) { + try { + return JSON.stringify(value); + } catch (error) { + console.error('Failed to encode session value:', error); + return null; } - - return JSON.stringify(values); } +/** + * Decode JSON text from session memory. + * + * @param {*} value The stored JSON text. + * @param {*} defaultValue The fallback value. + * + * @returns {*} The decoded value or default. + */ +function decodeSessionValue(value, defaultValue = null) { + if (!isJsonString(value)) { + return defaultValue; + } + + try { + return JSON.parse(value); + } catch (error) { + console.error('Failed to decode session value:', error); + return defaultValue; + } +} + +/** + * Cached session storage availability flag. + * + * Evaluated once at load time to avoid repeated write-tests + * on every get/set call. + * + * @type {boolean} + */ +const _sessionStorageAvailable = (() => { + try { + if (typeof window === 'undefined' || !window.sessionStorage) { + return false; + } + + const testKey = '__jcb_session_test__'; + window.sessionStorage.setItem(testKey, '1'); + window.sessionStorage.removeItem(testKey); + + return true; + } catch (error) { + return false; + } +})(); + +/** + * Determine whether session storage is usable. + * + * @returns {boolean} True when sessionStorage is available. + */ +function hasSessionStorage() { + return _sessionStorageAvailable; +} + +/** + * Retrieve a parsed value from session storage. + * + * @param {string} key The storage key. + * @param {*} defaultValue The fallback value. + * + * @returns {*} The stored value or default. + */ function getSessionMemory(key, defaultValue = null) { - if (typeof Storage !== "undefined") { - const localValue = sessionStorage.getItem(key); + if (typeof key !== 'string' || key === '') { + return defaultValue; + } - if (isJsonString(localValue)) { - defaultValue = JSON.parse(localValue); + if (hasSessionStorage()) { + try { + return decodeSessionValue(window.sessionStorage.getItem(key), defaultValue); + } catch (error) { + console.error('Failed to read session storage:', error); + return defaultValue; } - } else if (typeof memorypull[key] !== "undefined") { - const localValue = memorypull[key]; + } - if (isJsonString(localValue)) { - defaultValue = JSON.parse(localValue); - } + if (typeof memorypull[key] !== 'undefined') { + return decodeSessionValue(memorypull[key], defaultValue); } return defaultValue; } -function isJsonString(str) { - try { - JSON.parse(str); - } catch (e) { +/** + * Merge new values into an existing session storage entry. + * + * @param {string} key The storage key. + * @param {*} values The values to merge. + * + * @returns {string|null} Encoded merged value. + */ +function mergeSessionMemory(key, values) { + const oldValues = getSessionMemory(key, null); + + if (isPlainObject(oldValues) && isPlainObject(values)) { + return encodeSessionValue({ ...oldValues, ...values }); + } + + return encodeSessionValue(values); +} + +/** + * Store a value in session storage. + * + * @param {string} key The storage key. + * @param {*} values The values to store. + * @param {boolean} merge Whether to merge plain objects with existing data. + * + * @returns {boolean} True on success. + */ +function setSessionMemory(key, values, merge = true) { + if (typeof key !== 'string' || key === '') { return false; } + const payload = merge ? mergeSessionMemory(key, values) : encodeSessionValue(values); + + if (payload === null) { + return false; + } + + if (hasSessionStorage()) { + try { + window.sessionStorage.setItem(key, payload); + return true; + } catch (error) { + console.error('Failed to write session storage:', error); + } + } + + memorypull[key] = payload; + return true; } +/** + * Ensure a value is returned as an array. + * + * @param {*} items The value to normalize. + * + * @returns {Array} The normalized array. + */ function getArrayFormat(items) { - // Check if items is an object and not an array - if (typeof items === 'object' && !Array.isArray(items)) { + if (Array.isArray(items)) { + return items; + } + + if (isPlainObject(items)) { return Object.values(items); } - return items; + + return []; +} + +/** + * Get a translated string safely. + * + * @param {string} key The translation key. + * @param {string} fallbackText The fallback text. + * + * @returns {string} The translated or fallback text. + */ +function translate(key, fallbackText = '') { + if ( + typeof Joomla !== 'undefined' + && Joomla.Text + && typeof Joomla.Text._ === 'function' + ) { + const translated = Joomla.Text._(key); + + if (typeof translated === 'string' && translated !== '') { + return translated; + } + } + + return fallbackText || key; } class PullManager { + /** @type {HTMLElement|null} */ #repoArea = document.getElementById('select-repo-area'); - #powersArea = document.getElementById('select-powers-area'); - #pullButton = document.getElementById('pull-selected-powers'); - #backButton = document.getElementById('back-to-select-repo'); - #loadingDiv = window.loadingDiv || null; - #buildTable = typeof buildPowerSelectionTable === 'function' ? buildPowerSelectionTable : null; - #drawTable = typeof drawPowerSelectionTable === 'function' ? drawPowerSelectionTable : null; + /** @type {HTMLElement|null} */ + #powersArea = document.getElementById('select-powers-area'); + + /** @type {HTMLButtonElement|null} */ + #pullButton = document.getElementById('pull-selected-powers'); + + /** @type {HTMLButtonElement|null} */ + #backButton = document.getElementById('back-to-select-repo'); + + /** @type {HTMLElement|null} */ + #loadingDiv = window.loadingDiv || null; + + /** @type {Function|null} */ + #buildTable = typeof window.buildPowerSelectionTable === 'function' + ? window.buildPowerSelectionTable + : null; + + /** @type {Function|null} */ + #drawTable = typeof window.drawPowerSelectionTable === 'function' + ? window.drawPowerSelectionTable + : null; + + /** @type {AbortController|null} */ + #repoRequestController = null; + + /** @type {AbortController|null} */ + #pullRequestController = null; + + /** @type {boolean} */ + #isLoadingRepo = false; + + /** @type {boolean} */ + #isPulling = false; + + /** + * The current repository GUID. + * + * @type {string|null} + */ currentRepo = null; + + /** + * The current area key. + * + * @type {string|null} + */ currentArea = null; constructor() { @@ -89,29 +294,49 @@ class PullManager { this._updatePullButtonState(); } - /** Getter for selected items using global window reference. */ + /** + * Get the shared selected items collection. + * + * @returns {Array} The selected items. + */ get selectedItems() { if (!Array.isArray(window.selectedPowerItems)) { window.selectedPowerItems = []; } + return window.selectedPowerItems; } - /** Setter for selected items with sync to window and button state update. */ + /** + * Set the shared selected items collection. + * + * @param {Array} items The selected items. + * + * @returns {void} + */ set selectedItems(items) { - window.selectedPowerItems = items; + window.selectedPowerItems = Array.isArray(items) ? items : []; this._updatePullButtonState(); } - /** Add items to selection if not already selected. */ + /** + * Add items not already selected. + * + * @param {Array|Object} data The items to add. + * + * @returns {void} + */ addSelectedItems(data) { - if (!data || typeof data.length !== 'number') return; + const items = getArrayFormat(data); + + if (items.length === 0) { + return; + } const updated = [...this.selectedItems]; - for (let i = 0; i < data.length; i++) { - const item = data[i]; - if (!updated.some(existing => this.#isSameItem(existing, item))) { + for (const item of items) { + if (!updated.some((existing) => this.#isSameItem(existing, item))) { updated.push(item); } } @@ -119,284 +344,624 @@ class PullManager { this.selectedItems = updated; } - /** Remove items from selection based on identity comparison. */ + /** + * Remove items from the selection. + * + * @param {Array|Object} data The items to remove. + * + * @returns {void} + */ removeSelectedItems(data) { - if (!data || typeof data.length !== 'number') return; + const items = getArrayFormat(data); - const updated = this.selectedItems.filter(existing => { - for (let i = 0; i < data.length; i++) { - if (this.#isSameItem(existing, data[i])) { - return false; - } - } - return true; + if (items.length === 0) { + return; + } + + this.selectedItems = this.selectedItems.filter((existing) => { + return !items.some((item) => this.#isSameItem(existing, item)); }); - - this.selectedItems = updated; } - /** Check if two items are the same using GUID. */ + /** + * Compare two items by GUID. + * + * @param {Object} a First item. + * @param {Object} b Second item. + * + * @returns {boolean} True if both items match. + */ #isSameItem(a, b) { - return a?.guid && b?.guid && a.guid === b.guid; + return Boolean(a?.guid && b?.guid && a.guid === b.guid); } - /** Enable/disable the pull button based on selection state. */ + /** + * Update action button state. + * + * @returns {void} + */ _updatePullButtonState() { if (this.#pullButton) { - this.#pullButton.disabled = this.selectedItems.length === 0; + this.#pullButton.disabled = ( + this.selectedItems.length === 0 + || this.#isPulling + || this.#isLoadingRepo + ); + } + + if (this.#backButton) { + this.#backButton.disabled = this.#isPulling || this.#isLoadingRepo; } } - _bindRepoButtons() { - document.querySelectorAll('.select-repo-to-load').forEach(button => { - button.addEventListener('click', (e) => this._handleRepoClick(e)); + /** + * Toggle repository buttons disabled state. + * + * @param {boolean} disabled Whether buttons should be disabled. + * + * @returns {void} + */ + _setRepoButtonsDisabled(disabled) { + document.querySelectorAll('.select-repo-to-load').forEach((button) => { + button.disabled = disabled; }); } + /** + * Bind repository buttons. + * + * @returns {void} + */ + _bindRepoButtons() { + document.querySelectorAll('.select-repo-to-load').forEach((button) => { + button.addEventListener('click', (event) => this._handleRepoClick(event)); + }); + } + + /** + * Bind pull and back buttons. + * + * @returns {void} + */ _bindPullSelectedPowers() { if (this.#pullButton) { this.#pullButton.addEventListener('click', () => this._handlePullSelectedPowers()); } + if (this.#backButton) { this.#backButton.addEventListener('click', () => this._handleBackToRepos()); } } + /** + * Handle repository click. + * + * @param {Event} event The click event. + * + * @returns {Promise} + */ async _handleRepoClick(event) { + if (this.#isLoadingRepo || this.#isPulling) { + return; + } + const button = event.currentTarget; const repo = button?.dataset?.repo; const area = button?.dataset?.area; if (!repo || !area) { - this._notify(Joomla.Text._("COM_COMPONENTBUILDER_MISSING_REPOSITORY_OR_AREA_DATA"), "danger"); + this._notify( + translate('COM_COMPONENTBUILDER_MISSING_REPOSITORY_OR_AREA_DATA'), + 'danger' + ); return; } + this.#isLoadingRepo = true; + this._updatePullButtonState(); + this._setRepoButtonsDisabled(true); this._showLoading(); - clearPowerSelectionTable(); + this._abortRepoRequest(); + + // Reset previous selection when switching repositories. + this.selectedItems = []; + + if (typeof window.clearPowerSelectionTable === 'function') { + window.clearPowerSelectionTable(); + } + + this.#repoRequestController = new AbortController(); const url = `${UrlAjax}getRepoIndex&repo=${encodeURIComponent(repo)}&area=${encodeURIComponent(area)}`; try { - const response = await fetch(url); + const response = await fetch(url, { + method: 'GET', + signal: this.#repoRequestController.signal, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + } + }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } const data = await response.json(); - if (data.success && data.index && this.#buildTable) { - const repoData = data.index[0]; - const { - path = 'joomla/super-powers', - read_branch = 'master', - target, - base = 'https://git.vdm.dev' - } = repoData; + if (!data?.success || !data?.index || !this.#buildTable) { + this._notify( + data?.message || translate('COM_COMPONENTBUILDER_FAILED_TO_RETRIEVE_REPOSITORY_INDEX'), + 'danger' + ); + return; + } - const repo_base = (target === 'github') ? 'https://github.com' : base; - const repo_path = (target === 'github') ? 'tree' : 'src/branch'; + const repoData = Array.isArray(data.index) ? data.index[0] : data.index; - window.targetPowerRepoUrl = `${repo_base}/${path}/${repo_path}/${read_branch}/`; + if (!repoData || typeof repoData !== 'object') { + this._notify( + translate('COM_COMPONENTBUILDER_FAILED_TO_RETRIEVE_REPOSITORY_INDEX'), + 'danger' + ); + return; + } - this.#buildTable(repoData.index); + const { + path = 'joomla/super-powers', + read_branch = 'master', + target = '', + base = 'https://git.vdm.dev', + guid = null, + index = [] + } = repoData; - setTimeout(() => { - this._transitionTo(this.#repoArea, this.#powersArea); - this._hideLoading(); - }, 500); + const repoBase = target === 'github' ? 'https://github.com' : base; + const repoPath = target === 'github' ? 'tree' : 'src/branch'; - this.currentRepo = repoData.guid; - this.currentArea = area; - } else { - this._notify(data.message || Joomla.Text._("COM_COMPONENTBUILDER_FAILED_TO_RETRIEVE_REPOSITORY_INDEX"), "danger"); - this._hideLoading(); + window.targetPowerRepoUrl = `${repoBase}/${path}/${repoPath}/${read_branch}/`; + + // The index may be an array or an object keyed by GUID. + this.#buildTable(index); + + this.currentRepo = guid; + this.currentArea = area; + + await this._transitionTo(this.#repoArea, this.#powersArea); + + if (this.#drawTable) { + this.#drawTable(); } } catch (error) { - console.error("Fetch error:", error); + if (error.name !== 'AbortError') { + console.error('Fetch error:', error); + this._notify( + translate('COM_COMPONENTBUILDER_NETWORK_OR_SERVER_ERROR_OCCURRED_WHILE_FETCHING_INDEX'), + 'danger' + ); + } + } finally { + this.#isLoadingRepo = false; this._hideLoading(); - this._notify(Joomla.Text._("COM_COMPONENTBUILDER_NETWORK_OR_SERVER_ERROR_OCCURRED_WHILE_FETCHING_INDEX"), "danger"); + this._setRepoButtonsDisabled(false); + this._updatePullButtonState(); } } + /** + * Go back to repository selection. + * + * @returns {void} + */ _handleBackToRepos() { - this._transitionTo(this.#powersArea, this.#repoArea); - } - - async _handlePullSelectedPowers() { - if (!Array.isArray(this.selectedItems) || this.selectedItems.length === 0) { - this._notify(Joomla.Text._("COM_COMPONENTBUILDER_NO_ITEMS_SELECTED"), "warning"); + if (this.#isLoadingRepo || this.#isPulling) { return; } + this._transitionTo(this.#powersArea, this.#repoArea) + .then(() => { + if (this.#drawTable) { + this.#drawTable(); + } + }) + .catch((error) => { + console.error('Transition error:', error); + }); + } + + /** + * Submit selected powers. + * + * @returns {Promise} + */ + async _handlePullSelectedPowers() { + if (this.#isPulling || this.#isLoadingRepo) { + return; + } + + if (!Array.isArray(this.selectedItems) || this.selectedItems.length === 0) { + this._notify( + translate('COM_COMPONENTBUILDER_NO_ITEMS_SELECTED'), + 'warning' + ); + return; + } + + const area = this.currentArea; + const repo = this.currentRepo; + + if (!area || !repo) { + this._notify( + translate('COM_COMPONENTBUILDER_MISSING_REPOSITORY_OR_AREA_DATA'), + 'danger' + ); + return; + } + + this.#isPulling = true; + this._updatePullButtonState(); + this._setRepoButtonsDisabled(true); this._showLoading(); + this._abortPullRequest(); - const area = this.currentArea || 'error'; - const repo = this.currentRepo || 'error'; - - const func = 'pullSelectedPackages'; + this.#pullRequestController = new AbortController(); try { - // Convert selected items to form data const formData = new FormData(); - // Assuming Joomla expects `selected[]` for multiple values for (const item of this.selectedItems) { - formData.append('selected[]', item.guid); // Only sending GUIDs + if (item?.guid) { + formData.append('selected[]', item.guid); + } } + formData.append('area', area); formData.append('repo', repo); - const response = await fetch(`${UrlAjax}${func}`, { + const response = await fetch(`${UrlAjax}pullSelectedPackages`, { method: 'POST', - body: formData + body: formData, + signal: this.#pullRequestController.signal, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + } }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } const data = await response.json(); - if (!data.success) { - this._notify(data.message || Joomla.Text._("COM_COMPONENTBUILDER_FAILED_TO_PULL_SELECTED_POWERS"), "danger"); - } else { - this._handleResultLog(data.result_log || {}); + if (!data?.success) { + this._notify( + data?.message || translate('COM_COMPONENTBUILDER_FAILED_TO_PULL_SELECTED_POWERS'), + 'danger' + ); + return; } - this._hideLoading(); - this._transitionTo(this.#powersArea, this.#repoArea); - } catch (error) { - console.error("Submission error:", error); - this._notify(Joomla.Text._("COM_COMPONENTBUILDER_ERROR_OCCURRED_WHILE_PULLING_POWERS"), "danger"); + // Process result log before clearing selection, + // since _getNamesFromGuids reads from selectedItems. + this._handleResultLog(data.result_log || {}); + this.selectedItems = []; + await this._transitionTo(this.#powersArea, this.#repoArea); + } catch (error) { + if (error.name !== 'AbortError') { + console.error('Submission error:', error); + this._notify( + translate('COM_COMPONENTBUILDER_ERROR_OCCURRED_WHILE_PULLING_POWERS'), + 'danger' + ); + } + } finally { + this.#isPulling = false; this._hideLoading(); - this._transitionTo(this.#powersArea, this.#repoArea); + this._setRepoButtonsDisabled(false); + this._updatePullButtonState(); } } + /** + * Process pull result log. + * + * @param {Object} resultLog The result log object. + * + * @returns {void} + */ _handleResultLog(resultLog) { const localGuids = this._normalizeGuids(resultLog.local); const notFoundGuids = this._normalizeGuids(resultLog.not_found); const addedGuids = this._normalizeGuids(resultLog.added); if (localGuids.length > 0) { - this._notify( - this._generateResultMessage(localGuids, 'local'), + this._notifyList( + translate('COM_COMPONENTBUILDER_THESE_ITEMS_WERE_ALREADY_PRESENT_LOCALLY_AND_WERE_NOT_PULLED'), + this._getNamesFromGuids(localGuids), 'info' ); } if (notFoundGuids.length > 0) { - this._notify( - this._generateResultMessage(notFoundGuids, 'not_found'), - 'info' + this._notifyList( + translate('COM_COMPONENTBUILDER_THESE_ITEMS_COULD_NOT_BE_FOUND_IN_THE_REMOTE_REPOSITORY_AND_WERE_NOT_PULLED'), + this._getNamesFromGuids(notFoundGuids), + 'warning' ); } if (addedGuids.length > 0) { - this._notify( - this._generateResultMessage(addedGuids, 'added'), + this._notifyList( + translate('COM_COMPONENTBUILDER_THESE_ITEMS_WERE_SUCCESSFULLY_PULLED'), + this._getNamesFromGuids(addedGuids), 'success' ); } } + /** + * Normalize log values to GUID arrays. + * + * @param {*} value The raw value. + * + * @returns {Array} The GUID array. + */ _normalizeGuids(value) { - if (!value) return []; - - if (Array.isArray(value)) { - return value; + if (!value) { + return []; } - if (typeof value === 'object') { + if (Array.isArray(value)) { + return value.filter((item) => typeof item === 'string' && item !== ''); + } + + if (isPlainObject(value)) { return Object.keys(value); } return []; } - _generateResultMessage(guids, type) { - const messages = { - local: Joomla.Text._('COM_COMPONENTBUILDER_THESE_ITEMS_WERE_ALREADY_PRESENT_LOCALLY_AND_WERE_NOT_PULLED'), - not_found: Joomla.Text._('COM_COMPONENTBUILDER_THESE_ITEMS_COULD_NOT_BE_FOUND_IN_THE_REMOTE_REPOSITORY_AND_WERE_NOT_PULLED'), - added: Joomla.Text._('COM_COMPONENTBUILDER_THESE_ITEMS_WERE_SUCCESSFULLY_PULLED') - }; - + /** + * Resolve item names from GUIDs. + * + * @param {Array} guids The GUIDs. + * + * @returns {Array} The item names. + */ + _getNamesFromGuids(guids) { const names = []; for (const guid of guids) { - const item = this.selectedItems.find(i => i.guid === guid); - if (item?.name) { + const item = this.selectedItems.find((entry) => entry?.guid === guid); + + if (typeof item?.name === 'string' && item.name !== '') { names.push(item.name); } } - if (names.length === 0) { - return null; - } - - return `${messages[type]}\n
- ${names.join('\n
- ')}`; + return names; } - _transitionTo(hideEl, showEl) { - if (hideEl && showEl) { - UIkit.util.ready(() => { - UIkit.util.removeClass(hideEl, 'uk-animation-slide-top-small'); - UIkit.util.removeClass(showEl, 'uk-animation-slide-bottom-small'); - UIkit.util.addClass(hideEl, 'uk-animation-slide-top-small'); + /** + * Abort any running repository request. + * + * @returns {void} + */ + _abortRepoRequest() { + if (this.#repoRequestController) { + this.#repoRequestController.abort(); + this.#repoRequestController = null; + } + } - setTimeout(() => { + /** + * Abort any running pull request. + * + * @returns {void} + */ + _abortPullRequest() { + if (this.#pullRequestController) { + this.#pullRequestController.abort(); + this.#pullRequestController = null; + } + } + + /** + * Transition between two elements. + * + * @param {HTMLElement|null} hideEl The element to hide. + * @param {HTMLElement|null} showEl The element to show. + * + * @returns {Promise} Resolves after transition completes. + */ + _transitionTo(hideEl, showEl) { + if (!hideEl || !showEl || hideEl === showEl) { + return Promise.resolve(); + } + + const duration = 250; + + return new Promise((resolve) => { + hideEl.style.transition = `opacity ${duration}ms ease`; + showEl.style.transition = `opacity ${duration}ms ease`; + + hideEl.style.opacity = '1'; + showEl.style.display = 'none'; + showEl.style.opacity = '0'; + + requestAnimationFrame(() => { + hideEl.style.opacity = '0'; + + window.setTimeout(() => { hideEl.style.display = 'none'; showEl.style.display = ''; - UIkit.util.addClass(showEl, 'uk-animation-slide-bottom-small'); - if (this.#drawTable) this.#drawTable(); - }, 300); + showEl.style.opacity = '0'; + + requestAnimationFrame(() => { + showEl.style.opacity = '1'; + + window.setTimeout(() => { + hideEl.style.transition = ''; + hideEl.style.opacity = ''; + showEl.style.transition = ''; + showEl.style.opacity = ''; + resolve(); + }, duration); + }); + }, duration); }); + }); + } + + /** + * Show the loading overlay. + * + * @returns {void} + */ + _showLoading() { + if (this.#loadingDiv) { + this.#loadingDiv.style.display = 'block'; } } - _showLoading() { - if (this.#loadingDiv) this.#loadingDiv.style.display = 'block'; - } - + /** + * Hide the loading overlay. + * + * @returns {void} + */ _hideLoading() { - if (this.#loadingDiv) this.#loadingDiv.style.display = 'none'; + if (this.#loadingDiv) { + this.#loadingDiv.style.display = 'none'; + } } + /** + * Notify with a simple message. + * + * @param {string} message The message. + * @param {string} type The alert type. + * + * @returns {void} + */ _notify(message, type = 'info') { - const alertTypes = { - primary: 'alert-primary', - info: 'alert-info', - success: 'alert-success', - warning: 'alert-warning', - danger: 'alert-danger', - }; + if (typeof message !== 'string' || message.trim() === '') { + return; + } - const alertClass = alertTypes[type] || alertTypes.primary; + const body = document.createElement('div'); + body.textContent = message; + + this._renderAlert(body, type); + } + + /** + * Notify with a heading and a list. + * + * @param {string} heading The heading text. + * @param {Array} items The item names. + * @param {string} type The alert type. + * + * @returns {void} + */ + _notifyList(heading, items, type = 'info') { + if (!heading && (!Array.isArray(items) || items.length === 0)) { + return; + } + + const fragment = document.createDocumentFragment(); + + if (heading) { + const headingNode = document.createElement('div'); + headingNode.textContent = heading; + fragment.appendChild(headingNode); + } + + if (Array.isArray(items) && items.length > 0) { + const list = document.createElement('ul'); + list.className = 'mb-0 mt-2'; + + for (const item of items) { + const li = document.createElement('li'); + li.textContent = item; + list.appendChild(li); + } + + fragment.appendChild(list); + } + + const wrapper = document.createElement('div'); + wrapper.appendChild(fragment); + + this._renderAlert(wrapper, type); + } + + /** + * Render a Bootstrap alert. + * + * @param {Node} contentNode The content node. + * @param {string} type The alert type. + * + * @returns {void} + */ + _renderAlert(contentNode, type = 'info') { + const allowedTypes = ['primary', 'info', 'success', 'warning', 'danger']; + const alertType = allowedTypes.includes(type) ? type : 'primary'; let container = document.getElementById('alert-container'); + if (!container) { container = document.createElement('div'); container.id = 'alert-container'; container.className = 'position-fixed top-0 start-50 translate-middle-x p-3'; container.style.zIndex = '1060'; + container.style.maxWidth = 'min(92vw, 720px)'; + container.style.width = '100%'; document.body.appendChild(container); } const alert = document.createElement('div'); - alert.className = `alert ${alertClass} alert-dismissible fade show`; + alert.className = `alert alert-${alertType} alert-dismissible fade show shadow`; alert.setAttribute('role', 'alert'); - alert.innerHTML = ` - ${message} - - `; + const body = document.createElement('div'); + body.appendChild(contentNode); + + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'btn-close'; + closeButton.setAttribute('data-bs-dismiss', 'alert'); + closeButton.setAttribute('aria-label', 'Close'); + + alert.appendChild(body); + alert.appendChild(closeButton); container.appendChild(alert); - setTimeout(() => { - alert.classList.remove('show'); - alert.classList.add('hide'); - alert.addEventListener('transitionend', () => { - alert.remove(); - }); + if ( + typeof bootstrap !== 'undefined' + && bootstrap.Alert + && typeof bootstrap.Alert.getOrCreateInstance === 'function' + ) { + const instance = bootstrap.Alert.getOrCreateInstance(alert); + + window.setTimeout(() => { + try { + instance.close(); + } catch (error) { + if (alert.parentNode) { + alert.remove(); + } + } + }, 5000); + + return; + } + + window.setTimeout(() => { + alert.remove(); }, 5000); } } \ No newline at end of file diff --git a/admin/config.xml b/admin/config.xml index fbc8aec25..57123fdd1 100644 --- a/admin/config.xml +++ b/admin/config.xml @@ -73,6 +73,8 @@ + + + @@ -119,14 +125,14 @@ multiple="false" filter="CMD" required="true" - default="repeatablejcbjfive"> + default="repeatablejcbjsix"> - + diff --git a/admin/forms/admin_fields_conditions.xml b/admin/forms/admin_fields_conditions.xml index 1bffeb5b7..321f38e7d 100644 --- a/admin/forms/admin_fields_conditions.xml +++ b/admin/forms/admin_fields_conditions.xml @@ -115,7 +115,7 @@ type="subform" name="addconditions" label="COM_COMPONENTBUILDER_ADMIN_FIELDS_CONDITIONS_ADDCONDITIONS_LABEL" - layout="repeatablejcb" + layout="joomla.form.field.subform.repeatable-table" multiple="true" description="COM_COMPONENTBUILDER_ADMIN_FIELDS_CONDITIONS_ADDCONDITIONS_DESCRIPTION" default="" diff --git a/admin/forms/admin_view.xml b/admin/forms/admin_view.xml index b61acf5a8..847ae79dc 100644 --- a/admin/forms/admin_view.xml +++ b/admin/forms/admin_view.xml @@ -212,7 +212,6 @@ label="COM_COMPONENTBUILDER_ADMIN_VIEW_AJAX_INPUT_LABEL" layout="joomla.form.field.subform.repeatable-table" multiple="true" - default="" icon="list">