From 26b2fafc3d96da782581def42ab7983e2fb910a8 Mon Sep 17 00:00:00 2001 From: David Baldwynn Date: Fri, 17 Jun 2016 14:33:33 -0700 Subject: [PATCH] created basic analytics dashboard --- app/controllers/forms.server.controller.js | 9 +- app/models/form.server.model.js | 26 +- bower.json | 3 +- public/config.js | 2 +- .../forms/admin/config/i18n/english.js | 14 +- .../forms/admin/css/edit-submissions-view.css | 28 ++ .../edit-submissions-form.client.directive.js | 113 +++++--- .../edit-submissions-form.client.view.html | 261 +++++++++++++++--- .../forms/config/forms.client.config.js | 6 +- .../analytics-service.client.directive.js | 32 ++- 10 files changed, 406 insertions(+), 88 deletions(-) create mode 100644 public/modules/forms/admin/css/edit-submissions-view.css diff --git a/app/controllers/forms.server.controller.js b/app/controllers/forms.server.controller.js index d800f827..0c7b2aa5 100644 --- a/app/controllers/forms.server.controller.js +++ b/app/controllers/forms.server.controller.js @@ -19,13 +19,6 @@ var mongoose = require('mongoose'), */ exports.uploadPDF = function(req, res, next) { - //console.log('inside uploadPDF'); - - // console.log('\n\nProperty Descriptor\n-----------'); - // console.log(Object.getOwnPropertyDescriptor(req.files.file, 'path')); - - //console.log(req.file); - if(req.file){ var pdfFile = req.file; var _user = req.user; @@ -376,7 +369,7 @@ exports.formByID = function(req, res, next, id) { message: 'Form is invalid' }); } else { - Form.findById(id).populate('admin').exec(function(err, form) { + Form.findById(id).populate('admin').populate('submissions').exec(function(err, form) { if (err) { return next(err); } else if (form === undefined || form === null) { diff --git a/app/models/form.server.model.js b/app/models/form.server.model.js index 563ae47e..4efe22d8 100644 --- a/app/models/form.server.model.js +++ b/app/models/form.server.model.js @@ -48,17 +48,37 @@ var ButtonSchema = new Schema({ var VisitorDataSchema = new Schema({ referrer: { - type: String + type: String, + required: true }, lastActiveField: { type: Schema.Types.ObjectId }, timeElapsed: { - type: Number + type: Number, + required: true }, isSubmitted: { - type: Boolean + type: Boolean, + required: true + }, + language: { + type: String + }, + ipAddr: { + type: String, + default: '' + }, + deviceType: { + type: String, + enum: ['desktop', 'phone', 'tablet', 'other'], + default: 'other', + required: true + }, + userAgent: { + type: String } + }); /** diff --git a/bower.json b/bower.json index c4595134..f92eec0c 100755 --- a/bower.json +++ b/bower.json @@ -35,7 +35,8 @@ "tableExport.jquery.plugin": "^1.5.1", "js-yaml": "^3.6.1", "angular-ui-select": "whitef0x0/ui-select#compiled", - "angular-translate": "~2.11.0" + "angular-translate": "~2.11.0", + "ng-device-detector": "^3.0.1" }, "resolutions": { "angular-bootstrap": "^0.14.0", diff --git a/public/config.js b/public/config.js index 4f2264aa..9a2fa788 100755 --- a/public/config.js +++ b/public/config.js @@ -4,7 +4,7 @@ var ApplicationConfiguration = (function() { // Init module configuration options var applicationModuleName = 'NodeForm'; - var applicationModuleVendorDependencies = ['duScroll', 'ui.select', 'cgBusy', 'ngSanitize', 'vButton', 'ngResource', 'NodeForm.templates', 'ui.router', 'ui.bootstrap', 'ui.utils', 'pascalprecht.translate']; + var applicationModuleVendorDependencies = ['duScroll', 'ui.select', 'cgBusy', 'ngSanitize', 'vButton', 'ngResource', 'NodeForm.templates', 'ui.router', 'ui.bootstrap', 'ui.utils', 'pascalprecht.translate', 'ng.deviceDetector']; // Add a new vertical module var registerModule = function(moduleName, dependencies) { diff --git a/public/modules/forms/admin/config/i18n/english.js b/public/modules/forms/admin/config/i18n/english.js index 3995614b..0c34a66c 100644 --- a/public/modules/forms/admin/config/i18n/english.js +++ b/public/modules/forms/admin/config/i18n/english.js @@ -61,9 +61,17 @@ angular.module('forms').config(['$translateProvider', function ($translateProvid CLICK_FIELDS_FOOTER: 'Click on fields to add them here', //Edit Submissions View - TOTAL_VIEWS: 'Total Views', - SUBMISSIONS: 'Submissions', - CONVERSION_RATE: 'Conversion Rate', + TOTAL_VIEWS: 'total unique visits', + RESPONSES: 'responses', + COMPLETION_RATE: 'completion rate', + AVERAGE_TIME_TO_COMPLETE: 'avg. completion time', + + DESKTOP_AND_LAPTOP: 'Desktops/Laptops', + TABLETS: 'Tablets', + PHONES: 'Phones', + OTHER: 'Other', + UNIQUE_VISITS: 'Unique Visits', + FIELD_TITLE: 'Field Title', FIELD_VIEWS: 'Field Views', FIELD_DROPOFF: 'User dropoff rate at this field', diff --git a/public/modules/forms/admin/css/edit-submissions-view.css b/public/modules/forms/admin/css/edit-submissions-view.css new file mode 100644 index 00000000..d9bb2699 --- /dev/null +++ b/public/modules/forms/admin/css/edit-submissions-view.css @@ -0,0 +1,28 @@ +.analytics .header-title { + font-size: 1em; + color: #bab8b8; +} + +.analytics .header-numbers { + font-size: 4em; + padding-bottom: 0.1em; + margin-bottom: 0.5em; + border-bottom: #fafafa solid 1px; +} + +.analytics .detailed-title { + font-size: 1.8em; + margin-bottom: 1.1em; +} + +.analytics .detailed-row { + padding-bottom: 0.8em; +} +.analytics .detailed-row .row { + font-size: 1.3em; +} +.analytics .detailed-row .row.header { + font-size: 0.8em; + color: #bab8b8; + text-transform: uppercase; +} diff --git a/public/modules/forms/admin/directives/edit-submissions-form.client.directive.js b/public/modules/forms/admin/directives/edit-submissions-form.client.directive.js index c9464467..26d8024f 100644 --- a/public/modules/forms/admin/directives/edit-submissions-form.client.directive.js +++ b/public/modules/forms/admin/directives/edit-submissions-form.client.directive.js @@ -15,6 +15,87 @@ angular.module('forms').directive('editSubmissionsFormDirective', ['$rootScope', rows: [] }; + (function initController(){ + + var defaultFormFields = _.cloneDeep($scope.myform.form_fields); + + //Iterate through form's submissions + + var submissions = _.cloneDeep($scope.myform.submissions); + for(var i = 0; i < submissions.length; i++){ + for(var x = 0; x < submissions[i].form_fields; x++){ + var oldValue = submissions[i].form_fields[x].fieldValue || ''; + submissions[i].form_fields[x] = _.merge(defaultFormFields, submissions[i].form_fields); + submissions[i].form_fields[x].fieldValue = oldValue; + } + submissions[i].selected = false; + } + // console.log('after textField2: '+data[0].form_fields[1].fieldValue); + + $scope.table.rows = submissions; + + // console.log('form submissions successfully fetched'); + // console.log( JSON.parse(JSON.stringify($scope.submissions)) ) ; + // console.log( JSON.parse(JSON.stringify($scope.myform.form_fields)) ); + + })(); + + /* + ** Analytics Functions + */ + $scope.AverageTimeElapsed = (function(){ + var totalTime = 0; + var numSubmissions = $scope.table.rows.length; + + for(var i=0; i<$scope.table.rows.length; i++){ + totalTime += $scope.table.rows[i].timeElapsed; + } + + console.log(totalTime/numSubmissions); + return totalTime/numSubmissions; + })(); + + $scope.DeviceStatistics = (function(){ + var stats = { + desktop: null, + tablet: null, + phone: null, + other: null + }; + var newStatItem = function(){ + return { + visits: 0, + responses: 0, + completion: 0, + average_time: 0, + total_time: 0 + } + }; + + var visitors = $scope.myform.analytics.visitors; + + console.log(visitors); + for(var i=0; i -
-
- {{ 'TOTAL_VIEWS' | translate }}: {{myform.analytics.views}} -
- -
- {{ 'SUBMISSIONS' | translate }}: {{myform.analytics.submissions}} -
- -
- {{ 'CONVERSION_RATE' | translate }}: {{myform.analytics.conversionRate}}% -
-
-
-
-
- -
- {{ 'FIELD_TITLE' | translate }} -
-
- {{ 'FIELD_VIEWS' | translate }} +
+
+
+
+ {{ 'TOTAL_VIEWS' | translate }}
-
- {{ 'FIELD_DROPOFF' | translate }} +
+ {{ 'RESPONSES' | translate }} +
+ +
+ {{ 'COMPLETION_RATE' | translate }} +
+ +
+ {{ 'AVERAGE_TIME_TO_COMPLETE' | translate }}
-
-
- {{fieldStats.field.title}} -
-
- {{fieldStats.totalViews}} +
+
+ {{myform.analytics.views}}
-
- {{fieldStats.dropoffRate}}% +
+ {{myform.analytics.submissions}} +
+ +
+ {{myform.analytics.conversionRate | number:0}}% +
+ +
+ {{AverageTimeElapsed | secondsToDateTime | date:'mm:ss'}} +
+
+ +
+
+ {{ 'DESKTOP_AND_LAPTOP' | translate }} +
+ +
+ {{ 'TABLETS' | translate }} +
+ +
+ {{ 'PHONES' | translate }} +
+ +
+ {{ 'OTHER' | translate }} +
+
+ +
+
+
+ {{ 'UNIQUE_VISITS' | translate }} +
+
+ {{DeviceStatistics.desktop.visits}} +
+
+ +
+
+ {{ 'UNIQUE_VISITS' | translate }} +
+
+ {{DeviceStatistics.tablet.visits}} +
+
+ +
+
+ {{ 'UNIQUE_VISITS' | translate }} +
+
+ {{DeviceStatistics.tablet.visits}} +
+
+ +
+
+ {{ 'UNIQUE_VISITS' | translate }} +
+
+ {{DeviceStatistics.other.visits}} +
+
+
+ +
+
+
+ {{ 'RESPONSES' | translate }} +
+
+ {{DeviceStatistics.desktop.responses}} +
+
+ +
+
+ {{ 'RESPONSES' | translate }} +
+
+ {{DeviceStatistics.tablet.responses}} +
+
+ +
+
+ {{ 'RESPONSES' | translate }} +
+
+ {{DeviceStatistics.phone.responses}} +
+
+ +
+
+ {{ 'RESPONSES' | translate }} +
+
+ {{DeviceStatistics.other.responses}} +
+
+
+ +
+
+
+ {{ 'COMPLETION_RATE' | translate }} +
+
+ {{DeviceStatistics.desktop.completion}} +
+
+ +
+
+ {{ 'COMPLETION_RATE' | translate }} +
+
+ {{DeviceStatistics.tablet.completion}} +
+
+ +
+
+ {{ 'COMPLETION_RATE' | translate }} +
+
+ {{DeviceStatistics.phone.completion}} +
+
+ +
+
+ {{ 'COMPLETION_RATE' | translate }} +
+
+ {{DeviceStatistics.other.completion}} +
+
+
+ +
+
+
+ {{ 'AVERAGE_TIME_TO_COMPLETE' | translate }} +
+
+ {{DeviceStatistics.desktop.average_time | secondsToDateTime | date:'mm:ss'}} +
+
+ +
+
+ {{ 'AVERAGE_TIME_TO_COMPLETE' | translate }} +
+
+ {{DeviceStatistics.tablet.average_time | secondsToDateTime | date:'mm:ss'}} +
+
+ +
+
+ {{ 'AVERAGE_TIME_TO_COMPLETE' | translate }} +
+
+ {{DeviceStatistics.phone.average_time | secondsToDateTime | date:'mm:ss'}} +
+
+ +
+
+ {{ 'AVERAGE_TIME_TO_COMPLETE' | translate }} +
+
+ {{DeviceStatistics.other.average_time | secondsToDateTime | date:'mm:ss'}} +
+
+
+ +
+ +
+ {{ 'FIELD_TITLE' | translate }} +
+
+ {{ 'FIELD_VIEWS' | translate }} +
+ +
+ {{ 'FIELD_DROPOFF' | translate }} +
+
+
+ +
+ {{fieldStats.field.title}} +
+
+ {{fieldStats.totalViews}} +
+ +
+ {{fieldStats.dropoffRate}}% +
@@ -116,7 +309,7 @@ {{row.percentageComplete}}% - {{row.timeElapsed}} + {{row.timeElapsed | secondsToDateTime | date:'mm:ss'}} {{row.device.name}}, {{row.device.type}} diff --git a/public/modules/forms/config/forms.client.config.js b/public/modules/forms/config/forms.client.config.js index ed15ac88..0366c4d0 100644 --- a/public/modules/forms/config/forms.client.config.js +++ b/public/modules/forms/config/forms.client.config.js @@ -6,7 +6,11 @@ angular.module('forms').run(['Menus', // Set top bar menu items Menus.addMenuItem('topbar', 'My Forms', 'forms', '', '/forms', false); } -]).filter('formValidity', +]).filter('secondsToDateTime', [function() { + return function(seconds) { + return new Date(1970, 0, 1).setSeconds(seconds); + }; +}]).filter('formValidity', function(){ return function(formObj){ if(formObj && formObj.form_fields && formObj.visible_form_fields){ diff --git a/public/modules/forms/directives/analytics-service.client.directive.js b/public/modules/forms/directives/analytics-service.client.directive.js index f8ed20e1..dcdef071 100644 --- a/public/modules/forms/directives/analytics-service.client.directive.js +++ b/public/modules/forms/directives/analytics-service.client.directive.js @@ -6,22 +6,42 @@ .module('forms') .factory('SendVisitorData', SendVisitorData); - SendVisitorData.$inject = ['Socket', '$state']; + SendVisitorData.$inject = ['Socket', '$state', '$http', 'deviceDetector']; - function SendVisitorData(Socket, $state) { + function SendVisitorData(Socket, $state, $http) { // Create a controller method for sending visitor data - function send(form, lastActiveIndex, timeElapsed) { - + function send(form, lastActiveIndex, timeElapsed, deviceDetector) { // Create a new message object var visitorData = { referrer: document.referrer, isSubmitted: form.submitted, formId: form._id, lastActiveField: form.form_fields[lastActiveIndex]._id, - timeElapsed: timeElapsed + timeElapsed: timeElapsed, + //@TODO @FIXME: David: Need to make this get the language from the HTTP Header instead + language: window.navigator.userLanguage || window.navigator.language }; - Socket.emit('form-visitor-data', visitorData); + + $http.get('http://jsonip.com/').success(function(response) { + visitorData.ipAddr = response['ip']+''; + }).error(function(error) { + console.error('Could not get users\'s ip'); + visitorData.ipAddr = ''; + }).finally(function(){ + visitorData.userAgent = deviceDetector.raw; + + if(deviceDetector.isTablet()) { + visitorData.deviceType = 'tablet'; + }else if(deviceDetector.isMobile()){ + visitorData.deviceType = 'phone'; + }else { + visitorData.deviceType = 'desktop'; + } + Socket.emit('form-visitor-data', visitorData); + + }); + } function init(){