Repository: braintree/braintree-web-drop-in
Pull request #705 (2021-03-03 19:43:51)

feat: add `changeActiveView` event
### Summary

Adds an event to expose when the active view changes.

### Checklist

- [x] Added a changelog entry

### Authors
> List GitHub usernames for everyone who contributed to this pull request.

- @crookedneighbor 
- @jplukarski 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ca7b0a..589755a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ unreleased
 - Improve keyboard navigation
 - Fix issue where scrollbar appears for saved payment methods (#724)
 - Fix bug in initialization process where loading spinner would never resolve (#716)
+- Add `changeActiveView` event to track when the Drop-in updates the active view presented to the customer
 
 1.27.0
 ------
diff --git a/src/dropin-model.js b/src/dropin-model.js
index 49d57fa..327812d 100644
--- a/src/dropin-model.js
+++ b/src/dropin-model.js
@@ -132,9 +132,14 @@ DropinModel.prototype.changeActivePaymentMethod = function (paymentMethod) {
   this._emit('changeActivePaymentMethod', paymentMethod);
 };
 
-DropinModel.prototype.changeActivePaymentView = function (paymentViewID) {
-  this._activePaymentView = paymentViewID;
-  this._emit('changeActivePaymentView', paymentViewID);
+DropinModel.prototype.changeActiveView = function (paymentViewID) {
+  var previousViewId = this._activePaymentViewId;
+
+  this._activePaymentViewId = paymentViewID;
+  this._emit('changeActiveView', {
+    previousViewId: previousViewId,
+    newViewId: paymentViewID
+  });
 };
 
 DropinModel.prototype.removeActivePaymentMethod = function () {
@@ -229,8 +234,8 @@ DropinModel.prototype.getActivePaymentMethod = function () {
   return this._activePaymentMethod;
 };
 
-DropinModel.prototype.getActivePaymentView = function () {
-  return this._activePaymentView;
+DropinModel.prototype.getActivePaymentViewId = function () {
+  return this._activePaymentViewId;
 };
 
 DropinModel.prototype.reportAppSwitchPayload = function (payload) {
diff --git a/src/dropin.js b/src/dropin.js
index 6a07aaa..1e2635d 100644
--- a/src/dropin.js
+++ b/src/dropin.js
@@ -26,6 +26,7 @@ var mainHTML = fs.readFileSync(__dirname + '/html/main.html', 'utf8');
 var svgHTML = fs.readFileSync(__dirname + '/html/svgs.html', 'utf8');
 
 var PASS_THROUGH_EVENTS = [
+  'changeActiveView',
   'paymentMethodRequestable',
   'noPaymentMethodRequestable',
   'paymentOptionSelected',
@@ -157,6 +158,7 @@ HAS_RAW_PAYMENT_DATA[constants.paymentMethodTypes.applePay] = true;
  * @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:
  *
+ *  * [`changeActiveView`](#event:changeActiveView)
  *  * [`paymentMethodRequestable`](#event:paymentMethodRequestable)
  *  * [`noPaymentMethodRequestable`](#event:noPaymentMethodRequestable)
  *  * [`paymentOptionSelected`](#event:paymentOptionSelected)
@@ -248,6 +250,19 @@ HAS_RAW_PAYMENT_DATA[constants.paymentMethodTypes.applePay] = true;
  *   });
  * });
  * @example
+ * <caption>Listen for when the customer navigates to different views in Drop-in</caption>
+ * braintree.dropin.create({
+ *   authorization: 'CLIENT_AUTHORIZATION',
+ *   container: '#dropin-container'
+ * }, function (err, dropinInstance) {
+ *   dropinInstance.on('changeActiveView', function (event) {
+ *     // fires when the view changes, such as going from the
+ *     // credit card view to the saved payment methods view
+ *     event.oldActivePaymentViewId; // card
+ *     event.newActivePaymentViewId; // methods
+ *   });
+ * });
+ * @example
  * <caption>Listen on various events from the card view</caption>
  * braintree.dropin.create({
  *   authorization: 'CLIENT_AUTHORIZATION',
@@ -311,6 +326,28 @@ HAS_RAW_PAYMENT_DATA[constants.paymentMethodTypes.applePay] = true;
  * @type {Dropin~paymentOptionSelectedPayload}
  */
 
+/**
+ * This event is emitted when the Drop-in view changes what is presented as the active view.
+ * @event Dropin#changeActiveView
+ * @type {Dropin~changeActiveView}
+ */
+
+/**
+ * @typedef {object} Dropin~changeActiveView
+ * @description The event payload sent from {@link Dropin#on|`on`} with the {@link Dropin#event:changeActiveView|`changeActiveView`} event.
+ * @property {string} previousViewId The id for the previously active view. Possible values are:
+ * * `card` - The credit card form view
+ * * `paypal` - The PayPal view
+ * * `payapCredit` - The PayPal Credit view
+ * * `venmo` - The Venmo View
+ * * `googlePay` - The Google Pay view
+ * * `applePay` - The Apple Pay view
+ * * `methods` - The view presenting the available payment methods (already vaulted or tokenized payment methods)
+ * * `options` - The view presenting the available payment options (where the customer chooses what payment method option to use). Note, if both the methods view and the options view are presented at the same time, `methods` will be shown as the view id.
+ * * `delete-confirmation` - The view where the customer confirms they would like to delete their saved payment method.
+ * @property {string} newViewId The id for the new active view. The possible values are the same as `previousViewId`.
+ */
+
 /**
  * The underlying [hosted fields `binAvailable` event](http://braintree.github.io/braintree-web/{@pkg bt-web-version}/HostedFields.html#event:binAvailable).
  * @event Dropin#card:binAvailable
diff --git a/src/views/main-view.js b/src/views/main-view.js
index f0a25dc..4c6f434 100644
--- a/src/views/main-view.js
+++ b/src/views/main-view.js
@@ -98,10 +98,10 @@ MainView.prototype._initialize = function () {
     }.bind(this));
   }.bind(this));
 
-  this.model.on('changeActivePaymentView', this._onChangeActivePaymentMethodView.bind(this));
+  this.model.on('changeActiveView', this._onChangeActiveView.bind(this));
 
   this.model.on('removeActivePaymentMethod', function () {
-    var activePaymentView = this.getView(this.model.getActivePaymentView());
+    var activePaymentView = this.getView(this.model.getActivePaymentViewId());
 
     if (activePaymentView && typeof activePaymentView.removeActivePaymentMethod === 'function') {
       activePaymentView.removeActivePaymentMethod();
@@ -132,7 +132,8 @@ MainView.prototype._initialize = function () {
   this._sendToDefaultView();
 };
 
-MainView.prototype._onChangeActivePaymentMethodView = function (id) {
+MainView.prototype._onChangeActiveView = function (data) {
+  var id = data.newViewId;
   var activePaymentView = this.getView(id);
 
   if (id === PaymentMethodsView.ID) {
@@ -172,7 +173,7 @@ MainView.prototype.setPrimaryView = function (id, secondaryViewId) {
   }.bind(this));
 
   this.primaryView = this.getView(id);
-  this.model.changeActivePaymentView(id);
+  this.model.changeActiveView(id);
 
   if (this.paymentSheetViewIDs.indexOf(id) !== -1) {
     if (this.model.getPaymentMethods().length > 0 || this.getView(PaymentOptionsView.ID)) {
@@ -204,7 +205,7 @@ MainView.prototype.setPrimaryView = function (id, secondaryViewId) {
 };
 
 MainView.prototype.requestPaymentMethod = function () {
-  var activePaymentView = this.getView(this.model.getActivePaymentView());
+  var activePaymentView = this.getView(this.model.getActivePaymentViewId());
 
   return activePaymentView.requestPaymentMethod().then(function (payload) {
     analytics.sendEvent(this.client, 'request-payment-method.' + analyticsKinds[payload.type]);
@@ -239,7 +240,7 @@ MainView.prototype.toggleAdditionalOptions = function () {
     sheetViewID = this.paymentSheetViewIDs[0];
 
     classList.add(this.element, prefixShowClass(sheetViewID));
-    this.model.changeActivePaymentView(sheetViewID);
+    this.model.changeActiveView(sheetViewID);
   } else if (isPaymentSheetView) {
     if (this.model.getPaymentMethods().length === 0) {
       this.setPrimaryView(PaymentOptionsView.ID);


Test patch:
diff --git a/test/app/index.html b/test/app/index.html
index 87fcd68..273f6fb 100644
--- a/test/app/index.html
+++ b/test/app/index.html
@@ -249,6 +249,9 @@
           payButton.removeAttribute('disabled');
         }
 
+        instance.on('changeActiveView', function (event) {
+          console.log('change active view', event);
+        });
         instance.on('paymentMethodRequestable', function (event) {
           if (event.paymentMethodIsSelected) {
             clearButton.className = '';
diff --git a/test/unit/dropin-model.js b/test/unit/dropin-model.js
index dbde28f..1ae25c4 100644
--- a/test/unit/dropin-model.js
+++ b/test/unit/dropin-model.js
@@ -593,6 +593,33 @@ describe('DropinModel', () => {
     );
   });
 
+  describe('changeActiveView', () => {
+    beforeEach(() => {
+      testContext.model = new DropinModel(testContext.modelOptions);
+    });
+
+    test('sets active payment method view id', () => {
+      testContext.model._activePaymentViewId = 'methods';
+
+      testContext.model.changeActiveView('card');
+
+      expect(testContext.model._activePaymentViewId).toBe('card');
+    });
+
+    test('emits event with new and old ids', () => {
+      jest.spyOn(testContext.model, '_emit');
+
+      testContext.model._activePaymentViewId = 'methods';
+
+      testContext.model.changeActiveView('card');
+
+      expect(testContext.model._emit).toBeCalledWith('changeActiveView', {
+        previousViewId: 'methods',
+        newViewId: 'card'
+      });
+    });
+  });
+
   describe('removeActivePaymentMethod', () => {
     beforeEach(() => {
       testContext.model = new DropinModel(testContext.modelOptions);
diff --git a/test/unit/dropin.js b/test/unit/dropin.js
index 01facbb..556e2df 100644
--- a/test/unit/dropin.js
+++ b/test/unit/dropin.js
@@ -1,6 +1,7 @@
 
 const Dropin = require('../../src/dropin');
 const DropinModel = require('../../src/dropin-model');
+const MainView = require('../../src/views/main-view');
 const EventEmitter = require('@braintree/event-emitter');
 const assets = require('@braintree/asset-loader');
 const analytics = require('../../src/lib/analytics');
@@ -2239,38 +2240,37 @@ describe('Dropin', () => {
     });
   });
 
-  describe('payment method requestable events', () => {
-    test(
-      'emits paymentMethodRequestable event when the model emits paymentMethodRequestable',
-      done => {
-        const instance = new Dropin(testContext.dropinOptions);
-
-        instance.on('paymentMethodRequestable', event => {
-          expect(event.type).toBe('Foo');
+  describe('passthrough drop-in events', () => {
+    beforeEach(() => {
+      // the payload for the changeActiveView event is not quite
+      // accurate and it ends up throwing an error during the other
+      // tests for the events, so we stub the method that recieves
+      // the changeActiveView event to prevent that from causing
+      // our tests to fail. Is this a bit of a smell? yes, do
+      // I have the bandwidth to figure out a better way? no.
+      // sorry future Blade.
+      jest.spyOn(MainView.prototype, '_onChangeActiveView').mockImplementation();
+    });
 
-          done();
-        });
+    test.each([
+      'changeActiveView',
+      'paymentMethodRequestable',
+      'noPaymentMethodRequestable',
+      'paymentOptionSelected'
+    ])('emits %s event', (eventName, done) => {
+      const instance = new Dropin(testContext.dropinOptions);
+      const eventPayload = { foo: 'bar' };
 
-        instance._initialize(() => {
-          instance._model._emit('paymentMethodRequestable', { type: 'Foo' });
-        });
-      }
-    );
+      instance.on(eventName, event => {
+        expect(event).toBe(eventPayload);
 
-    test(
-      'emits noPaymentMethodRequestable events when the model emits noPaymentMethodRequestable',
-      done => {
-        const instance = new Dropin(testContext.dropinOptions);
-
-        instance.on('noPaymentMethodRequestable', () => {
-          done();
-        });
+        done();
+      });
 
-        instance._initialize(() => {
-          instance._model._emit('noPaymentMethodRequestable');
-        });
-      }
-    );
+      instance._initialize(() => {
+        instance._model._emit(eventName, eventPayload);
+      });
+    });
   });
 
   describe('card events', () => {
diff --git a/test/unit/views/main-view.js b/test/unit/views/main-view.js
index aa8e9d4..27ff97f 100644
--- a/test/unit/views/main-view.js
+++ b/test/unit/views/main-view.js
@@ -395,7 +395,7 @@ describe('MainView', () => {
 
         mainView.setPrimaryView(View.ID);
 
-        expect(mainView.model.getActivePaymentView()).toBe(View.ID);
+        expect(mainView.model.getActivePaymentViewId()).toBe(View.ID);
       });
     });
 
@@ -682,7 +682,7 @@ describe('MainView', () => {
           hideSheetError: jest.fn(),
           hideLoadingIndicator: function () {},
           _sendToDefaultView: jest.fn(),
-          _onChangeActivePaymentMethodView: jest.fn(),
+          _onChangeActiveView: jest.fn(),
           model: model,
           client: fake.client(),
           setPrimaryView: jest.fn(),
@@ -815,7 +815,7 @@ describe('MainView', () => {
 
           testContext.mainView.addView(optionsView);
 
-          jest.spyOn(testContext.model, 'getActivePaymentView').mockReturnValue('options');
+          jest.spyOn(testContext.model, 'getActivePaymentViewId').mockReturnValue('options');
           testContext.model._emit('removeActivePaymentMethod');
 
           setTimeout(() => {
@@ -826,7 +826,7 @@ describe('MainView', () => {
       );
     });
 
-    describe('for changeActivePaymentView', () => {
+    describe('for changeActiveView', () => {
       beforeEach(() => {
         jest.spyOn(testContext.model, 'setPaymentMethodRequestable').mockImplementation();
         testContext.paymentMethodsContainer = testContext.element.querySelector('[data-braintree-id="methods-container"]');
@@ -842,7 +842,9 @@ describe('MainView', () => {
         test(
           'adds braintree-methods--active to the payment methods view element',
           () => {
-            testContext.model._emit('changeActivePaymentView', PaymentMethodsView.ID);
+            testContext.model._emit('changeActiveView', {
+              newViewId: PaymentMethodsView.ID
+            });
             expect(testContext.paymentMethodsContainer.className).toMatch('braintree-methods--active');
           }
         );
@@ -850,18 +852,24 @@ describe('MainView', () => {
         test(
           'removes braintree-sheet--active from the payment sheet element',
           () => {
-            testContext.model._emit('changeActivePaymentView', PaymentMethodsView.ID);
+            testContext.model._emit('changeActiveView', {
+              newViewId: PaymentMethodsView.ID
+            });
             expect(testContext.sheetElement.className).toEqual(expect.not.arrayContaining(['braintree-sheet--active']));
           }
         );
 
         test('does not call model.setPaymentMethodRequestable', () => {
-          testContext.model._emit('changeActivePaymentView', PaymentMethodsView.ID);
+          testContext.model._emit('changeActiveView', {
+            newViewId: PaymentMethodsView.ID
+          });
           expect(testContext.model.setPaymentMethodRequestable).not.toBeCalled();
         });
 
         test('calls onSelection', () => {
-          testContext.model._emit('changeActivePaymentView', PaymentMethodsView.ID);
+          testContext.model._emit('changeActiveView', {
+            newViewId: PaymentMethodsView.ID
+          });
           expect(testContext.mainView._views.methods.onSelection).toBeCalledTimes(1);
         });
       });
@@ -879,7 +887,9 @@ describe('MainView', () => {
           describe('using a ' + ID + ' sheet', () => {
             beforeEach(() => {
               wait.delay.mockResolvedValue();
-              testContext.model._emit('changeActivePaymentView', ID);
+              testContext.model._emit('changeActiveView', {
+                newViewId: ID
+              });
             });
 
             test('adds braintree-sheet--active to the payment sheet', () => {
@@ -964,7 +974,7 @@ describe('MainView', () => {
       });
 
       test('sets the payment option as the active payment view', () => {
-        expect(testContext.mainView.model.getActivePaymentView()).toBe(CardView.ID);
+        expect(testContext.mainView.model.getActivePaymentViewId()).toBe(CardView.ID);
       });
 
       test('exposes the payment sheet view', () => {
@@ -1013,7 +1023,7 @@ describe('MainView', () => {
         test('sets the PaymentMethodsView as the primary view', () => {
           expect(testContext.mainView.setPrimaryView).toBeCalledWith(PaymentMethodsView.ID, expect.anything());
           expect(testContext.wrapper.className).toMatch('braintree-show-' + PaymentMethodsView.ID);
-          expect(testContext.mainView.model.getActivePaymentView()).toBe(PaymentMethodsView.ID);
+          expect(testContext.mainView.model.getActivePaymentViewId()).toBe(PaymentMethodsView.ID);
         });
 
         test('exposes the PaymentOptionsView', () => {
@@ -1124,7 +1134,7 @@ describe('MainView', () => {
       test('requests payment method from payment methods view', () => {
         const paymentMethodsViews = testContext.mainView.getView(PaymentMethodsView.ID);
 
-        testContext.mainView.model.changeActivePaymentView(PaymentMethodsView.ID);
+        testContext.mainView.model.changeActiveView(PaymentMethodsView.ID);
         jest.spyOn(paymentMethodsViews, 'requestPaymentMethod').mockResolvedValue({});
 
         return testContext.mainView.requestPaymentMethod().then(() => {