feat: add promise based loader for FB script (#9780)
 --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
124
app/javascript/dashboard/helper/DOMHelpers.js
Normal file
124
app/javascript/dashboard/helper/DOMHelpers.js
Normal file
@@ -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<HTMLScriptElement|boolean>} 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
95
app/javascript/dashboard/helper/specs/DOMHelpers.spec.js
Normal file
95
app/javascript/dashboard/helper/specs/DOMHelpers.spec.js
Normal file
@@ -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('<!DOCTYPE html><html><head></head><body></body></html>', {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -379,6 +379,7 @@
|
|||||||
},
|
},
|
||||||
"DETAILS": {
|
"DETAILS": {
|
||||||
"LOADING_FB": "Authenticating you with Facebook...",
|
"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_AUTH": "Something went wrong, Please refresh page...",
|
||||||
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
|
"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 <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
|
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
<div
|
<div
|
||||||
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
|
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
|
||||||
>
|
>
|
||||||
<div v-if="!hasLoginStarted" class="login-init h-full">
|
<div v-if="!hasLoginStarted" class="pt-[30%] h-full">
|
||||||
<a href="#" @click="startLogin()">
|
<a href="#" @click="startLogin()">
|
||||||
<img
|
<img
|
||||||
|
class="h-10 w-auto"
|
||||||
src="~dashboard/assets/images/channels/facebook_login.png"
|
src="~dashboard/assets/images/channels/facebook_login.png"
|
||||||
alt="Facebook-logo"
|
alt="Facebook-logo"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<p>
|
<p class="py-6">
|
||||||
{{
|
{{
|
||||||
useInstallationName(
|
useInstallationName(
|
||||||
$t('INBOX_MGMT.ADD.FB.HELP'),
|
$t('INBOX_MGMT.ADD.FB.HELP'),
|
||||||
@@ -99,12 +100,16 @@ import router from '../../../../index';
|
|||||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||||
import accountMixin from '../../../../../mixins/account';
|
import accountMixin from '../../../../../mixins/account';
|
||||||
|
|
||||||
|
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||||
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
|
import * as Sentry from '@sentry/browser';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
LoadingState,
|
LoadingState,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
},
|
},
|
||||||
mixins: [globalConfigMixin, accountMixin],
|
mixins: [globalConfigMixin, accountMixin, alertMixin],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isCreating: false,
|
isCreating: false,
|
||||||
@@ -147,19 +152,29 @@ export default {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
created() {
|
|
||||||
this.initFB();
|
|
||||||
this.loadFBsdk();
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initFB();
|
window.fbAsyncInit = this.runFBInit;
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
startLogin() {
|
async startLogin() {
|
||||||
this.hasLoginStarted = true;
|
this.hasLoginStarted = true;
|
||||||
this.tryFBlogin();
|
try {
|
||||||
|
// this will load the SDK in a promise, and resolve it when the sdk is loaded
|
||||||
|
// in case the SDK is already present, it will resolve immediately
|
||||||
|
await this.loadFBsdk();
|
||||||
|
this.runFBInit(); // run init anyway, `tryFBlogin` won't wait for `fbAsyncInit` otherwise.
|
||||||
|
this.tryFBlogin(); // make an attempt to login
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'ScriptLoaderError') {
|
||||||
|
// if the error was related to script loading, we show a toast
|
||||||
|
this.showAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
|
||||||
|
} else {
|
||||||
|
// if the error was anything else, we capture it and show a toast
|
||||||
|
Sentry.captureException(error);
|
||||||
|
this.showAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setPageName({ name }) {
|
setPageName({ name }) {
|
||||||
@@ -173,34 +188,21 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
initFB() {
|
runFBInit() {
|
||||||
if (window.fbSDKLoaded === undefined) {
|
FB.init({
|
||||||
window.fbAsyncInit = () => {
|
appId: window.chatwootConfig.fbAppId,
|
||||||
FB.init({
|
xfbml: true,
|
||||||
appId: window.chatwootConfig.fbAppId,
|
version: window.chatwootConfig.fbApiVersion,
|
||||||
xfbml: true,
|
status: true,
|
||||||
version: window.chatwootConfig.fbApiVersion,
|
});
|
||||||
status: true,
|
window.fbSDKLoaded = true;
|
||||||
});
|
FB.AppEvents.logPageView();
|
||||||
window.fbSDKLoaded = true;
|
|
||||||
FB.AppEvents.logPageView();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
loadFBsdk() {
|
async loadFBsdk() {
|
||||||
((d, s, id) => {
|
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
|
||||||
let js;
|
id: 'facebook-jssdk',
|
||||||
// eslint-disable-next-line
|
});
|
||||||
const fjs = (js = d.getElementsByTagName(s)[0]);
|
|
||||||
if (d.getElementById(id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
js = d.createElement(s);
|
|
||||||
js.id = id;
|
|
||||||
js.src = '//connect.facebook.net/en_US/sdk.js';
|
|
||||||
fjs.parentNode.insertBefore(js, fjs);
|
|
||||||
})(document, 'script', 'facebook-jssdk');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
tryFBlogin() {
|
tryFBlogin() {
|
||||||
@@ -285,16 +287,3 @@ export default {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<style scoped lang="scss">
|
|
||||||
.login-init {
|
|
||||||
@apply pt-[30%] text-center;
|
|
||||||
|
|
||||||
p {
|
|
||||||
@apply p-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
> a > img {
|
|
||||||
@apply w-60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<inbox-reconnection-required class="mx-8 mt-5" @reauthorize="tryFBlogin" />
|
<inbox-reconnection-required class="mx-8 mt-5" @reauthorize="startLogin" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* global FB */
|
/* global FB */
|
||||||
import InboxReconnectionRequired from '../components/InboxReconnectionRequired';
|
import InboxReconnectionRequired from '../components/InboxReconnectionRequired';
|
||||||
|
|
||||||
|
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||||
import alertMixin from 'shared/mixins/alertMixin';
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
|
import * as Sentry from '@sentry/browser';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -24,38 +27,44 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initFB();
|
window.fbAsyncInit = this.runFBInit;
|
||||||
this.loadFBsdk();
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
initFB() {
|
runFBInit() {
|
||||||
if (window.fbSDKLoaded === undefined) {
|
FB.init({
|
||||||
window.fbAsyncInit = () => {
|
appId: window.chatwootConfig.fbAppId,
|
||||||
FB.init({
|
xfbml: true,
|
||||||
appId: window.chatwootConfig.fbAppId,
|
version: window.chatwootConfig.fbApiVersion,
|
||||||
xfbml: true,
|
status: true,
|
||||||
version: window.chatwootConfig.fbApiVersion,
|
});
|
||||||
status: true,
|
window.fbSDKLoaded = true;
|
||||||
});
|
FB.AppEvents.logPageView();
|
||||||
window.fbSDKLoaded = true;
|
|
||||||
FB.AppEvents.logPageView();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
loadFBsdk() {
|
async loadFBsdk() {
|
||||||
((d, s, id) => {
|
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
|
||||||
let js;
|
id: 'facebook-jssdk',
|
||||||
// eslint-disable-next-line
|
});
|
||||||
const fjs = (js = d.getElementsByTagName(s)[0]);
|
},
|
||||||
if (d.getElementById(id)) {
|
|
||||||
return;
|
async startLogin() {
|
||||||
|
this.hasLoginStarted = true;
|
||||||
|
try {
|
||||||
|
// this will load the SDK in a promise, and resolve it when the sdk is loaded
|
||||||
|
// in case the SDK is already present, it will resolve immediately
|
||||||
|
await this.loadFBsdk();
|
||||||
|
this.runFBInit(); // run init anyway, `tryFBlogin` won't wait for `fbAsyncInit` otherwise.
|
||||||
|
this.tryFBlogin(); // make an attempt to login
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'ScriptLoaderError') {
|
||||||
|
// if the error was related to script loading, we show a toast
|
||||||
|
this.showAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
|
||||||
|
} else {
|
||||||
|
// if the error was anything else, we capture it and show a toast
|
||||||
|
Sentry.captureException(error);
|
||||||
|
this.showAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
|
||||||
}
|
}
|
||||||
js = d.createElement(s);
|
}
|
||||||
js.id = id;
|
|
||||||
js.src = 'https://connect.facebook.net/en_US/sdk.js';
|
|
||||||
fjs.parentNode.insertBefore(js, fjs);
|
|
||||||
})(document, 'script', 'facebook-jssdk');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
tryFBlogin() {
|
tryFBlogin() {
|
||||||
|
|||||||
Reference in New Issue
Block a user