Commit 143d35c3 authored by Jeremy Jackson's avatar Jeremy Jackson Committed by Clement Ho

Improve tracking performance

This restructures how tracking is implemented and will enable
better tracking on DOM changes as it eliminates the need to
re-look up elements.
parent 80eca67f
export const initSidebarTracking = () => {};
export const trackEvent = () => {};
// Noop function which has a EE counter-part
export default () => {};
import Vue from 'vue'; import Vue from 'vue';
import { initSidebarTracking } from 'ee_else_ce/event_tracking/issue_sidebar';
import issuableApp from './components/app.vue'; import issuableApp from './components/app.vue';
import { parseIssuableData } from './utils/parse_data'; import { parseIssuableData } from './utils/parse_data';
...@@ -9,9 +8,6 @@ export default function initIssueableApp() { ...@@ -9,9 +8,6 @@ export default function initIssueableApp() {
components: { components: {
issuableApp, issuableApp,
}, },
mounted() {
initSidebarTracking();
},
render(createElement) { render(createElement) {
return createElement('issuable-app', { return createElement('issuable-app', {
props: parseIssuableData(), props: parseIssuableData(),
......
...@@ -19,7 +19,9 @@ export default { ...@@ -19,7 +19,9 @@ export default {
<gl-button <gl-button
ref="button" ref="button"
v-gl-tooltip v-gl-tooltip
class="note-action-button js-note-action-reply" class="note-action-button"
data-track-event="click_button"
data-track-label="reply_comment_button"
variant="transparent" variant="transparent"
:title="__('Reply to comment')" :title="__('Reply to comment')"
@click="$emit('startReplying')" @click="$emit('startReplying')"
......
import Vue from 'vue'; import Vue from 'vue';
import initNoteStats from 'ee_else_ce/event_tracking/notes';
import notesApp from './components/notes_app.vue'; import notesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters'; import initDiscussionFilters from './discussion_filters';
import createStore from './stores'; import createStore from './stores';
...@@ -39,9 +38,6 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -39,9 +38,6 @@ document.addEventListener('DOMContentLoaded', () => {
notesData: JSON.parse(notesDataset.notesData), notesData: JSON.parse(notesDataset.notesData),
}; };
}, },
mounted() {
initNoteStats();
},
render(createElement) { render(createElement) {
return createElement('notes-app', { return createElement('notes-app', {
props: { props: {
......
<script> <script>
import { n__ } from '~/locale'; import { n__ } from '~/locale';
import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
export default { export default {
name: 'AssigneeTitle', name: 'AssigneeTitle',
...@@ -30,11 +29,6 @@ export default { ...@@ -30,11 +29,6 @@ export default {
return n__('Assignee', `%d Assignees`, assignees); return n__('Assignee', `%d Assignees`, assignees);
}, },
}, },
methods: {
trackEdit() {
trackEvent('click_edit_button', 'assignee');
},
},
}; };
</script> </script>
<template> <template>
...@@ -45,7 +39,9 @@ export default { ...@@ -45,7 +39,9 @@ export default {
v-if="editable" v-if="editable"
class="js-sidebar-dropdown-toggle edit-link float-right" class="js-sidebar-dropdown-toggle edit-link float-right"
href="#" href="#"
@click.prevent="trackEdit" data-track-event="click_edit_button"
data-track-label="right_sidebar"
data-track-property="assignee"
> >
{{ __('Edit') }} {{ __('Edit') }}
</a> </a>
......
...@@ -5,7 +5,6 @@ import tooltip from '~/vue_shared/directives/tooltip'; ...@@ -5,7 +5,6 @@ import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue'; import editForm from './edit_form.vue';
import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
export default { export default {
components: { components: {
...@@ -52,11 +51,6 @@ export default { ...@@ -52,11 +51,6 @@ export default {
toggleForm() { toggleForm() {
this.edit = !this.edit; this.edit = !this.edit;
}, },
onEditClick() {
this.toggleForm();
trackEvent('click_edit_button', 'confidentiality');
},
updateConfidentialAttribute(confidential) { updateConfidentialAttribute(confidential) {
this.service this.service
.update('issue', { confidential }) .update('issue', { confidential })
...@@ -88,7 +82,10 @@ export default { ...@@ -88,7 +82,10 @@ export default {
v-if="isEditable" v-if="isEditable"
class="float-right confidential-edit" class="float-right confidential-edit"
href="#" href="#"
@click.prevent="onEditClick" data-track-event="click_edit_button"
data-track-label="right_sidebar"
data-track-property="confidentiality"
@click.prevent="toggleForm"
> >
{{ __('Edit') }} {{ __('Edit') }}
</a> </a>
......
...@@ -6,7 +6,6 @@ import issuableMixin from '~/vue_shared/mixins/issuable'; ...@@ -6,7 +6,6 @@ import issuableMixin from '~/vue_shared/mixins/issuable';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue'; import editForm from './edit_form.vue';
import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
export default { export default {
components: { components: {
...@@ -66,11 +65,6 @@ export default { ...@@ -66,11 +65,6 @@ export default {
toggleForm() { toggleForm() {
this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen; this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen;
}, },
onEditClick() {
this.toggleForm();
trackEvent('click_edit_button', 'lock_issue');
},
updateLockedAttribute(locked) { updateLockedAttribute(locked) {
this.mediator.service this.mediator.service
.update(this.issuableType, { .update(this.issuableType, {
...@@ -114,7 +108,10 @@ export default { ...@@ -114,7 +108,10 @@ export default {
v-if="isEditable" v-if="isEditable"
class="float-right lock-edit" class="float-right lock-edit"
type="button" type="button"
@click.prevent="onEditClick" data-track-event="click_edit_button"
data-track-label="right_sidebar"
data-track-property="lock_issue"
@click.prevent="toggleForm"
> >
{{ __('Edit') }} {{ __('Edit') }}
</button> </button>
......
<script> <script>
import { __ } from '~/locale'; import { __ } from '~/locale';
import Tracking from '~/tracking';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import toggleButton from '~/vue_shared/components/toggle_button.vue'; import toggleButton from '~/vue_shared/components/toggle_button.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
const ICON_ON = 'notifications'; const ICON_ON = 'notifications';
const ICON_OFF = 'notifications-off'; const ICON_OFF = 'notifications-off';
...@@ -19,6 +19,7 @@ export default { ...@@ -19,6 +19,7 @@ export default {
icon, icon,
toggleButton, toggleButton,
}, },
mixins: [Tracking.mixin({ label: 'right_sidebar' })],
props: { props: {
loading: { loading: {
type: Boolean, type: Boolean,
...@@ -65,7 +66,10 @@ export default { ...@@ -65,7 +66,10 @@ export default {
// Component event emission. // Component event emission.
this.$emit('toggleSubscription', this.id); this.$emit('toggleSubscription', this.id);
trackEvent('toggle_button', 'notifications', this.subscribed ? 0 : 1); this.track('toggle_button', {
property: 'notifications',
value: this.subscribed ? 0 : 1,
});
}, },
onClickCollapsedIcon() { onClickCollapsedIcon() {
this.$emit('toggleSidebar'); this.$emit('toggleSidebar');
......
import $ from 'jquery'; import _ from 'underscore';
const DEFAULT_SNOWPLOW_OPTIONS = { const DEFAULT_SNOWPLOW_OPTIONS = {
namespace: 'gl', namespace: 'gl',
...@@ -14,18 +14,31 @@ const DEFAULT_SNOWPLOW_OPTIONS = { ...@@ -14,18 +14,31 @@ const DEFAULT_SNOWPLOW_OPTIONS = {
linkClickTracking: false, linkClickTracking: false,
}; };
const extractData = (el, opts = {}) => { const eventHandler = (e, func, opts = {}) => {
const { trackEvent, trackLabel = '', trackProperty = '' } = el.dataset; const el = e.target.closest('[data-track-event]');
let trackValue = el.dataset.trackValue || el.value || ''; const action = el && el.dataset.trackEvent;
if (el.type === 'checkbox' && !el.checked) trackValue = false; if (!action) return;
return [
trackEvent + (opts.suffix || ''), let value = el.dataset.trackValue || el.value || undefined;
{ if (el.type === 'checkbox' && !el.checked) value = false;
label: trackLabel,
property: trackProperty, const data = {
value: trackValue, label: el.dataset.trackLabel,
}, property: el.dataset.trackProperty,
]; value,
context: el.dataset.trackContext,
};
func(opts.category, action + (opts.suffix || ''), _.omit(data, _.isUndefined));
};
const eventHandlers = (category, func) => {
const handler = opts => e => eventHandler(e, func, { ...{ category }, ...opts });
const handlers = [];
handlers.push({ name: 'click', func: handler() });
handlers.push({ name: 'show.bs.dropdown', func: handler({ suffix: '_show' }) });
handlers.push({ name: 'hide.bs.dropdown', func: handler({ suffix: '_hide' }) });
return handlers;
}; };
export default class Tracking { export default class Tracking {
...@@ -39,49 +52,43 @@ export default class Tracking { ...@@ -39,49 +52,43 @@ export default class Tracking {
return typeof window.snowplow === 'function' && this.trackable(); return typeof window.snowplow === 'function' && this.trackable();
} }
static event(category = document.body.dataset.page, event = 'generic', data = {}) { static event(category = document.body.dataset.page, action = 'generic', data = {}) {
if (!this.enabled()) return false; if (!this.enabled()) return false;
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
if (!category) throw new Error('Tracking: no category provided for tracking.'); if (!category) throw new Error('Tracking: no category provided for tracking.');
return window.snowplow( const { label, property, value, context } = data;
'trackStructEvent', const contexts = context ? [context] : undefined;
category, return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
event,
Object.assign({}, { label: '', property: '', value: '' }, data),
);
} }
constructor(category = document.body.dataset.page) { static bindDocument(category = document.body.dataset.page, documentOverride = null) {
this.category = category; const el = documentOverride || document;
} if (!this.enabled() || el.trackingBound) return [];
bind(container = document) {
if (!this.constructor.enabled()) return;
container.querySelectorAll(`[data-track-event]`).forEach(el => {
if (this.customHandlingFor(el)) return;
// jquery is required for select2, so we use it always
// see: https://github.com/select2/select2/issues/4686
$(el).on('click', this.eventHandler(this.category));
});
}
customHandlingFor(el) { el.trackingBound = true;
const classes = el.classList;
// bootstrap dropdowns const handlers = eventHandlers(category, (...args) => this.event(...args));
if (classes.contains('dropdown')) { handlers.forEach(event => el.addEventListener(event.name, event.func));
$(el).on('show.bs.dropdown', this.eventHandler(this.category, { suffix: '_show' })); return handlers;
$(el).on('hide.bs.dropdown', this.eventHandler(this.category, { suffix: '_hide' }));
return true;
}
return false;
} }
eventHandler(category = null, opts = {}) { static mixin(opts) {
return e => { return {
this.constructor.event(category || this.category, ...extractData(e.currentTarget, opts)); data() {
return {
tracking: {
// eslint-disable-next-line no-underscore-dangle
category: this.$options.name || this.$options._componentTag,
},
};
},
methods: {
track(action, data) {
const category = opts.category || data.category || this.tracking.category;
Tracking.event(category || 'unspecified', action, { ...opts, ...this.tracking, ...data });
},
},
}; };
} }
} }
...@@ -89,7 +96,7 @@ export default class Tracking { ...@@ -89,7 +96,7 @@ export default class Tracking {
export function initUserTracking() { export function initUserTracking() {
if (!Tracking.enabled()) return; if (!Tracking.enabled()) return;
const opts = Object.assign({}, DEFAULT_SNOWPLOW_OPTIONS, window.snowplowOptions); const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions };
window.snowplow('newTracker', opts.namespace, opts.hostname, opts); window.snowplow('newTracker', opts.namespace, opts.hostname, opts);
window.snowplow('enableActivityTracking', 30, 30); window.snowplow('enableActivityTracking', 30, 30);
...@@ -97,4 +104,6 @@ export function initUserTracking() { ...@@ -97,4 +104,6 @@ export function initUserTracking() {
if (opts.formTracking) window.snowplow('enableFormTracking'); if (opts.formTracking) window.snowplow('enableFormTracking');
if (opts.linkClickTracking) window.snowplow('enableLinkClickTracking'); if (opts.linkClickTracking) window.snowplow('enableLinkClickTracking');
Tracking.bindDocument();
} }
import Tracking from '~/tracking';
export const initSidebarTracking = () => {
new Tracking().bind(document.querySelector('.js-issuable-sidebar'));
};
export const trackEvent = (eventType, property, value = '') => {
Tracking.event(document.body.dataset.page, eventType, {
label: 'right_sidebar',
property,
value,
});
};
...@@ -27,8 +27,6 @@ export default function trackNavbarEvents() { ...@@ -27,8 +27,6 @@ export default function trackNavbarEvents() {
const navbar = document.querySelector('.navbar-gitlab'); const navbar = document.querySelector('.navbar-gitlab');
if (!navbar) return; if (!navbar) return;
new Tracking(TRACKING_CATEGORY).bind(navbar);
// track search inputs within frequent-items component // track search inputs within frequent-items component
navbar.querySelectorAll(`.frequent-items-dropdown-container input`).forEach(el => { navbar.querySelectorAll(`.frequent-items-dropdown-container input`).forEach(el => {
el.addEventListener('click', e => { el.addEventListener('click', e => {
......
import Tracking from '~/tracking';
export default () => {
document.querySelector('.main-notes-list').addEventListener('click', event => {
const isReplyButtonClick = event.target.parentElement.classList.contains(
'js-note-action-reply',
);
if (isReplyButtonClick) {
Tracking.event(document.body.dataset.page, 'click_button', {
label: 'reply_comment_button',
property: '',
value: '',
});
}
});
new Tracking().bind();
};
import '~/pages/projects/issues/index/index'; import '~/pages/projects/issues/index/index';
import Tracking from '~/tracking';
document.addEventListener('DOMContentLoaded', () => {
new Tracking().bind();
});
import '~/pages/projects/new/index'; import '~/pages/projects/new/index';
import initCustomProjectTemplates from 'ee/projects/custom_project_templates'; import initCustomProjectTemplates from 'ee/projects/custom_project_templates';
import Tracking from '~/tracking';
import { bindOnboardingEvents } from 'ee/onboarding/new_project'; import { bindOnboardingEvents } from 'ee/onboarding/new_project';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initCustomProjectTemplates(); initCustomProjectTemplates();
new Tracking().bind();
bindOnboardingEvents(document.getElementById('new_project')); bindOnboardingEvents(document.getElementById('new_project'));
}); });
import '~/pages/sessions/index'; import '~/pages/sessions/index';
import initSignInRegisterTracking from './sign_in_register_tracking';
document.addEventListener('DOMContentLoaded', initSignInRegisterTracking);
import Tracking from '~/tracking';
export default () => {
const container = document.getElementById('#signin-container');
new Tracking().bind(container);
};
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { trackEvent } from 'ee/event_tracking/issue_sidebar';
export default { export default {
components: { components: {
...@@ -15,6 +15,7 @@ export default { ...@@ -15,6 +15,7 @@ export default {
directives: { directives: {
tooltip, tooltip,
}, },
mixins: [Tracking.mixin({ label: 'right_sidebar' })],
props: { props: {
fetching: { fetching: {
type: Boolean, type: Boolean,
...@@ -105,7 +106,7 @@ export default { ...@@ -105,7 +106,7 @@ export default {
onEditClick(shouldShowEditField = true) { onEditClick(shouldShowEditField = true) {
this.showEditField(shouldShowEditField); this.showEditField(shouldShowEditField);
trackEvent('click_edit_button', 'weight'); this.track('click_edit_button', { property: 'weight' });
}, },
showEditField(bool = true) { showEditField(bool = true) {
this.shouldShowEditField = bool; this.shouldShowEditField = bool;
......
import Tracking from '~/tracking';
import { initSidebarTracking } from 'ee/event_tracking/issue_sidebar';
describe('ee/event_tracking/issue_sidebar', () => {
beforeEach(() => {
setFixtures(`
<div>
<div class="js-issuable-sidebar">I'm an issuable sidebar</div>
</div>
`);
});
const findIssuableSidebar = () => document.querySelector('.js-issuable-sidebar');
describe('initSidebarTracking', () => {
beforeEach(() => {
jest.spyOn(Tracking.prototype, 'bind');
initSidebarTracking();
});
it('bind to be called with element', () => {
expect(Tracking.prototype.bind).toHaveBeenCalledWith(findIssuableSidebar());
});
});
});
import Vue from 'vue';
import Tracking from '~/tracking';
import { shallowMount } from '@vue/test-utils';
import initNoteStats from 'ee_else_ce/event_tracking/notes';
jest.mock('~/tracking');
describe('initNoteStats', () => {
let wrapper;
const createComponent = template => {
const component = Vue.component('Notes', {
name: 'Notes',
template,
});
return shallowMount(component, { attachToDocument: true });
};
afterEach(() => {
jest.clearAllMocks();
wrapper.destroy();
});
describe('is a reply', () => {
beforeEach(() => {
wrapper = createComponent(
"<div class='js-note-action-reply'><button class='main-notes-list'></button></div>",
);
initNoteStats();
});
it('calls bindTrackableContainer', () => {
expect(Tracking.prototype.bind).toHaveBeenCalledTimes(1);
});
it('calls trackEvent', () => {
wrapper.find('.main-notes-list').trigger('click');
expect(Tracking.event).toHaveBeenCalledTimes(1);
});
});
describe('is not a reply', () => {
it('does not call trackEvent', () => {
wrapper = createComponent("<div><button class='main-notes-list'></button></div>");
initNoteStats();
wrapper.find('.main-notes-list').trigger('click');
expect(Tracking.event).not.toHaveBeenCalled();
});
});
});
import testAction from 'spec/helpers/vuex_action_helper'; import testAction from 'spec/helpers/vuex_action_helper';
import Tracking from '~/tracking';
import createState from 'ee/security_dashboard/store/modules/filters/state'; import createState from 'ee/security_dashboard/store/modules/filters/state';
import * as types from 'ee/security_dashboard/store/modules/filters/mutation_types'; import * as types from 'ee/security_dashboard/store/modules/filters/mutation_types';
import module, * as actions from 'ee/security_dashboard/store/modules/filters/actions'; import module, * as actions from 'ee/security_dashboard/store/modules/filters/actions';
describe('filters actions', () => { describe('filters actions', () => {
beforeEach(() => {
spyOn(Tracking, 'event');
});
describe('setFilter', () => { describe('setFilter', () => {
it('should commit the SET_FILTER mutuation', done => { it('should commit the SET_FILTER mutuation', done => {
const state = createState(); const state = createState();
......
...@@ -3,6 +3,7 @@ import weight from 'ee/sidebar/components/weight/weight.vue'; ...@@ -3,6 +3,7 @@ import weight from 'ee/sidebar/components/weight/weight.vue';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
import { ENTER_KEY_CODE } from '~/lib/utils/keycodes'; import { ENTER_KEY_CODE } from '~/lib/utils/keycodes';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTracking, triggerEvent } from 'spec/helpers/tracking_helper';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
weightNoneValue: 'None', weightNoneValue: 'None',
...@@ -11,10 +12,8 @@ const DEFAULT_PROPS = { ...@@ -11,10 +12,8 @@ const DEFAULT_PROPS = {
describe('Weight', function() { describe('Weight', function() {
let vm; let vm;
let Weight; let Weight;
let statsSpy;
beforeEach(() => { beforeEach(() => {
statsSpy = spyOnDependency(weight, 'trackEvent');
Weight = Vue.extend(weight); Weight = Vue.extend(weight);
}); });
...@@ -117,11 +116,12 @@ describe('Weight', function() { ...@@ -117,11 +116,12 @@ describe('Weight', function() {
editable: true, editable: true,
}); });
vm.$el.querySelector('.js-weight-edit-link').click(); const spy = mockTracking('_category_', vm.$el, spyOn, afterEach);
triggerEvent('.js-weight-edit-link');
vm.$nextTick() vm.$nextTick()
.then(() => { .then(() => {
expect(statsSpy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
......
import $ from 'jquery';
import { setHTMLFixture } from './helpers/fixtures'; import { setHTMLFixture } from './helpers/fixtures';
import Tracking, { initUserTracking } from '~/tracking'; import Tracking, { initUserTracking } from '~/tracking';
describe('Tracking', () => { describe('Tracking', () => {
let snowplowSpy; let snowplowSpy;
let bindDocumentSpy;
beforeEach(() => { beforeEach(() => {
window.snowplow = window.snowplow || (() => {}); window.snowplow = window.snowplow || (() => {});
...@@ -17,6 +16,10 @@ describe('Tracking', () => { ...@@ -17,6 +16,10 @@ describe('Tracking', () => {
}); });
describe('initUserTracking', () => { describe('initUserTracking', () => {
beforeEach(() => {
bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null);
});
it('calls through to get a new tracker with the expected options', () => { it('calls through to get a new tracker with the expected options', () => {
initUserTracking(); initUserTracking();
expect(snowplowSpy).toHaveBeenCalledWith('newTracker', '_namespace_', 'app.gitfoo.com', { expect(snowplowSpy).toHaveBeenCalledWith('newTracker', '_namespace_', 'app.gitfoo.com', {
...@@ -50,6 +53,11 @@ describe('Tracking', () => { ...@@ -50,6 +53,11 @@ describe('Tracking', () => {
expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking');
expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking'); expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking');
}); });
it('binds the document event handling', () => {
initUserTracking();
expect(bindDocumentSpy).toHaveBeenCalled();
});
}); });
describe('.event', () => { describe('.event', () => {
...@@ -62,11 +70,15 @@ describe('Tracking', () => { ...@@ -62,11 +70,15 @@ describe('Tracking', () => {
it('tracks to snowplow (our current tracking system)', () => { it('tracks to snowplow (our current tracking system)', () => {
Tracking.event('_category_', '_eventName_', { label: '_label_' }); Tracking.event('_category_', '_eventName_', { label: '_label_' });
expect(snowplowSpy).toHaveBeenCalledWith('trackStructEvent', '_category_', '_eventName_', { expect(snowplowSpy).toHaveBeenCalledWith(
label: '_label_', 'trackStructEvent',
property: '', '_category_',
value: '', '_eventName_',
}); '_label_',
undefined,
undefined,
undefined,
);
}); });
it('skips tracking if snowplow is unavailable', () => { it('skips tracking if snowplow is unavailable', () => {
...@@ -99,83 +111,70 @@ describe('Tracking', () => { ...@@ -99,83 +111,70 @@ describe('Tracking', () => {
}); });
describe('tracking interface events', () => { describe('tracking interface events', () => {
let eventSpy = null; let eventSpy;
let subject = null;
const trigger = (selector, eventName = 'click') => {
const event = new Event(eventName, { bubbles: true });
document.querySelector(selector).dispatchEvent(event);
};
beforeEach(() => { beforeEach(() => {
eventSpy = jest.spyOn(Tracking, 'event'); eventSpy = jest.spyOn(Tracking, 'event');
subject = new Tracking('_category_'); Tracking.bindDocument('_category_'); // only happens once
setHTMLFixture(` setHTMLFixture(`
<input data-track-event="click_input1" data-track-label="_label_" value="_value_"/> <input data-track-event="click_input1" data-track-label="_label_" value="_value_"/>
<input data-track-event="click_input2" data-track-value="_value_override_" value="_value_"/> <input data-track-event="click_input2" data-track-value="_value_override_" value="_value_"/>
<input type="checkbox" data-track-event="toggle_checkbox" value="_value_" checked/> <input type="checkbox" data-track-event="toggle_checkbox" value="_value_" checked/>
<input class="dropdown" data-track-event="toggle_dropdown"/> <input class="dropdown" data-track-event="toggle_dropdown"/>
<div class="js-projects-list-holder"></div> <div data-track-event="nested_event"><span class="nested"></span></div>
`); `);
}); });
it('binds to clicks on elements matching [data-track-event]', () => { it('binds to clicks on elements matching [data-track-event]', () => {
subject.bind(document); trigger('[data-track-event="click_input1"]');
$('[data-track-event="click_input1"]').click();
expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input1', { expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input1', {
label: '_label_', label: '_label_',
value: '_value_', value: '_value_',
property: '',
}); });
}); });
it('allows value override with the data-track-value attribute', () => { it('allows value override with the data-track-value attribute', () => {
subject.bind(document); trigger('[data-track-event="click_input2"]');
$('[data-track-event="click_input2"]').click();
expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input2', { expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input2', {
label: '',
value: '_value_override_', value: '_value_override_',
property: '',
}); });
}); });
it('handles checkbox values correctly', () => { it('handles checkbox values correctly', () => {
subject.bind(document); trigger('[data-track-event="toggle_checkbox"]'); // checking
const $checkbox = $('[data-track-event="toggle_checkbox"]');
$checkbox.click(); // unchecking
expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', { expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', {
label: '',
property: '',
value: false, value: false,
}); });
$checkbox.click(); // checking trigger('[data-track-event="toggle_checkbox"]'); // unchecking
expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', { expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', {
label: '',
property: '',
value: '_value_', value: '_value_',
}); });
}); });
it('handles bootstrap dropdowns', () => { it('handles bootstrap dropdowns', () => {
new Tracking('_category_').bind(document); trigger('[data-track-event="toggle_dropdown"]', 'show.bs.dropdown'); // showing
const $dropdown = $('[data-track-event="toggle_dropdown"]');
$dropdown.trigger('show.bs.dropdown'); // showing expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_show', {});
expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_show', { trigger('[data-track-event="toggle_dropdown"]', 'hide.bs.dropdown'); // hiding
label: '',
property: '', expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_hide', {});
value: '', });
});
$dropdown.trigger('hide.bs.dropdown'); // hiding it('handles nested elements inside an element with tracking', () => {
trigger('span.nested', 'click');
expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_hide', { expect(eventSpy).toHaveBeenCalledWith('_category_', 'nested_event', {});
label: '',
property: '',
value: '',
});
}); });
}); });
}); });
import Tracking from '~/tracking';
export default Tracking;
let document;
let handlers;
export function mockTracking(category = '_category_', documentOverride, spyMethod) {
document = documentOverride || window.document;
window.snowplow = () => {};
Tracking.bindDocument(category, document);
return spyMethod ? spyMethod(Tracking, 'event') : null;
}
export function unmockTracking() {
window.snowplow = undefined;
handlers.forEach(event => document.removeEventListener(event.name, event.func));
}
export function triggerEvent(selectorOrEl, eventName = 'click') {
const event = new Event(eventName, { bubbles: true });
const el = typeof selectorOrEl === 'string' ? document.querySelector(selectorOrEl) : selectorOrEl;
el.dispatchEvent(event);
}
import Vue from 'vue'; import Vue from 'vue';
import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue'; import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue';
import { mockTracking, triggerEvent } from 'spec/helpers/tracking_helper';
describe('AssigneeTitle component', () => { describe('AssigneeTitle component', () => {
let component; let component;
let AssigneeTitleComponent; let AssigneeTitleComponent;
let statsSpy;
beforeEach(() => { beforeEach(() => {
statsSpy = spyOnDependency(AssigneeTitle, 'trackEvent');
AssigneeTitleComponent = Vue.extend(AssigneeTitle); AssigneeTitleComponent = Vue.extend(AssigneeTitle);
}); });
...@@ -105,15 +104,20 @@ describe('AssigneeTitle component', () => { ...@@ -105,15 +104,20 @@ describe('AssigneeTitle component', () => {
expect(component.$el.querySelector('.edit-link')).not.toBeNull(); expect(component.$el.querySelector('.edit-link')).not.toBeNull();
}); });
it('calls trackEvent when edit is clicked', () => { it('tracks the event when edit is clicked', () => {
component = new AssigneeTitleComponent({ component = new AssigneeTitleComponent({
propsData: { propsData: {
numberOfAssignees: 0, numberOfAssignees: 0,
editable: true, editable: true,
}, },
}).$mount(); }).$mount();
component.$el.querySelector('.js-sidebar-dropdown-toggle').click();
expect(statsSpy).toHaveBeenCalled(); const spy = mockTracking('_category_', component.$el, spyOn);
triggerEvent('.js-sidebar-dropdown-toggle');
expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
label: 'right_sidebar',
property: 'assignee',
});
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import confidentialIssueSidebar from '~/sidebar/components/confidential/confidential_issue_sidebar.vue'; import confidentialIssueSidebar from '~/sidebar/components/confidential/confidential_issue_sidebar.vue';
import { mockTracking, triggerEvent } from 'spec/helpers/tracking_helper';
describe('Confidential Issue Sidebar Block', () => { describe('Confidential Issue Sidebar Block', () => {
let vm1; let vm1;
let vm2; let vm2;
let statsSpy;
beforeEach(() => { beforeEach(() => {
statsSpy = spyOnDependency(confidentialIssueSidebar, 'trackEvent');
const Component = Vue.extend(confidentialIssueSidebar); const Component = Vue.extend(confidentialIssueSidebar);
const service = { const service = {
update: () => Promise.resolve(true), update: () => Promise.resolve(true),
...@@ -70,9 +69,13 @@ describe('Confidential Issue Sidebar Block', () => { ...@@ -70,9 +69,13 @@ describe('Confidential Issue Sidebar Block', () => {
}); });
}); });
it('calls trackEvent when "Edit" is clicked', () => { it('tracks the event when "Edit" is clicked', () => {
vm1.$el.querySelector('.confidential-edit').click(); const spy = mockTracking('_category_', vm1.$el, spyOn);
triggerEvent('.confidential-edit');
expect(statsSpy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
label: 'right_sidebar',
property: 'confidentiality',
});
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import lockIssueSidebar from '~/sidebar/components/lock/lock_issue_sidebar.vue'; import lockIssueSidebar from '~/sidebar/components/lock/lock_issue_sidebar.vue';
import { mockTracking, triggerEvent } from 'spec/helpers/tracking_helper';
describe('LockIssueSidebar', () => { describe('LockIssueSidebar', () => {
let vm1; let vm1;
let vm2; let vm2;
let statsSpy;
beforeEach(() => { beforeEach(() => {
statsSpy = spyOnDependency(lockIssueSidebar, 'trackEvent');
const Component = Vue.extend(lockIssueSidebar); const Component = Vue.extend(lockIssueSidebar);
const mediator = { const mediator = {
...@@ -61,10 +60,14 @@ describe('LockIssueSidebar', () => { ...@@ -61,10 +60,14 @@ describe('LockIssueSidebar', () => {
}); });
}); });
it('calls trackEvent when "Edit" is clicked', () => { it('tracks an event when "Edit" is clicked', () => {
vm1.$el.querySelector('.lock-edit').click(); const spy = mockTracking('_category_', vm1.$el, spyOn);
triggerEvent('.lock-edit');
expect(statsSpy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
label: 'right_sidebar',
property: 'lock_issue',
});
}); });
it('displays the edit form when opened from collapsed state', done => { it('displays the edit form when opened from collapsed state', done => {
......
...@@ -2,14 +2,13 @@ import Vue from 'vue'; ...@@ -2,14 +2,13 @@ import Vue from 'vue';
import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTracking } from 'spec/helpers/tracking_helper';
describe('Subscriptions', function() { describe('Subscriptions', function() {
let vm; let vm;
let Subscriptions; let Subscriptions;
let statsSpy;
beforeEach(() => { beforeEach(() => {
statsSpy = spyOnDependency(subscriptions, 'trackEvent');
Subscriptions = Vue.extend(subscriptions); Subscriptions = Vue.extend(subscriptions);
}); });
...@@ -53,6 +52,7 @@ describe('Subscriptions', function() { ...@@ -53,6 +52,7 @@ describe('Subscriptions', function() {
vm = mountComponent(Subscriptions, { subscribed: true }); vm = mountComponent(Subscriptions, { subscribed: true });
spyOn(eventHub, '$emit'); spyOn(eventHub, '$emit');
spyOn(vm, '$emit'); spyOn(vm, '$emit');
spyOn(vm, 'track');
vm.toggleSubscription(); vm.toggleSubscription();
...@@ -60,11 +60,12 @@ describe('Subscriptions', function() { ...@@ -60,11 +60,12 @@ describe('Subscriptions', function() {
expect(vm.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object)); expect(vm.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object));
}); });
it('calls trackEvent when toggled', () => { it('tracks the event when toggled', () => {
vm = mountComponent(Subscriptions, { subscribed: true }); vm = mountComponent(Subscriptions, { subscribed: true });
const spy = mockTracking('_category_', vm.$el, spyOn);
vm.toggleSubscription(); vm.toggleSubscription();
expect(statsSpy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
}); });
it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => { it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment