From cb1d83db863938ace3ebdafd072dfd32e434fded Mon Sep 17 00:00:00 2001 From: diosmosis Date: Thu, 2 Aug 2018 15:57:13 -0700 Subject: Add reusable widget to display single metric w/ sparkline & evolution percent (+ other changes) (#13101) * Add empty metric for single metric view. * Add new isReusable property to widget metadata & if set to true, do not grey out the widget in the dashboard manager, even if the widget is used in the dashboard. * Initial working version of single metric view. * Get single metric view widget to work and look correctly (no series picker). * Add series picker to single metric widget and add filter_last_period_evolution parameter. * Persist metric change through dashboard widget parameter saving. * Loading state for single metric view. * Make new evolution param work on processed reports + tweak component implementation. * Tweak CSS and make sure angular components are compiled in widget preview. * Make component work with widget preview and avoid unnecessary widget reloads when multiple widgets of the same type are shown. * Generalize JS lastN range period computing and use to create standalone sparkline angular component and get rid of need for "past-period" argument to single metric view. * Add format_metrics: "1" to API.get method. * Add escaping to _angularComponent.twig. * hacky fix for formatting revenue columns * Format past data values & allow evolution to be calculated for processed metrics. * filter evolution changes * Fix issue in subtable recursion for processed metric computation & metric formatting + add new processed metric compute hooks to fix bug in evolution calculation on subtables. * remove isReusable property. * attempting to change strategy * simpler solution that does not require backend changes * remove unneeded code + fix issue w/ formatted metrics * remove some more unneeded code * write UI test * add new screenshots * Add all goals to single metric view picker. * move category * fix test * fixing more tests * Fixing some UI tests. * Update more screenshots. * update two more screenshots --- plugins/CoreVisualizations/CoreVisualizations.php | 4 +- .../Widgets/SingleMetricView.php | 83 +++++++ .../series-picker/series-picker.component.less | 4 + .../single-metric-view.component.html | 20 ++ .../single-metric-view.component.js | 262 +++++++++++++++++++++ .../single-metric-view.component.less | 61 +++++ 6 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 plugins/CoreVisualizations/Widgets/SingleMetricView.php create mode 100644 plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.html create mode 100644 plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.js create mode 100644 plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.less (limited to 'plugins/CoreVisualizations') diff --git a/plugins/CoreVisualizations/CoreVisualizations.php b/plugins/CoreVisualizations/CoreVisualizations.php index 5c633706e3..48117517a4 100644 --- a/plugins/CoreVisualizations/CoreVisualizations.php +++ b/plugins/CoreVisualizations/CoreVisualizations.php @@ -40,6 +40,7 @@ class CoreVisualizations extends \Piwik\Plugin public function getStylesheetFiles(&$stylesheets) { $stylesheets[] = "plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.less"; + $stylesheets[] = "plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.less"; $stylesheets[] = "plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less"; $stylesheets[] = "plugins/CoreVisualizations/stylesheets/jqplot.css"; @@ -48,7 +49,7 @@ class CoreVisualizations extends \Piwik\Plugin public function getJsFiles(&$jsFiles) { $jsFiles[] = "plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.js"; - $jsFiles[] = "plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.js"; + $jsFiles[] = "plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.js"; $jsFiles[] = "plugins/CoreVisualizations/javascripts/seriesPicker.js"; $jsFiles[] = "plugins/CoreVisualizations/javascripts/jqplot.js"; @@ -65,5 +66,6 @@ class CoreVisualizations extends \Piwik\Plugin $translationKeys[] = 'General_SaveImageOnYourComputer'; $translationKeys[] = 'General_ExportAsImage'; $translationKeys[] = 'General_NoDataForGraph'; + $translationKeys[] = 'General_EvolutionSummaryGeneric'; } } diff --git a/plugins/CoreVisualizations/Widgets/SingleMetricView.php b/plugins/CoreVisualizations/Widgets/SingleMetricView.php new file mode 100644 index 0000000000..b9ea39840e --- /dev/null +++ b/plugins/CoreVisualizations/Widgets/SingleMetricView.php @@ -0,0 +1,83 @@ +addParameters(['column' => $column]); + $config->setCategoryId('General_Generic'); + $config->setName('General_Metric'); + $config->setIsWidgetizable(); + } + + public function render() + { + $column = Common::getRequestVar('column', 'nb_visits', 'string'); + + $goalMetrics = []; + $goals = []; + + $idSite = Common::getRequestVar('idSite'); + $idGoal = Common::getRequestVar('idGoal', false); + + $reportMetadata = Request::processRequest('API.getMetadata', [ + 'idSites' => $idSite, + 'apiModule' => 'API', + 'apiAction' => 'get', + ]); + $reportMetadata = reset($reportMetadata); + + $metricTranslations = array_merge($reportMetadata['metrics'], $reportMetadata['processedMetrics']); + $metricDocumentations = $reportMetadata['metricsDocumentation']; + + if (PluginManager::getInstance()->isPluginActivated('Goals')) { + $reportMetadata = Request::processRequest('API.getMetadata', [ + 'idSites' => $idSite, + 'apiModule' => 'Goals', + 'apiAction' => 'get', + ]); + $reportMetadata = reset($reportMetadata); + + $goalMetrics = array_merge( + array_keys($reportMetadata['metrics']), + array_keys($reportMetadata['processedMetrics']) + ); + $metricDocumentations = array_merge($metricDocumentations, $reportMetadata['metricsDocumentation']); + + $goals = GoalsAPI::getInstance()->getGoals($idSite); + } + + $view = new View("@CoreHome/_angularComponent.twig"); + $view->componentName = 'piwik-single-metric-view'; + $view->componentParameters = [ + 'metric' => json_encode($column), + 'id-goal' => $idGoal === false ? 'undefined' : $idGoal, + 'goal-metrics' => json_encode($goalMetrics), + 'goals' => json_encode($goals), + 'metric-translations' => json_encode($metricTranslations), + 'metric-documentations' => json_encode($metricDocumentations), + ]; + + return $view->render(); + } +} \ No newline at end of file diff --git a/plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.less b/plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.less index e950b1eefb..9d4fdc8de9 100644 --- a/plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.less +++ b/plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.less @@ -6,6 +6,10 @@ piwik-series-picker { opacity: .55; } + &.open { // while open, make sure we're above other series picker icons + z-index: 1000; + } + > a { display: inline-block; opacity: 0; diff --git a/plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.html b/plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.html new file mode 100644 index 0000000000..7d7b0c2a9f --- /dev/null +++ b/plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.html @@ -0,0 +1,20 @@ +
+ + +
+ + {{ $ctrl.metricValue }} {{ ($ctrl.metricTranslation || '').toLowerCase() }} + + + + {{ $ctrl.metricChangePercent }} + + +
+
\ No newline at end of file diff --git a/plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.js b/plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.js new file mode 100644 index 0000000000..d18e9d88ed --- /dev/null +++ b/plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.js @@ -0,0 +1,262 @@ +/*! + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * Usage: + * + */ +(function () { + angular.module('piwikApp').component('piwikSingleMetricView', { + templateUrl: 'plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.html?cb=' + piwik.cacheBuster, + bindings: { + metric: '<', + idGoal: '<', + metricTranslations: '<', + metricDocumentations: '<', + goals: '<', + goalMetrics: '<' + }, + controller: SingleMetricViewController + }); + + SingleMetricViewController.$inject = ['piwik', 'piwikApi', '$element', '$httpParamSerializer', '$compile', '$scope', 'piwikPeriods', '$q']; + + function SingleMetricViewController(piwik, piwikApi, $element, $httpParamSerializer, $compile, $scope, piwikPeriods, $q) { + var seriesPickerScope; + + var vm = this; + vm.metricValue = null; + vm.isLoading = false; + vm.metricTranslation = null; + vm.metricDocumentation = null; + vm.selectableColumns = []; + vm.responses = null; + vm.$onInit = $onInit; + vm.$onChanges = $onChanges; + vm.$onDestroy = $onDestroy; + vm.getCurrentPeriod = getCurrentPeriod; + vm.getMetricTranslation = getMetricTranslation; + vm.setMetric = setMetric; + + function $onInit() { + vm.selectedColumns = [vm.metric]; + if (piwik.period !== 'range') { + vm.pastPeriod = getPastPeriodStr(); + } + + setSelectableColumns(); + + createSeriesPicker(); + + $element.closest('.widgetContent') + .on('widget:destroy', function() { $scope.$parent.$destroy(); }) + .on('widget:reload', function() { $scope.$parent.$destroy(); }); + } + + function $onChanges(changes) { + if (changes.metric && changes.metric.previousValue !== changes.metric.currentValue) { + onMetricChanged(); + } + } + + function $onDestroy() { + $element.closest('.widgetContent').off('widget:destroy').off('widget:reload'); + destroySeriesPicker(); + } + + function fetchData() { + if (vm.responses && vm.responses.length && typeof vm.idGoal === 'undefined') { + return $q.resolve(); + } + + vm.isLoading = true; + + var promises = []; + + var apiModule = 'API'; + var apiAction = 'get'; + + var extraParams = {}; + if (vm.idGoal) { + extraParams.idGoal = vm.idGoal; + // the conversion rate added by the AddColumnsProcessedMetrics filter conflicts w/ the goals one, so don't run it + extraParams.filter_add_columns_when_show_all_columns = 0; + + apiModule = 'Goals'; + apiAction = 'get'; + } + + // first request for formatted data + promises.push(piwikApi.fetch($.extend({ + method: apiModule + '.' + apiAction, + format_metrics: 'all' + }, extraParams))); + + if (piwik.period !== 'range') { + // second request for unformatted data so we can calculate evolution + promises.push(piwikApi.fetch($.extend({ + method: apiModule + '.' + apiAction, + format_metrics: '0' + }, extraParams))); + + // third request for past data (unformatted) + promises.push(piwikApi.fetch($.extend({ + method: apiModule + '.' + apiAction, + date: getLastPeriodDate(), + format_metrics: '0', + }, extraParams))); + + // fourth request for past data (formatted for tooltip display) + promises.push(piwikApi.fetch($.extend({ + method: apiModule + '.' + apiAction, + date: getLastPeriodDate(), + format_metrics: 'all', + }, extraParams))); + } + + return $q.all(promises).then(function (responses) { + vm.responses = responses; + vm.isLoading = false; + }); + } + + function recalculateValues() { + // update display based on processed report metadata + setWidgetTitle(); + vm.metricDocumentation = getMetricDocumentation(); + + // update data + var currentData = vm.responses[0]; + vm.metricValue = currentData[vm.metric] || 0; + + if (vm.responses[1]) { + vm.metricValueUnformatted = vm.responses[1][vm.metric]; + + var pastData = vm.responses[2]; + vm.pastValueUnformatted = pastData[vm.metric] || 0; + + var evolution = piwik.helper.calculateEvolution(vm.metricValueUnformatted, vm.pastValueUnformatted); + vm.metricChangePercent = (evolution * 100).toFixed(2) + ' %'; + + var pastDataFormatted = vm.responses[3]; + vm.pastValue = pastDataFormatted[vm.metric] || 0; + } else { + vm.pastValue = null; + vm.metricChangePercent = null; + } + + // don't change the metric translation until data is fetched to avoid loading state confusion + vm.metricTranslation = getMetricTranslation(); + } + + function getLastPeriodDate() { + var RangePeriod = piwikPeriods.get('range'); + var result = RangePeriod.getLastNRange(piwik.period, 2, piwik.currentDateString).startDate; + return $.datepicker.formatDate('yy-mm-dd', result); + } + + function setWidgetTitle() { + var title = vm.getMetricTranslation(); + if (vm.idGoal) { + var goalName = vm.goals[vm.idGoal].name; + title = goalName + ' - ' + title; + } + + $element.closest('div.widget').find('.widgetTop > .widgetName > span').text(title); + } + + function getCurrentPeriod() { + if (piwik.startDateString === piwik.endDateString) { + return piwik.endDateString; + } + return piwik.startDateString + ', ' + piwik.endDateString; + } + + function createSeriesPicker() { + vm.selectedColumns = [vm.idGoal ? ('goal' + vm.idGoal + '_' + vm.metric) : vm.metric]; + + var $widgetName = $element.closest('div.widget').find('.widgetTop > .widgetName'); + + var $seriesPicker = $(''); + + seriesPickerScope = $scope.$new(); + $compile($seriesPicker)(seriesPickerScope); + + $widgetName.append($seriesPicker); + } + + function destroySeriesPicker() { + $element.closest('div.widget').find('.single-metric-view-picker').remove(); + + seriesPickerScope.$destroy(); + seriesPickerScope = null; + } + + function getMetricDocumentation() { + if (!vm.metricDocumentations || !vm.metricDocumentations[vm.metric]) { + return ''; + } + + return vm.metricDocumentations[vm.metric]; + } + + function getMetricTranslation() { + if (!vm.metricTranslations || !vm.metricTranslations[vm.metric]) { + return ''; + } + + return vm.metricTranslations[vm.metric]; + } + + function setSelectableColumns() { + var result = []; + Object.keys(vm.metricTranslations).forEach(function (column) { + result.push({ column: column, translation: vm.metricTranslations[column] }); + }); + + Object.keys(vm.goals).forEach(function (idgoal) { + var goal = vm.goals[idgoal]; + vm.goalMetrics.forEach(function (column) { + result.push({ + column: 'goal' + goal.idgoal + '_' + column, + translation: goal.name + ' - ' + vm.metricTranslations[column] + }); + }); + }); + + vm.selectableColumns = result; + } + + function onMetricChanged() { + fetchData().then(recalculateValues); + + // notify widget of parameter change so it is replaced + $element.closest('[widgetId]').trigger('setParameters', { column: vm.metric, idGoal: vm.idGoal }); + } + + function setMetric(newColumn) { + var m = newColumn.match(/^goal([0-9])_(.*)/); + if (m) { + vm.idGoal = m[1]; + newColumn = m[2]; + } else { + vm.idGoal = undefined; + } + + vm.metric = newColumn; + onMetricChanged(); + } + + function getPastPeriodStr() { + var startDate = piwikPeriods.get('range').getLastNRange(piwik.period, 2, piwik.currentDateString).startDate; + var dateRange = piwikPeriods.get(piwik.period).parse(startDate).getDateRange(); + return $.datepicker.formatDate('yy-mm-dd', dateRange[0]) + ',' + $.datepicker.formatDate('yy-mm-dd', dateRange[1]); + } + } +})(); diff --git a/plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.less b/plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.less new file mode 100644 index 0000000000..f091b530e2 --- /dev/null +++ b/plugins/CoreVisualizations/angularjs/single-metric-view/single-metric-view.component.less @@ -0,0 +1,61 @@ +.singleMetricView { + margin: 5px 12px 10px; + display: inline-block; + + &.loading { + opacity: 0.5; + } + + .metric-value { + display: inline-block; + font-size: 14px; + line-height: 25px; + vertical-align: top; + margin-left: 3px; + } + + .metric-sparkline { + display: inline-block; + vertical-align: top; + img { + width: 100px; + height: 25px; + } + } + + .metricEvolution { + font-weight: bold; + font-size: 12px; + + > span { + display: inline-block; + + &:not(.positive-evolution):not(.negative-evolution) { + margin-left: 8px; + } + } + + .positive-evolution::before { + content: ""; + background: url(plugins/MultiSites/images/arrow_up.png) no-repeat center center; + display: inline-block; + height: 7px; + width: 12px; + } + .negative-evolution::before { + content: ""; + background: url(plugins/MultiSites/images/arrow_down.png) no-repeat center center; + display: inline-block; + height: 7px; + width: 12px; + } + } +} + +[piwik-single-metric-view] { + display: block; +} + +.single-metric-view-picker { + margin-left: 6px; +} \ No newline at end of file -- cgit v1.2.3