♻️ Create generic base classes for preview buttons

This commit is contained in:
Philipp Stracker 2024-06-05 17:58:54 +02:00
parent 9342086f33
commit ebb1f6962f
No known key found for this signature in database
3 changed files with 343 additions and 241 deletions

View file

@ -0,0 +1,106 @@
import merge from "deepmerge";
/**
* Base class for APM button previews, used on the plugin's settings page.
*/
class PreviewButton {
/**
* @param {string} selector - CSS ID of the wrapper, including the `#`
* @param {object} configResponse - PayPal configuration object; retrieved via a
* widgetBuilder API method
* @param {object} defaultAttributes - Optional.
*/
constructor({selector, configResponse, defaultAttributes = {}}) {
this.configResponse = configResponse;
this.defaultAttributes = defaultAttributes;
this.buttonConfig = {};
this.ppcpConfig = {};
// Usually overwritten in constructor of derived class.
this.selector = selector;
this.domWrapper = null;
this.payButton = null;
}
/**
* Creates a new DOM node to contain the preview button.
*
* @return {jQuery} Always a single jQuery element with the new DOM node.
*/
createNewWrapper() {
const previewId = this.selector.replace('#', '')
const previewClass = 'ppcp-button-apm';
return jQuery(`<div id="${previewId}" class="${previewClass}">`)
}
/**
* Updates the internal button configuration. Does not trigger a redraw.
*
* @return {this} Reference to self, for chaining.
*/
config({buttonConfig, ppcpConfig}) {
if (ppcpConfig) {
this.ppcpConfig = merge({}, ppcpConfig);
}
if (buttonConfig) {
this.buttonConfig = merge(this.defaultAttributes, buttonConfig)
this.buttonConfig.button.wrapper = this.selector
}
return this;
}
/**
* Responsible for creating the actual payment button preview.
* Called by the `render()` method, after the wrapper DOM element is ready.
*
* @return {any} Return value is assigned to `this.payButton`
*/
createButton() {
throw new Error('The "createButton" method must be implemented by the derived class');
}
/**
* Refreshes the button in the DOM.
* Will always create a new button in the DOM.
*/
render() {
this.remove();
if (!this.buttonConfig?.button?.wrapper) {
console.error('Skip render, button is not configured yet');
return;
}
this.isVisible = true;
const newDomWrapper = this.createNewWrapper();
if (this.domWrapper?.length) {
this.domWrapper.replaceWith(newDomWrapper);
} else {
jQuery(this.ppcpConfig.button.wrapper).after(newDomWrapper);
}
this.domWrapper = newDomWrapper;
this.payButton = this.createButton();
}
remove() {
this.isVisible = false;
// The current payButtons have no remove/cleanup function.
this.payButton = null;
if (this.domWrapper?.remove) {
this.domWrapper.remove();
}
this.domWrapper = null;
}
}
export default PreviewButton;

View file

