diff --git a/plugins/CoreVisualizations/CoreVisualizations.php b/plugins/CoreVisualizations/CoreVisualizations.php index b832903228fbd0550c7a690a5fc49d792881ca73..5c633706e3c6314b5faf3eddf437e1cf08dee10e 100644 --- a/plugins/CoreVisualizations/CoreVisualizations.php +++ b/plugins/CoreVisualizations/CoreVisualizations.php @@ -39,12 +39,17 @@ class CoreVisualizations extends \Piwik\Plugin public function getStylesheetFiles(&$stylesheets) { + $stylesheets[] = "plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.less"; + $stylesheets[] = "plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less"; $stylesheets[] = "plugins/CoreVisualizations/stylesheets/jqplot.css"; } 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/javascripts/seriesPicker.js"; $jsFiles[] = "plugins/CoreVisualizations/javascripts/jqplot.js"; $jsFiles[] = "plugins/CoreVisualizations/javascripts/jqplotBarGraph.js"; diff --git a/plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.html b/plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.html new file mode 100644 index 0000000000000000000000000000000000000000..103df58343f6ffd4ba0fae6be312a57e0aad19c4 --- /dev/null +++ b/plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.html @@ -0,0 +1,49 @@ +<div + class="jqplot-seriespicker" + ng-class="{open: $ctrl.isPopupVisible}" + ng-mouseenter="$ctrl.isPopupVisible = true" + ng-mouseleave="$ctrl.onLeavePopup()" +> + <a + href="#" + ng-click="$event.preventDefault(); $event.stopPropagation();" + > + + + </a> + <div + class="jqplot-seriespicker-popover" + ng-if="$ctrl.isPopupVisible" + > + <p class="headline">{{ ($ctrl.multiselect ? 'General_MetricsToPlot' : 'General_MetricToPlot') | translate }}</p> + <p + ng-repeat="columnConfig in $ctrl.selectableColumns" + class="pickColumn" + ng-click="$ctrl.optionSelected(columnConfig.column, $ctrl.columnStates)" + > + <input + class="select" + ng-checked="$ctrl.columnStates[columnConfig.column]" + ng-attr-type="{{ $ctrl.multiselect ? 'checkbox' : 'radio' }}" + /> + <label>{{ columnConfig.translation }}</label> + </p> + <p + ng-if="$ctrl.selectableRows.length" + class="headline recordsToPlot" + > + {{ 'General_RecordsToPlot' | translate }} + </p> + <p + ng-repeat="rowConfig in $ctrl.selectableRows" + class="pickRow" + ng-click="$ctrl.optionSelected(rowConfig.matcher, $ctrl.rowStates)" + > + <input + class="select" + ng-checked="$ctrl.rowStates[rowConfig.matcher]" + ng-attr-type="{{ $ctrl.multiselect ? 'checkbox' : 'radio' }}" + /> + <label>{{ rowConfig.label }}</label> + </p> + </div> +</div> \ No newline at end of file diff --git a/plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.js b/plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.js new file mode 100644 index 0000000000000000000000000000000000000000..b84a190ad3d9f6c82ddbc482d3a9adafd109b455 --- /dev/null +++ b/plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.js @@ -0,0 +1,144 @@ +/*! + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * This series picker component is a popup that displays a list of metrics/row + * values that can be selected. It's used by certain datatable visualizations + * to allow users to select different data series for display. + * + * Inputs: + * - multiselect: true if the picker should allow selecting multiple items, false + * if otherwise. + * - selectableColumns: the list of selectable metric values. must be a list of + * objects with the following properties: + * * column: the ID of the column, eg, nb_visits + * * translation: the translated text for the column, eg, Visits + * - selectableRows: the list of selectable row values. must be a list of objects + * with the following properties: + * * matcher: the ID of the row + * * label: the display text for the row + * - selectedColumns: the list of selected columns. should be a list of strings + * that correspond to the 'column' property in selectableColumns. + * - selectedRows: the list of selected rows. should be a list of strings that + * correspond to the 'matcher' property in selectableRows. + * - onSelect: expression invoked when a user makes a new selection. invoked + * with the following local variables: + * * columns: list of IDs of new selected columns, if any + * * rows: list of matchers of new selected rows, if any + * + * Usage: + * <piwik-series-picker /> + */ +(function () { + angular.module('piwikApp').component('piwikSeriesPicker', { + templateUrl: 'plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.html?cb=' + piwik.cacheBuster, + bindings: { + multiselect: '<', + selectableColumns: '<', + selectableRows: '<', + selectedColumns: '<', + selectedRows: '<', + onSelect: '&' + }, + controller: SeriesPickerController + }); + + SeriesPickerController.$inject = []; + + function SeriesPickerController() { + var vm = this; + vm.isPopupVisible = false; + + // note: column & row states are separated since it's technically possible (though + // highly improbable) that a row value matcher will be the same as a recognized column. + vm.columnStates = {}; + vm.rowStates = {}; + vm.optionSelected = optionSelected; + vm.onLeavePopup = onLeavePopup; + vm.$onInit = $onInit; + + function $onInit() { + vm.columnStates = getInitialOptionStates(vm.selectableColumns, vm.selectedColumns); + vm.rowStates = getInitialOptionStates(vm.selectableRows, vm.selectedRows); + } + + function getInitialOptionStates(allOptions, selectedOptions) { + var states = {}; + + allOptions.forEach(function (columnConfig) { + states[columnConfig.column || columnConfig.matcher] = false; + }); + + selectedOptions.forEach(function (column) { + states[column] = true; + }); + + return states; + } + + function optionSelected(optionValue, optionStates) { + if (!vm.multiselect) { + unselectOptions(vm.columnStates); + unselectOptions(vm.rowStates); + } + + optionStates[optionValue] = !optionStates[optionValue]; + + if (optionStates[optionValue]) { + triggerOnSelectAndClose(); + } + } + + function onLeavePopup() { + vm.isPopupVisible = false; + + if (optionsChanged()) { + triggerOnSelectAndClose(); + } + } + + function triggerOnSelectAndClose() { + if (!vm.onSelect) { + return; + } + + vm.isPopupVisible = false; + + vm.onSelect({ + columns: getSelected(vm.columnStates), + rows: getSelected(vm.rowStates) + }); + } + + function optionsChanged() { + return !arrayEqual(getSelected(vm.columnStates), vm.selectedColumns) + || !arrayEqual(getSelected(vm.rowStates), vm.selectedRows); + } + + function arrayEqual(lhs, rhs) { + if (lhs.length !== rhs.length) { + return false; + } + + return lhs + .filter(function (element) { return rhs.indexOf(element) === -1; }) + .length === 0; + } + + function unselectOptions(optionStates) { + Object.keys(optionStates).forEach(function (optionName) { + optionStates[optionName] = false; + }); + } + + function getSelected(optionStates) { + return Object.keys(optionStates).filter(function (optionName) { + return !! optionStates[optionName]; + }); + } + } +})(); \ 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 new file mode 100644 index 0000000000000000000000000000000000000000..e950b1eefb753211ca88ee09a6047d2fccb34417 --- /dev/null +++ b/plugins/CoreVisualizations/angularjs/series-picker/series-picker.component.less @@ -0,0 +1,24 @@ +piwik-series-picker { + display: inline-block; + + .jqplot-seriespicker { + &:not(.open) { + opacity: .55; + } + + > a { + display: inline-block; + opacity: 0; + position: absolute; + } + + position: relative; + } + + .jqplot-seriespicker-popover { + position: absolute; + + top: -3px; + left: -4px; + } +} diff --git a/plugins/CoreVisualizations/javascripts/seriesPicker.js b/plugins/CoreVisualizations/javascripts/seriesPicker.js index f1e8c7eb3ade4a67eec501787fb3f95e1f05edce..f56499dc9abb0c3bd7da356552d15554ddf3999d 100644 --- a/plugins/CoreVisualizations/javascripts/seriesPicker.js +++ b/plugins/CoreVisualizations/javascripts/seriesPicker.js @@ -7,7 +7,7 @@ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later */ -(function ($, doc, require) { +(function ($, require) { /** * This class creates and manages the Series Picker for certain DataTable visualizations. @@ -37,6 +37,7 @@ * * @param {dataTable} dataTable The dataTable instance to add a series picker to. * @constructor + * @deprecated use the piwik-series-picker directive instead */ var SeriesPicker = function (dataTable) { this.domElem = null; @@ -54,17 +55,6 @@ // can multiple rows we selected? this.multiSelect = !! dataTable.props.allow_multi_select_series_picker; - - // language strings - this.lang = - { - metricsToPlot: _pk_translate('General_MetricsToPlot'), - metricToPlot: _pk_translate('General_MetricToPlot'), - recordsToPlot: _pk_translate('General_RecordsToPlot') - }; - - this._pickerState = null; - this._pickerPopover = null; }; SeriesPicker.prototype = { @@ -80,40 +70,62 @@ var self = this; + var selectedColumns = this.selectableColumns + .filter(isItemDisplayed) + .map(function (columnConfig) { + return columnConfig.column; + }); + + var selectedRows = this.selectableRows + .filter(isItemDisplayed) + .map(function (rowConfig) { + return rowConfig.matcher; + }); + // initialize dom element - this.domElem = $(doc.createElement('a')) - .addClass('jqplot-seriespicker') - .attr('href', '#') - .html('+') + var seriesPicker = '<piwik-series-picker' + + ' multiselect="' + (this.multiSelect ? 'true' : 'false') + '"' + + ' selectable-columns="selectableColumns"' + + ' selectable-rows="selectableRows"' + + ' selected-columns="selectedColumns"' + + ' selected-rows="selectedRows"' + + ' on-select="selectionChanged(columns, rows)"/>'; - // set opacity on 'hide' - .on('hide', function () { - $(this).css('opacity', .55); - }) - .trigger('hide') + this.domElem = $(seriesPicker); // TODO: don't know if this will work without a root scope - // show picker on hover - .hover( - function () { - var $this = $(this); + $(this).trigger('placeSeriesPicker'); - $this.css('opacity', 1); - if (!$this.hasClass('open')) { - $this.addClass('open'); - self._showPicker(); + piwikHelper.compileAngularComponents(this.domElem, { + scope: { + selectableColumns: this.selectableColumns, + selectableRows: this.selectableRows, + selectedColumns: selectedColumns, + selectedRows: selectedRows, + selectionChanged: function selectionChanged(columns, rows) { + if (columns.length === 0 && rows.length === 0) { + return; } - }, - function () { - // do nothing on mouseout because using this event doesn't work properly. - // instead, the timeout check beneath is used (_bindCheckPickerLeave()). + + $(self).trigger('seriesPicked', [columns, rows]); + + // inform dashboard widget about changed parameters (to be restored on reload) + var UI = require('piwik/UI'); + var params = { + columns: columns, + columns_to_display: columns, + rows: rows, + rows_to_display: rows + }; + + var tableNode = $('#' + this.dataTableId); + UI.DataTable.prototype.notifyWidgetParametersChange(tableNode, params); } - ) - .click(function (e) { - e.preventDefault(); - return false; - }); + } + }); - $(this).trigger('placeSeriesPicker'); + function isItemDisplayed(columnOrRowConfig) { + return columnOrRowConfig.displayed; + } }, /** @@ -124,247 +136,16 @@ * is returned. */ getMetricTranslation: function (metric) { - for (var i = 0; i != this.selectableColumns.length; ++i) { - if (this.selectableColumns[i].column == metric) { + for (var i = 0; i !== this.selectableColumns.length; ++i) { + if (this.selectableColumns[i].column === metric) { return this.selectableColumns[i].translation; } } return metric; - }, - - /** - * Creates the popover DOM element, binds event handlers to it, and then displays it. - */ - _showPicker: function () { - this._pickerState = {manipulated: false}; - this._pickerPopover = this._createPopover(); - - this._positionPopover(); - - // hide and replot on mouse leave - var self = this; - this._bindCheckPickerLeave(function () { - var replot = self._pickerState.manipulated; - self._hidePicker(replot); - }); - }, - - /** - * Creates a checkbox and related elements for a selectable column or selectable row. - */ - _createPickerPopupItem: function (config, type) { - var self = this; - - if (type == 'column') { - var columnName = config.column, - columnLabel = config.translation, - cssClass = 'pickColumn'; - } else { - var columnName = config.matcher, - columnLabel = config.label, - cssClass = 'pickRow'; - } - - var checkbox = $(document.createElement('input')).addClass('select') - .attr('type', this.multiSelect ? 'checkbox' : 'radio'); - - if (config.displayed && !(!this.multiSelect && this._pickerState.oneChecked)) { - checkbox.prop('checked', true); - this._pickerState.oneChecked = true; - } - - // if we are rendering a column, remember the column name - // if it's a row, remember the string that can be used to match the row - checkbox.data('name', columnName); - - var el = $(document.createElement('p')) - .append(checkbox) - .append($('<label/>').text(columnLabel)) - .addClass(cssClass); - - var replot = function () { - self._unbindPickerLeaveCheck(); - self._hidePicker(true); - }; - - var checkBox = function (box) { - if (!self.multiSelect) { - self._pickerPopover.find('input.select:not(.current)').prop('checked', false); - } - box.prop('checked', true); - replot(); - }; - - el.click(function (e) { - self._pickerState.manipulated = true; - var box = $(this).find('input.select'); - if (!$(e.target).is('input.select')) { - if (box.is(':checked')) { - box.prop('checked', false); - } else { - checkBox(box); - } - } else { - if (box.is(':checked')) { - checkBox(box); - } - } - }); - - return el; - }, - - /** - * Binds an event to document that checks if the user has left the series picker. - */ - _bindCheckPickerLeave: function (onLeaveCallback) { - var offset = this._pickerPopover.offset(); - var minX = offset.left; - var minY = offset.top; - var maxX = minX + this._pickerPopover.outerWidth(); - var maxY = minY + this._pickerPopover.outerHeight(); - - var self = this; - this._onMouseMove = function (e) { - var currentX = e.pageX, currentY = e.pageY; - if (currentX < minX || currentX > maxX - || currentY < minY || currentY > maxY - ) { - self._unbindPickerLeaveCheck(); - onLeaveCallback(); - } - }; - - $(doc).mousemove(this._onMouseMove); - }, - - /** - * Unbinds the callback that was bound in _bindCheckPickerLeave. - */ - _unbindPickerLeaveCheck: function () { - $(doc).unbind('mousemove', this._onMouseMove); - }, - - /** - * Removes and destroys the popover dom element. If any columns/rows were selected, the - * 'seriesPicked' event is triggered. - */ - _hidePicker: function (replot) { - // hide picker - this._pickerPopover.hide(); - this.domElem.trigger('hide').removeClass('open'); - - // replot - if (replot) { - var columns = []; - var rows = []; - this._pickerPopover.find('input:checked').each(function () { - if ($(this).closest('p').hasClass('pickRow')) { - rows.push($(this).data('name')); - } else { - columns.push($(this).data('name')); - } - }); - - var noRowSelected = this._pickerPopover.find('.pickRow').length > 0 - && this._pickerPopover.find('.pickRow input:checked').length === 0; - if (columns.length > 0 && !noRowSelected) { - $(this).trigger('seriesPicked', [columns, rows]); - - // inform dashboard widget about changed parameters (to be restored on reload) - var UI = require('piwik/UI') - var params = {columns: columns, columns_to_display: columns, - rows: rows, rows_to_display: rows}; - var tableNode = $('#' + this.dataTableId); - UI.DataTable.prototype.notifyWidgetParametersChange(tableNode, params); - } - } - - this._pickerPopover.remove(); - }, - - /** - * Creates and returns the popover element. This element shows a list of checkboxes, one - * for each selectable column/row. - */ - _createPopover: function () { - var hasColumns = $.isArray(this.selectableColumns) && this.selectableColumns.length; - var hasRows = $.isArray(this.selectableRows) && this.selectableRows.length; - - var popover = $('<div/>') - .addClass('jqplot-seriespicker-popover'); - - // create headline element - var title = this.multiSelect ? this.lang.metricsToPlot : this.lang.metricToPlot; - popover.append($('<p/>').addClass('headline').html(title)); - - // create selectable columns list - if (hasColumns) { - for (var i = 0; i < this.selectableColumns.length; i++) { - var column = this.selectableColumns[i]; - popover.append(this._createPickerPopupItem(column, 'column')); - } - } - - // create selectable rows list - if (hasRows) { - // "records to plot" subheadline - var header = $('<p/>').addClass('headline').addClass('recordsToPlot').html(this.lang.recordsToPlot); - popover.append(header); - - // render the selectable rows - for (var i = 0; i < this.selectableRows.length; i++) { - var row = this.selectableRows[i]; - popover.append(this._createPickerPopupItem(row, 'row')); - } - } - - popover.hide(); - - return popover; - }, - - /** - * Positions the popover element. - */ - _positionPopover: function () { - var $body = $('body'), - popover = this._pickerPopover, - pickerLink = this.domElem, - pickerLinkLeft = pickerLink.offset().left, - bodyRight = $body.offset().left + $body.width() - ; - - $body.prepend(popover); - - var neededSpace = popover.outerWidth() + 10; - - var linkOffset = pickerLink.offset(); - if (navigator.appVersion.indexOf("MSIE 7.") != -1) { - linkOffset.left -= 10; - } - - // try to display popover to the right - var margin = parseInt(pickerLink.css('margin-left')) - 4; - - var popoverRight = pickerLinkLeft + margin + neededSpace; - if (popoverRight < bodyRight - // make sure it's not too far to the left - || popoverRight < 0 - ) { - popover.css('margin-left', (linkOffset.left - 4) + 'px').show(); - } else { - // display to the left - popover.addClass('alignright') - .css('margin-left', (linkOffset.left - neededSpace + 38) + 'px') - .css('background-position', (popover.outerWidth() - 25) + 'px 4px') - .show(); - } - popover.css('margin-top', (linkOffset.top - 5) + 'px').show(); } }; var exports = require('piwik/DataTableVisualizations/Widgets'); exports.SeriesPicker = SeriesPicker; -})(jQuery, document, require); +})(jQuery, require); diff --git a/plugins/CoreVisualizations/stylesheets/jqplot.css b/plugins/CoreVisualizations/stylesheets/jqplot.css index ef08f1292f560632e903aa2a4e12cd26943cb60a..05a92a8e2f6e1df25f0e8d389edfc1869f62486b 100644 --- a/plugins/CoreVisualizations/stylesheets/jqplot.css +++ b/plugins/CoreVisualizations/stylesheets/jqplot.css @@ -211,8 +211,6 @@ a.rowevolution-startmulti { height: 16px; margin-top: 3px; background: url(../../Morpheus/images/chart_line_edit.png) no-repeat center center; - overflow: hidden; - text-indent: -999px; } .jqplot-seriespicker-popover { @@ -236,6 +234,7 @@ a.rowevolution-startmulti { padding: 0 4px 0 0; line-height: 15px; vertical-align: middle; + white-space: nowrap; } .jqplot-seriespicker-popover p.headline { diff --git a/plugins/Morpheus/javascripts/piwikHelper.js b/plugins/Morpheus/javascripts/piwikHelper.js index 6a59e0e68a85498512cd60e41438a3e03c866338..44ee3c4343f9c14257fd565080fa594a13811e1a 100644 --- a/plugins/Morpheus/javascripts/piwikHelper.js +++ b/plugins/Morpheus/javascripts/piwikHelper.js @@ -108,20 +108,32 @@ var piwikHelper = { * compiling of angular components manually. * * @param selector + * @param {object} options + * @param {object} options.scope if supplied, a new isolate scope is created as the parent scope of the + * compiled angular components. The properties in this object are + * added to the new scope. */ - compileAngularComponents: function (selector) { + compileAngularComponents: function (selector, options) { + options = options || {}; + var $element = $(selector); if (!$element.length) { - return; } angular.element(document).injector().invoke(function($compile) { var scope = angular.element($element).scope(); - if (scope) { - $compile($element)(scope); + if (!scope) { + return; } + + if (options.scope) { + scope = scope.$new(true); + $.extend(scope, options.scope); + } + + $compile($element)(scope); }); }, diff --git a/plugins/TreemapVisualization b/plugins/TreemapVisualization index 41631ecdc69bbf5f381aaadcf5c3216c224a9eb9..f3d6a1d94ec620e8805cda98f48aff300d55ce94 160000 --- a/plugins/TreemapVisualization +++ b/plugins/TreemapVisualization @@ -1 +1 @@ -Subproject commit 41631ecdc69bbf5f381aaadcf5c3216c224a9eb9 +Subproject commit f3d6a1d94ec620e8805cda98f48aff300d55ce94 diff --git a/tests/UI/expected-screenshots/BarGraph_metric_picker_shown.png b/tests/UI/expected-screenshots/BarGraph_metric_picker_shown.png index 3b1b59724ea0ed60d66f52bd67a522b02270af5e..95f5ccf51553627b7f8b48d0a2785d955b4d3172 100644 Binary files a/tests/UI/expected-screenshots/BarGraph_metric_picker_shown.png and b/tests/UI/expected-screenshots/BarGraph_metric_picker_shown.png differ diff --git a/tests/UI/expected-screenshots/EvolutionGraph_metric_picker_shown.png b/tests/UI/expected-screenshots/EvolutionGraph_metric_picker_shown.png index d17c429a53d59071e4f149925a0df8c48378508b..4fb676fe92297452c52d38b77c87a598af8943ac 100644 Binary files a/tests/UI/expected-screenshots/EvolutionGraph_metric_picker_shown.png and b/tests/UI/expected-screenshots/EvolutionGraph_metric_picker_shown.png differ diff --git a/tests/UI/expected-screenshots/PieGraph_metric_picker_shown.png b/tests/UI/expected-screenshots/PieGraph_metric_picker_shown.png index f83211fd239b6f8074274f5e1ec6d08e3468f84c..bce669c47e50233e056375ea226b2ac50fbdb5c7 100644 Binary files a/tests/UI/expected-screenshots/PieGraph_metric_picker_shown.png and b/tests/UI/expected-screenshots/PieGraph_metric_picker_shown.png differ diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_exampleui_treemap.png b/tests/UI/expected-screenshots/UIIntegrationTest_exampleui_treemap.png index 45b01f751113589edef3f161a2a9ea0b3651639e..62d92810af18c3332c23ece8a5cdba0e3b4d2056 100644 Binary files a/tests/UI/expected-screenshots/UIIntegrationTest_exampleui_treemap.png and b/tests/UI/expected-screenshots/UIIntegrationTest_exampleui_treemap.png differ