Repository: braintree/braintree-web-drop-in
Pull request #663 (2020-09-16 15:48:06)

Add hf events
### Summary

We got a request to expose the underlying hosted fields events.

### Checklist

- [x] Added a changelog entry

### Authors
> List GitHub usernames for everyone who contributed to this pull request.

- @crookedneighbor 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9862189..fe96f20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
 CHANGELOG
 =========
 
+unreleased
+----------
+- Pass through all underlying hosted fields events
+
 1.24.0
 ------
 
diff --git a/src/dropin.js b/src/dropin.js
index be17010..f90fb0b 100644
--- a/src/dropin.js
+++ b/src/dropin.js
@@ -25,6 +25,21 @@ var wrapPrototype = require('@braintree/wrap-promise').wrapPrototype;
 var mainHTML = fs.readFileSync(__dirname + '/html/main.html', 'utf8');
 var svgHTML = fs.readFileSync(__dirname + '/html/svgs.html', 'utf8');
 
+var PASS_THROUGH_EVENTS = [
+  'paymentMethodRequestable',
+  'noPaymentMethodRequestable',
+  'paymentOptionSelected',
+
+  // Card View Events
+  'card:binAvailable',
+  'card:blur',
+  'card:cardTypeChange',
+  'card:empty',
+  'card:focus',
+  'card:inputSubmitRequest',
+  'card:notEmpty',
+  'card:validityChange'
+];
 var UPDATABLE_CONFIGURATION_OPTIONS = [
   paymentOptionIDs.paypal,
   paymentOptionIDs.paypalCredit,
@@ -131,9 +146,20 @@ HAS_RAW_PAYMENT_DATA[constants.paymentMethodTypes.applePay] = true;
  * @param {string} event The name of the event to which you are subscribing.
  * @param {function} handler A callback to handle the event.
  * @description Subscribes a handler function to a named event. `event` should be one of the following:
+ *
  *  * [`paymentMethodRequestable`](#event:paymentMethodRequestable)
  *  * [`noPaymentMethodRequestable`](#event:noPaymentMethodRequestable)
  *  * [`paymentOptionSelected`](#event:paymentOptionSelected)
+ *
+ *  _Card View Specific Events_
+ *  * [`card:binAvailable`](#event:card:binAvailable)
+ *  * [`card:blur`](#event:card:blur)
+ *  * [`card:cardTypeChange`](#event:card:cardTypeChange)
+ *  * [`card:empty`](#event:card:empty)
+ *  * [`card:focus`](#event:card:focus)
+ *  * [`card:inputSubmitRequest`](#event:card:inputSubmitRequest)
+ *  * [`card:notEmpty`](#event:card:notEmpty)
+ *  * [`card:validityChange`](#event:card:validityChange)
  * @returns {void}
  * @example
  * <caption>Dynamically enable or disable your submit button based on whether or not the payment method is requestable</caption>
@@ -206,6 +232,22 @@ HAS_RAW_PAYMENT_DATA[constants.paymentMethodTypes.applePay] = true;
  *     }
  *   });
  * });
+ * @example
+ * <caption>Listen on various events from the card view</caption>
+ * braintree.dropin.create({
+ *   authorization: 'CLIENT_AUTHORIZATION',
+ *   container: '#dropin-container'
+ * }, function (err, dropinInstance) {
+ *   dropinInstance.on('card:focus', function (event) {
+ *     // a card field was focussed
+ *   });
+ *   dropinInstance.on('card:blur', function (event) {
+ *     // a card field was blurred
+ *   });
+ *   dropinInstance.on('card:validityChange', function (event) {
+ *     // the card form went from invalid to valid or valid to invalid
+ *   });
+ * });
  */
 
 /**
@@ -254,6 +296,54 @@ HAS_RAW_PAYMENT_DATA[constants.paymentMethodTypes.applePay] = true;
  * @type {Dropin~paymentOptionSelectedPayload}
  */
 
+/**
+ * The underlying [hosted fields `binAvailable` event](http://braintree.github.io/braintree-web/{@pkg bt-web-version}/HostedFields.html#event:binAvailable).
+ * @event Dropin#card:binAvailable
+ * @type {Dropin~card:binAvailable}
+ */
+
+/**
+ * The underlying [hosted fields `blur` event](http://braintree.github.io/braintree-web/{@pkg bt-web-version}/HostedFields.html#event:blur).
+ * @event Dropin#card:blur
+ * @type {Dropin~card:blur}
+ */
+
+/**
+ * The underlying [hosted fields `cardTypeChange` event](http://braintree.github.io/braintree-web/{@pkg bt-web-version}/HostedFields.html#event:cardTypeChange).
+ * @event Dropin#card:cardTypeChange
+ * @type {Dropin~card:cardTypeChange}
+ */
+
+/**
+ * The underlying [hosted fields `empty` event](http://braintree.github.io/braintree-web/{@pkg bt-web-version}/HostedFields.html#event:empty).
+ * @event Dropin#card:empty
+ * @type {Dropin~card:empty}
+ */
+
+/**
+ * The underlying [hosted fields `focus` event](http://braintree.github.io/braintree-web/{@pkg bt-web-version}/HostedFields.html#event:focus).
+ * @event Dropin#card:focus
+ * @type {Dropin~card:focus}
+ */
+
+/**
+ * The underlying [hosted fields `inputSubmitRequest` event](http://braintree.github.io/braintree-web/{@pkg bt-web-version}/HostedFields.html#event:inputSubmitRequest).
+ * @event Dropin#card:inputSubmitRequest
+ * @type {Dropin~card:inputSubmitRequest}
+ */
+
+/**
+ * The underlying [hosted fields `notEmpty` event](http://braintree.github.io/braintree-web/{@pkg bt-web-version}/HostedFields.html#event:notEmpty).
+ * @event Dropin#card:notEmpty
+ * @type {Dropin~card:notEmpty}
+ */
+
+/**
+ * The underlying [hosted fields `validityChange` event](http://braintree.github.io/braintree-web/{@pkg bt-web-version}/HostedFields.html#event:validityChange).
+ * @event Dropin#card:validityChange
+ * @type {Dropin~card:validityChange}
+ */
+
 /**
  * @typedef {object} Dropin~paymentOptionSelectedPayload
  * @description The event payload sent from {@link Dropin#on|`on`} with the {@link Dropin#event:paymentOptionSelected|`paymentOptionSelected`} event.
@@ -375,16 +465,10 @@ Dropin.prototype._initialize = function (callback) {
       }
     });
 
-    self._model.on('paymentMethodRequestable', function (event) {
-      self._emit('paymentMethodRequestable', event);
-    });
-
-    self._model.on('noPaymentMethodRequestable', function () {
-      self._emit('noPaymentMethodRequestable');
-    });
-
-    self._model.on('paymentOptionSelected', function (event) {
-      self._emit('paymentOptionSelected', event);
+    PASS_THROUGH_EVENTS.forEach(function (eventName) {
+      self._model.on(eventName, function (event) {
+        self._emit(eventName, event);
+      });
     });
 
     return self._setUpDependenciesAndViews();
diff --git a/src/views/payment-sheet-views/card-view.js b/src/views/payment-sheet-views/card-view.js
index 32b89f8..12050cd 100644
--- a/src/views/payment-sheet-views/card-view.js
+++ b/src/views/payment-sheet-views/card-view.js
@@ -13,6 +13,13 @@ var Promise = require('../../lib/promise');
 
 var cardIconHTML = fs.readFileSync(__dirname + '/../../html/card-icons.html', 'utf8');
 
+var PASSTHROUGH_EVENTS = [
+  'empty',
+  // TODO should intercept this event and call tokenize
+  'inputSubmitRequest',
+  'binAvailable'
+];
+
 function CardView() {
   BaseView.apply(this, arguments);
 }
@@ -96,6 +103,12 @@ CardView.prototype.initialize = function () {
     this.hostedFieldsInstance.on('notEmpty', this._onNotEmptyEvent.bind(this));
     this.hostedFieldsInstance.on('validityChange', this._onValidityChangeEvent.bind(this));
 
+    PASSTHROUGH_EVENTS.forEach(function (eventName) {
+      this.hostedFieldsInstance.on(eventName, function (event) {
+        this.model._emit('card:' + eventName, event);
+      }.bind(this));
+    }.bind(this));
+
     this.model.asyncDependencyReady();
   }.bind(this)).catch(function (err) {
     this.model.asyncDependencyFailed({
@@ -523,6 +536,8 @@ CardView.prototype._onBlurEvent = function (event) {
     this.showFieldError('number', this.strings.unsupportedCardTypeError);
   }
 
+  this.model._emit('card:blur', event);
+
   setTimeout(function () {
     // when focusing on a field by clicking the label,
     // we need to wait a bit for the iframe to be
@@ -569,16 +584,22 @@ CardView.prototype._onCardTypeChangeEvent = function (event) {
       });
     }
   }
+
+  this.model._emit('card:cardTypeChange', event);
 };
 
 CardView.prototype._onFocusEvent = function (event) {
   var fieldGroup = this.getElementById(camelCaseToKebabCase(event.emittedBy) + '-field-group');
 
   classList.add(fieldGroup, 'braintree-form__field-group--is-focused');
+
+  this.model._emit('card:focus', event);
 };
 
 CardView.prototype._onNotEmptyEvent = function (event) {
   this.hideFieldError(event.emittedBy);
+
+  this.model._emit('card:notEmpty', event);
 };
 
 CardView.prototype._onValidityChangeEvent = function (event) {
@@ -598,6 +619,8 @@ CardView.prototype._onValidityChangeEvent = function (event) {
   }
 
   this._sendRequestableEvent();
+
+  this.model._emit('card:validityChange', event);
 };
 
 CardView.prototype.requestPaymentMethod = function () {


Test patch:
diff --git a/test/helpers/fake.js b/test/helpers/fake.js
index 7e90d3c..b22ea39 100644
--- a/test/helpers/fake.js
+++ b/test/helpers/fake.js
@@ -110,6 +110,7 @@ function model(options) {
   modelInstance = new DropinModel(options);
 
   jest.spyOn(modelInstance, 'getVaultedPaymentMethods').mockResolvedValue([]);
+  jest.spyOn(modelInstance, '_emit');
 
   return modelInstance;
 }
diff --git a/test/unit/dropin.js b/test/unit/dropin.js
index afe3530..b4d6ec9 100644
--- a/test/unit/dropin.js
+++ b/test/unit/dropin.js
@@ -2422,6 +2422,31 @@ describe('Dropin', () => {
     );
   });
 
+  describe('card events', () => {
+    test.each([
+      'binAvailable',
+      'blur',
+      'cardTypeChange',
+      'empty',
+      'focus',
+      'inputSubmitRequest',
+      'notEmpty',
+      'validityChange'
+    ])('emits card:%s event', (eventName, done) => {
+      const instance = new Dropin(testContext.dropinOptions);
+      const payload = {};
+
+      instance.on(`card:${eventName}`, (emittedPayload) => {
+        expect(emittedPayload).toBe(payload);
+        done();
+      });
+
+      instance._initialize(() => {
+        instance._model._emit(`card:${eventName}`, payload);
+      });
+    });
+  });
+
   describe('payment option selected event', () => {
     test(
       'emits paymentOptionSelected when the model emits paymentOptionSelected',
diff --git a/test/unit/views/payment-sheet-views/card-view.js b/test/unit/views/payment-sheet-views/card-view.js
index b4ccffe..a95ba83 100644
--- a/test/unit/views/payment-sheet-views/card-view.js
+++ b/test/unit/views/payment-sheet-views/card-view.js
@@ -863,6 +863,12 @@ describe('CardView', () => {
         });
       });
 
+      test('emits a focus event on the model', () => {
+        return cardView.initialize().then(() => {
+          expect(cardView.model._emit).toBeCalledWith('card:focus', eventPayload);
+        });
+      });
+
       test('shows default card icon in number field when focused', () => {
         return cardView.initialize().then(() => {
           const cardNumberIcon = cardElement.querySelector('[data-braintree-id="card-number-icon"]');
@@ -913,6 +919,24 @@ describe('CardView', () => {
         });
       });
 
+      test('emits blur event on model', () => {
+        const numberFieldGroup = cardElement.querySelector('[data-braintree-id="number-field-group"]');
+
+        eventPayload = {
+          cards: [{ type: 'visa' }],
+          emittedBy: 'number',
+          fields: {
+            number: { isEmpty: true }
+          }
+        };
+
+        classList.add(numberFieldGroup, 'braintree-form__field-group--is-focused');
+
+        return cardView.initialize().then(() => {
+          expect(cardView.model._emit).toBeCalledWith('card:blur', eventPayload);
+        });
+      });
+
       test(
         'removes braintree-form__field-group--is-focused class when blurred',
         () => {
@@ -1168,6 +1192,17 @@ describe('CardView', () => {
         });
       });
 
+      test('emits card type event on model', () => {
+        eventPayload = {
+          cards: [{ type: 'master-card' }],
+          emittedBy: 'number'
+        };
+
+        return cardView.initialize().then(() => {
+          expect(cardView.model._emit).toBeCalledWith('card:cardTypeChange', eventPayload);
+        });
+      });
+
       test(
         'adds the card-type-known class when there is one possible card type',
         () => {
@@ -1469,6 +1504,25 @@ describe('CardView', () => {
         });
       });
 
+      test('emits validity change event on model', () => {
+        eventPayload = {
+          emittedBy: 'number',
+          cards: [{ type: 'visa' }],
+          fields: {
+            number: {
+              container: document.createElement('div'),
+              isEmpty: false,
+              isValid: false,
+              isPotentiallyValid: true
+            }
+          }
+        };
+
+        return cardView.initialize().then(() => {
+          expect(cardView.model._emit).toBeCalledWith('card:validityChange', eventPayload);
+        });
+      });
+
       test(
         'removes the braintree-form__field-group--has-error class if a field is potentially valid',
         () => {
@@ -1738,6 +1792,25 @@ describe('CardView', () => {
         });
       });
 
+      test('emits not empty event on model', () => {
+        eventPayload = {
+          emittedBy: 'number',
+          cards: [{ type: 'visa' }],
+          fields: {
+            number: {
+              container: document.createElement('div'),
+              isEmpty: false,
+              isValid: false,
+              isPotentiallyValid: true
+            }
+          }
+        };
+
+        return cardView.initialize().then(() => {
+          expect(cardView.model._emit).toBeCalledWith('card:notEmpty', eventPayload);
+        });
+      });
+
       test('removes the braintree-form__field-group--has-error class', () => {
         const numberFieldGroup = cardElement.querySelector('[data-braintree-id="number-field-group"]');
 
@@ -1761,6 +1834,31 @@ describe('CardView', () => {
         });
       });
     });
+
+    describe('passthrough events', () => {
+      let eventPayload, eventNameToIntercept;
+
+      beforeEach(() => {
+        eventPayload = { emittedBy: 'number' };
+        fakeHostedFieldsInstance.on.mockImplementation((eventName, handler) => {
+          if (eventName === eventNameToIntercept) {
+            handler(eventPayload);
+          }
+        });
+      });
+
+      test.each([
+        'empty',
+        'inputSubmitRequest',
+        'binAvailable'
+      ])('passes along %s event', (eventName) => {
+        eventNameToIntercept = eventName;
+
+        return cardView.initialize().then(() => {
+          expect(cardView.model._emit).toBeCalledWith(`card:${eventName}`, eventPayload);
+        });
+      });
+    });
   });
 
   describe('tokenize', () => {