@ -0,0 +1,185 @@
import {loadCustomScript} from "@paypal/paypal-js";
import merge from "deepmerge";
/**
* Manages all PreviewButton instances of a certain payment method on the page.
*/
class PreviewButtonManager {
constructor({buttonConfig, widgetBuilder, defaultAttributes}) {
// Define the payment method name in the derived class.
this.methodName = 'UNDEFINED';
this.buttonConfig = buttonConfig;
this.widgetBuilder = widgetBuilder;
this.defaultAttributes = defaultAttributes;
this.isEnabled = true
this.buttons = {};
this.configResponse = null;
// Empty promise that resolves instantly when called.
this.bootstrapping = Promise.resolve();
// Add the bootstrap logic to the Promise chain. More `then`s are added by addButton().
this.bootstrapping = this.bootstrapping.then(() => this.bootstrap());
this.registerEventListeners();
}
/**
* Protected method that needs to be implemented by the derived class.
* Responsible for fetching and returning the PayPal configuration object for this payment
* method.
*
* @return {Promise<{}>}
*/
async fetchConfig() {
throw new Error('The "fetchConfig" method must be implemented by the derived class');
}
/**
* Protected method that needs to be implemented by the derived class.
* This method is responsible for creating a new PreviewButton instance and returning it.
*
* @param {string} wrapperId - CSS ID of the wrapper element.
* @return {PreviewButton}
*/
createButtonInst(wrapperId) {
throw new Error('The "createButtonInst" method must be implemented by the derived class');
}
registerEventListeners() {
jQuery(document).on('ppcp_paypal_render_preview', (ev, ppcpConfig) => this.addButton(ppcpConfig));
jQuery(document).on('DOMContentLoaded', () => this.bootstrapping);
}
/**
* Output an error message to the console, with a module-specific prefix.
*/
error(message, ...args) {
console.error(`${this.methodName} ${message}`, ...args)
}
/**
* Load dependencies and bootstrap the module.
* Returns a Promise that resolves once all dependencies were loaded and the module can be
* used without limitation.
*
* @return {Promise<void>}
*/
async bootstrap() {
if (!this.buttonConfig || !this.widgetBuilder) {
this.error('Button could not be configured.');
return;
}
// Load the custom SDK script.
const customScriptPromise = loadCustomScript({url: this.buttonConfig.sdk_url});
// Wait until PayPal is ready.
const paypalPromise = new Promise(resolve => {
if (this.widgetBuilder.paypal) {
resolve();
} else {
jQuery(document).on('ppcp-paypal-loaded', resolve);
}
});
await Promise.all([customScriptPromise, paypalPromise]);
this.configResponse = await this.fetchConfig();
}
/**
* Creates a new preview button, that is rendered once the bootstrapping Promise resolves.
*/
addButton(ppcpConfig) {
if (!ppcpConfig.button.wrapper) {
this.error('Button did not provide a wrapper ID', ppcpConfig)
return;
}
const createOrUpdateButton = () => {
const id = ppcpConfig.button.wrapper;
if (!this.buttons[id]) {
this.buttons[id] = this.createButtonInst(id);
}
this.buttons[id].config({
buttonConfig: this.buttonConfig,
ppcpConfig
}).render()
}
if (this.bootstrapping) {
this.bootstrapping.then(createOrUpdateButton);
} else {
createOrUpdateButton();
}
}
/**
* Changes the button configuration and re-renders all buttons.
*
* @return {this} Reference to self, for chaining.
*/
updateConfig(newConfig) {
if (!newConfig || 'object' !== typeof newConfig) {
return this;
}
this.buttonConfig = merge(this.buttonConfig, newConfig)
Object.values(this.buttons).forEach(button => button.config({buttonConfig: this.buttonConfig}))
this.renderButtons();
return this;
}
/**
* Refreshes all buttons using the latest buttonConfig.
*
* @return {this} Reference to self, for chaining.
*/
renderButtons() {
if (this.isEnabled) {
Object.values(this.buttons).forEach(button => button.render())
} else {
Object.values(this.buttons).forEach(button => button.remove())
}
return this;
}
/**
* Enables this payment method, which re-creates or refreshes all buttons.
*
* @return {this} Reference to self, for chaining.
*/
enable() {
if (!this.isEnabled) {
this.isEnabled = true;
this.renderButtons();
}
return this;
}
/**
* Disables this payment method, effectively removing all preview buttons.
*
* @return {this} Reference to self, for chaining.
*/
disable() {
if (!this.isEnabled) {
this.isEnabled = false;
this.renderButtons();
}
return this;
}
}
export default PreviewButtonManager;

View file

