diff --git a/app/javascript/dashboard/helper/DOMHelpers.js b/app/javascript/dashboard/helper/DOMHelpers.js new file mode 100644 index 000000000..54fe6cd92 --- /dev/null +++ b/app/javascript/dashboard/helper/DOMHelpers.js @@ -0,0 +1,124 @@ +const SCRIPT_TYPE = 'text/javascript'; +const DATA_LOADED_ATTR = 'data-loaded'; +const SCRIPT_PROPERTIES = [ + 'defer', + 'crossOrigin', + 'noModule', + 'referrerPolicy', + 'id', +]; + +/** + * Custom error class for script loading failures. + * @extends Error + */ +class ScriptLoaderError extends Error { + /** + * Creates a new ScriptLoaderError. + * @param {string} src - The source URL of the script that failed to load. + * @param {string} message - The error message. + */ + constructor(src, message = 'Failed to load script') { + super(message); + this.name = 'ScriptLoaderError'; + this.src = src; + } + + /** + * Gets detailed error information. + * @returns {string} A string containing the error details. + */ + getErrorDetails() { + return `Failed to load script from source: ${this.src}`; + } +} + +/** + * Creates a new script element with the specified attributes. + * @param {string} src - The source URL of the script. + * @param {Object} options - Options for configuring the script element. + * @param {string} [options.type='text/javascript'] - The type of the script. + * @param {boolean} [options.async=true] - Whether the script should load asynchronously. + * @param {boolean} [options.defer] - Whether the script execution should be deferred. + * @param {string} [options.crossOrigin] - The CORS setting for the script. + * @param {boolean} [options.noModule] - Whether the script should not be treated as a JavaScript module. + * @param {string} [options.referrerPolicy] - The referrer policy for the script. + * @param {string} [options.id] - The id attribute for the script element. + * @param {Object} [options.attrs] - Additional attributes to set on the script element. + * @returns {HTMLScriptElement} The created script element. + */ +const createScriptElement = (src, options) => { + const el = document.createElement('script'); + el.type = options.type || SCRIPT_TYPE; + el.async = options.async !== false; + el.src = src; + + SCRIPT_PROPERTIES.forEach(property => { + if (property in options) { + el[property] = options[property]; + } + }); + + Object.entries(options.attrs || {}).forEach(([name, value]) => + el.setAttribute(name, value) + ); + + return el; +}; + +/** + * Finds an existing script element with the specified source URL. + * @param {string} src - The source URL to search for. + * @returns {HTMLScriptElement|null} The found script element, or null if not found. + */ +const findExistingScript = src => { + return document.querySelector(`script[src="${src}"]`); +}; + +/** + * Loads a script asynchronously and returns a promise. + * @param {string} src - The source URL of the script to load. + * @param {Object} options - Options for configuring the script element. + * @param {string} [options.type='text/javascript'] - The type of the script. + * @param {boolean} [options.async=true] - Whether the script should load asynchronously. + * @param {boolean} [options.defer] - Whether the script execution should be deferred. + * @param {string} [options.crossOrigin] - The CORS setting for the script. + * @param {boolean} [options.noModule] - Whether the script should not be treated as a JavaScript module. + * @param {string} [options.referrerPolicy] - The referrer policy for the script. + * @param {string} [options.id] - The id attribute for the script element. + * @param {Object} [options.attrs] - Additional attributes to set on the script element. + * @returns {Promise} A promise that resolves with the loaded script element, + * or false if the script couldn't be loaded. + * @throws {ScriptLoaderError} If the script fails to load. + */ +export async function loadScript(src, options) { + if (typeof window === 'undefined' || !window.document) { + return Promise.resolve(false); + } + + return new Promise((resolve, reject) => { + if (typeof src !== 'string' || src.trim() === '') { + reject(new Error('Invalid source URL provided')); + return; + } + + let el = findExistingScript(src); + + if (!el) { + el = createScriptElement(src, options); + document.head.appendChild(el); + } else if (el.hasAttribute(DATA_LOADED_ATTR)) { + resolve(el); + return; + } + + const handleError = () => reject(new ScriptLoaderError(src)); + + el.addEventListener('error', handleError); + el.addEventListener('abort', handleError); + el.addEventListener('load', () => { + el.setAttribute(DATA_LOADED_ATTR, 'true'); + resolve(el); + }); + }); +} diff --git a/app/javascript/dashboard/helper/specs/DOMHelpers.spec.js b/app/javascript/dashboard/helper/specs/DOMHelpers.spec.js new file mode 100644 index 000000000..fb3f01c45 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/DOMHelpers.spec.js @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { loadScript } from '../DOMHelpers'; +import { JSDOM } from 'jsdom'; + +describe('loadScript', () => { + let dom; + let window; + let document; + + beforeEach(() => { + dom = new JSDOM('', { + url: 'http://localhost', + }); + window = dom.window; + document = window.document; + global.document = document; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete global.document; + }); + + it('should load a script successfully', async () => { + const src = 'https://example.com/script.js'; + const loadPromise = loadScript(src, {}); + + // Simulate successful script load + setTimeout(() => { + const script = document.querySelector(`script[src="${src}"]`); + if (script) { + script.dispatchEvent(new window.Event('load')); + } + }, 0); + + const script = await loadPromise; + + expect(script).toBeTruthy(); + expect(script.getAttribute('src')).toBe(src); + expect(script.getAttribute('data-loaded')).toBe('true'); + }); + + it('should not load a script if document is not available', async () => { + delete global.document; + const result = await loadScript('https://example.com/script.js', {}); + expect(result).toBe(false); + }); + + it('should use an existing script if already present', async () => { + const src = 'https://example.com/existing-script.js'; + const existingScript = document.createElement('script'); + existingScript.src = src; + existingScript.setAttribute('data-loaded', 'true'); + document.head.appendChild(existingScript); + + const script = await loadScript(src, {}); + + expect(script).toBe(existingScript); + }); + + it('should set custom attributes on the script element', async () => { + const src = 'https://example.com/custom-script.js'; + const options = { + type: 'module', + async: false, + defer: true, + crossOrigin: 'anonymous', + noModule: true, + referrerPolicy: 'origin', + id: 'custom-script', + attrs: { 'data-custom': 'value' }, + }; + + const loadPromise = loadScript(src, options); + + // Simulate successful script load + setTimeout(() => { + const script = document.querySelector(`script[src="${src}"]`); + if (script) { + script.dispatchEvent(new window.Event('load')); + } + }, 0); + + const script = await loadPromise; + + expect(script.type).toBe('module'); + expect(script.async).toBe(false); + expect(script.defer).toBe(true); + expect(script.crossOrigin).toBe('anonymous'); + expect(script.noModule).toBe(true); + expect(script.referrerPolicy).toBe('origin'); + expect(script.id).toBe('custom-script'); + expect(script.getAttribute('data-custom')).toBe('value'); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 82e6e7c2b..eec8ccde6 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -379,6 +379,7 @@ }, "DETAILS": { "LOADING_FB": "Authenticating you with Facebook...", + "ERROR_FB_LOADING": "Error loading Facebook SDK. Please disable any ad-blockers and try again from a different browser.", "ERROR_FB_AUTH": "Something went wrong, Please refresh page...", "ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ", "ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles here.", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue index 374a88854..b52af77a6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue @@ -2,14 +2,15 @@
-