Commit 9eec4fe2 authored by Clement Ho's avatar Clement Ho

Merge branch 'ee-tracking-performance' into 'master'

Improve tracking performance and simplify tracking implementations

See merge request gitlab-org/gitlab!15759
parents 80eca67f 143d35c3
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