@ -1,7 +1,7 @@
import {loadCustomScript} from "@paypal/paypal-js";
import GooglepayButton from "./GooglepayButton"; import GooglepayButton from "./GooglepayButton";
import merge from "deepmerge";
import widgetBuilder from "../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder"; import widgetBuilder from "../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder";
import PreviewButton from "../../../ppcp-button/resources/js/modules/Renderer/PreviewButton";
import PreviewButtonManager from "../../../ppcp-button/resources/js/modules/Renderer/PreviewButtonManager";
/** /**
* Button manager instance; we usually only need a single instance of this object. * Button manager instance; we usually only need a single instance of this object.
@ -28,23 +28,7 @@ const defaultAttributes = {
*/ */
const buttonManager = () => { const buttonManager = () => {
if (!managerInstance) { if (!managerInstance) {
managerInstance = new PreviewButtonManager({ managerInstance = new GooglePayPreviewButtonManager();
// Internal, for logging.
name: 'GooglePay',
// WooCommerce configuration object.
buttonConfig: window.wc_ppcp_googlepay_admin,
// Internal widgetBuilder instance.
widgetBuilder,
// Default button styles.
defaultAttributes,
// Returns an async function that fetches the button configuration.
cbFetchConfig: () => widgetBuilder?.paypal?.Googlepay()?.config,
// Returns the CSS selector to render the given button.
cbInitButton: (button) => {
button.selector = `${button.wrapper}GooglePay`
button.customClasses = 'ppcp-button-googlepay'
}
});
} }
return managerInstance; return managerInstance;
@ -54,267 +38,94 @@ const buttonManager = () => {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/**
* Returns a new object which contains a copy of all public properties of the
* input object.
*/
const cloneObject = obj => JSON.parse(JSON.stringify(obj));
/**
* Convenience function to attach an event handler to the document node.
* The callback handler will not receive the first argument (event), as this
* argument is not used by our module.
*/
const onDocumentEvent = (name, handler) => jQuery(document).on(name, (ev, ...args) => handler(...args));
// ----------------------------------------------------------------------------
/** /**
* A single GooglePay preview button instance. * A single GooglePay preview button instance.
*/ */
class PreviewButton { class GooglePayPreviewButton extends PreviewButton {
constructor({wrapperSelector, configResponse, defaultAttributes, cbInitButton}) { constructor(args) {
// Do not clone this object. It's generated by an API and might contain methods. super(args);
this.configResponse = configResponse;
this.defaultAttributes = cloneObject(defaultAttributes); this.selector = `${args.selector}GooglePay`
this.buttonConfig = {};
this.ppcpConfig = {};
this.wrapper = wrapperSelector;
this.selector = wrapperSelector + 'Button';
this.customClasses = '';
cbInitButton(this);
this.domWrapper = jQuery(this.selector);
this.payButton = null;
} }
createNewWrapper() { createNewWrapper() {
const previewId = this.selector.replace('#', '') const element = super.createNewWrapper();
const previewClass = `ppcp-button-apm ${this.customClasses}`; element.addClass('ppcp-button-googlepay');
return element;
return jQuery(`<div id="${previewId}" class="${previewClass}">`)
} }
config(buttonConfig, ppcpConfig) { createButton() {
if (ppcpConfig) { const button = new GooglepayButton(
this.ppcpConfig = cloneObject(ppcpConfig);
}
if (buttonConfig) {
this.buttonConfig = merge(this.defaultAttributes, buttonConfig)
this.buttonConfig.button.wrapper = this.selector
}
return this;
}
render() {
this.remove();
const newDomWrapper = this.createNewWrapper();
if (this.domWrapper?.length) {
this.domWrapper.replaceWith(newDomWrapper);
} else {
jQuery(this.ppcpConfig.button.wrapper).after(newDomWrapper);
}
this.domWrapper = newDomWrapper;
this.payButton = new GooglepayButton(
'preview', 'preview',
null, null,
this.buttonConfig, this.buttonConfig,
this.ppcpConfig, this.ppcpConfig,
); );
this.payButton.init(this.configResponse); button.init(this.configResponse);
return this;
}
remove() { return button;
// The payButton has no remove/cleanup function.
this.payButton = null;
if (this.domWrapper) {
this.domWrapper.remove();
this.domWrapper = null;
}
} }
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/** /**
* Manages all GooglePay preview buttons on this page. * Manages all GooglePay preview buttons on this page.
*/ */
class PreviewButtonManager { class GooglePayPreviewButtonManager extends PreviewButtonManager {
constructor({ constructor() {
name, const args = {
buttonConfig, // WooCommerce configuration object.
widgetBuilder, buttonConfig: window.wc_ppcp_googlepay_admin,
defaultAttributes, // Internal widgetBuilder instance.
cbFetchConfig, widgetBuilder,
cbInitButton // Default button styles.
}) { defaultAttributes
this.name = name; };
this.buttonConfig = buttonConfig;
this.widgetBuilder = widgetBuilder;
this.defaultAttributes = defaultAttributes;
this.cbInitButton = cbInitButton;
this.cbFetchConfig = cbFetchConfig;
this.state = 'enabled' super(args);
this.buttons = {};
this.configResponse = null;
// Empty promise that resolves instantly when called. this.methodName = 'GooglePay';
this.bootstrapping = Promise.resolve();
// Add the bootstrap logic to the Promise chain. More `then`s are added by createButton().
this.bootstrapping = this.bootstrapping.then(() => this.bootstrap());
this.registerEventListeners();
}
registerEventListeners() {
onDocumentEvent('ppcp_paypal_render_preview', this.createButton.bind(this));
onDocumentEvent('DOMContentLoaded', () => this.bootstrapping);
} }
/** /**
* Output an error message to the console, with a module-specific prefix. * Responsible for fetching and returning the PayPal configuration object for this payment
*/ * method.
error(message, ...args) {
console.error(`${this.name} ${message}`, ...args)
}
/**
* Load dependencies and bootstrap the module.
* Returns a Promise that resolves once all dependencies were loaded and the module can be
* used without limitation.
* *
* @return {Promise<void>} * @return {Promise<{}>}
*/ */
async bootstrap() { async fetchConfig() {
if (!this.buttonConfig || !this.widgetBuilder) { const apiMethod = this.widgetBuilder?.paypal?.Googlepay()?.config
this.error('Button could not be configured.');
return; if (!apiMethod) {
this.error('configuration object cannot be retrieved from PayPal');
return {};
} }
// Load the custom SDK script. return await apiMethod();
const customScriptPromise = loadCustomScript({url: this.buttonConfig.sdk_url}); }
// Wait until PayPal is ready. /**
const paypalPromise = new Promise(resolve => { * This method is responsible for creating a new PreviewButton instance and returning it.
if (this.widgetBuilder.paypal) { *
resolve(); * @param {string} wrapperId - CSS ID of the wrapper element.
} else { * @return {GooglePayPreviewButton}
onDocumentEvent('ppcp-paypal-loaded', resolve); */
} createButtonInst(wrapperId) {
return new GooglePayPreviewButton({
selector: wrapperId,
configResponse: this.configResponse,
defaultAttributes: this.defaultAttributes
}); });
await Promise.all([customScriptPromise, paypalPromise]);
const fetchConfig = this.cbFetchConfig()
if (!fetchConfig) {
this.error('Button could not be initialized');
return;
}
this.configResponse = await fetchConfig();
}
/**
* Creates a new preview button, that is rendered once the bootstrapping Promise resolves.
*/
createButton(ppcpConfig) {
if (!ppcpConfig.button.wrapper) {
this.error('Button did not provide a selector', ppcpConfig)
return;
}
const wrapper = ppcpConfig.button.wrapper;
const createButtonInst = () => {
if (!this.buttons[wrapper]) {
this.buttons[wrapper] = new PreviewButton({
wrapperSelector: wrapper,
configResponse: this.configResponse,
defaultAttributes: this.defaultAttributes,
cbInitButton: this.cbInitButton
});
}
this.buttons[wrapper].config(
this.buttonConfig,
ppcpConfig
).render()
}
if (this.bootstrapping) {
this.bootstrapping.then(createButtonInst);
} else {
createButtonInst();
}
}
/**
* Changes the button configuration, and re-renders all buttons.
*/
updateConfig(newConfig) {
if (!newConfig || 'object' !== typeof newConfig) {
return;
}
this.buttonConfig = merge(this.buttonConfig, newConfig)
Object.values(this.buttons).forEach(button => button.config(this.buttonConfig))
this.renderButtons()
}
/**
* Refreshes all buttons using the latest buttonConfig.
*/
renderButtons() {
if ('enabled' === this.state) {
Object.values(this.buttons).forEach(button => button.render())
} else {
Object.values(this.buttons).forEach(button => button.remove())
}
}
/**
* Enables this payment method, which re-creates or refreshes all buttons.
*/
enable() {
if ('enabled' === this.state) {
return;
}
this.state = 'enabled';
this.renderButtons()
}
/**
* Disables this payment method, effectively removing all preview buttons.
*/
disable() {
if ('disabled' === this.state) {
return;
}
this.state = 'disabled';
this.renderButtons()
} }
} }
alert('GPay Boot Admin')
// Initialize the preview button manager. // Initialize the preview button manager.
buttonManager().enable() buttonManager()
// todo - Expose button manager for testing. Remove this! // todo - Expose button manager for testing. Remove this!
window.gpay = buttonManager() window.gpay = buttonManager()