matomo / plugins /CoreHome /javascripts /dataTable_rowactions.js
Leon4gr45's picture
Upload source files chunk 28
da96562 verified
Raw
History Blame Contribute Delete
26.2 kB
/**
* Registry for row actions
*
* Plugins can call DataTable_RowActions_Registry.register() from their JS
* files in order to add new actions to arbitrary data tables. The register()
* method takes an object containing:
* - name: string identifying the action. must be short, no spaces.
* - dataTableIcon: path to the icon for the action
* - createInstance: a factory method to create an instance of the appropriate
* subclass of DataTable_RowAction
* - isAvailable: a method to determine whether the action is available in a
* given row of a data table
*/
var DataTable_RowActions_Registry = {
registry: [],
register: function (action) {
var createInstance = action.createInstance;
action.createInstance = function (dataTable, param) {
var instance = createInstance(dataTable, param);
instance.actionName = action.name;
return instance;
};
this.registry.push(action);
},
getAvailableActionsForReport: function (dataTableParams, tr) {
if (dataTableParams.disable_row_actions == '1') {
return [];
}
var available = [];
for (var i = 0; i < this.registry.length; i++) {
if (this.registry[i].isAvailableOnReport(dataTableParams, tr)) {
available.push(this.registry[i]);
}
}
available.sort(function (a, b) {
return b.order - a.order;
});
return available;
},
getActionByName: function (name) {
for (var i = 0; i < this.registry.length; i++) {
if (this.registry[i].name == name) {
return this.registry[i];
}
}
return false;
}
};
// Register Row Evolution (also servers as example)
DataTable_RowActions_Registry.register({
name: 'RowEvolution',
dataTableIcon: 'icon-evolution',
order: 50,
dataTableIconTooltip: [
_pk_translate('General_RowEvolutionRowActionTooltipTitle'),
_pk_translate('General_RowEvolutionRowActionTooltip')
],
createInstance: function (dataTable, param) {
if (dataTable !== null && typeof dataTable.rowEvolutionActionInstance != 'undefined') {
return dataTable.rowEvolutionActionInstance;
}
if (dataTable === null && param) {
// when row evolution is triggered from the url (not a click on the data table)
// we look for the data table instance in the dom
// This actually doesn't work very good, as opening a row evolution using url params
// directly also triggers loading the report datatable, which might not yet be finished at
// this state, so the datatable might not yet be available
// When migrating/refactoring this it might be good to use promises in some way, so it would
// be possible to actually trigger the row evolution popover once the origin report was loaded.
var report = param.split(':')[0];
var div = $(require('piwik/UI').DataTable.getDataTableByReport(report));
if (div.length && div.data('uiControlObject')) {
dataTable = div.data('uiControlObject');
if (typeof dataTable.rowEvolutionActionInstance != 'undefined') {
return dataTable.rowEvolutionActionInstance;
}
}
}
var instance = new DataTable_RowActions_RowEvolution(dataTable);
if (dataTable !== null) {
dataTable.rowEvolutionActionInstance = instance;
}
return instance;
},
isAvailableOnReport: function (dataTableParams) {
return (
typeof dataTableParams.disable_row_evolution == 'undefined'
|| dataTableParams.disable_row_evolution == "0"
);
},
isAvailableOnRow: function (dataTableParams, tr) {
return !tr.hasClass('totalsRow');
}
});
/**
* DataTable Row Actions
*
* The lifecycle of an action is as follows:
* - for each data table, a new instance of the action is created using the factory
* - when the table is loaded, initTr is called for each tr
* - when the action icon is clicked, trigger is called
* - the label is put together and performAction is called
* - performAction must call openPopover on the base class
* - openPopover calls back doOpenPopover after doing general stuff
*
* The two template methods are performAction and doOpenPopover
*/
//
// BASE CLASS
//
function DataTable_RowAction(dataTable) {
this.dataTable = dataTable;
// has to be overridden in subclasses
this.trEventName = 'piwikTriggerRowAction';
// set in registry
this.actionName = 'RowAction';
}
/** Initialize a row when the table is loaded */
DataTable_RowAction.prototype.initTr = function (tr) {
var self = this;
// For subtables, we need to make sure that the actions are always triggered on the
// action instance connected to the root table. Otherwise sharing data (e.g. for
// for multi-row evolution) wouldn't be possible. Also, sub-tables might have different
// API actions. For the label filter to work, we need to use the parent action.
// We use jQuery events to let subtables access their parents.
tr.unbind(self.trEventName).bind(self.trEventName, function (e, params) {
self.trigger($(this), params.originalEvent, params.label, params.originalRow);
});
};
/**
* This method is called from the click event and the tr event (see this.trEventName).
* It derives the label and calls performAction.
*/
DataTable_RowAction.prototype.trigger = function (tr, e, subTableLabel, originalRow) {
var label = this.getLabelFromTr(tr);
// if we have received the event from the sub table, add the label
if (subTableLabel) {
var separator = ' > '; // LabelFilter::SEPARATOR_RECURSIVE_LABEL
label += separator + subTableLabel;
}
// handle sub tables in nested reports: forward to parent
var subtable = tr.closest('table');
if (subtable.is('.subDataTable')) {
subtable.closest('tr').prev().trigger(this.trEventName, {
label: label,
originalEvent: e,
originalRow: tr
});
return;
}
// ascend in action reports
var $dataTable = subtable.closest('div.dataTable');
if ($dataTable.hasClass('dataTableActions')
|| $dataTable.data('table-type') === 'ActionsDataTable'
) {
var allClasses = tr.attr('class');
var matches = allClasses.match(/level[0-9]+/);
var level = parseInt(matches[0].substring(5, matches[0].length), 10);
if (level > 0) {
// .prev(.levelX) does not work for some reason => do it "by hand"
var findLevel = 'level' + (level - 1);
var ptr = tr;
while ((ptr = ptr.prev()).length) {
if (!ptr.hasClass(findLevel) || ptr.hasClass('nodata')) {
continue;
}
ptr.trigger(this.trEventName, {
label: label,
originalEvent: e,
originalRow: tr
});
return;
}
}
}
this.performAction(label, tr, e, originalRow);
};
/** Get the label string from a tr dom element */
DataTable_RowAction.prototype.getLabelFromTr = function (tr) {
if (tr.data('label')) {
return tr.data('label');
}
var rowMetadata = this.getRowMetadata(tr);
if (rowMetadata.combinedLabel) {
return '@' + rowMetadata.combinedLabel;
}
var label = tr.find('span.label');
// handle truncation
var value = label.data('originalText');
if (!value) {
value = label.text();
}
value = value.trim();
value = encodeURIComponent(value);
// if tr is a terminal node, we use the @ operator to distinguish it from branch nodes w/ the same name
if (!tr.hasClass('subDataTable')) {
value = '@' + value;
}
return value;
};
/** Get row metadata object */
DataTable_RowAction.prototype.getRowMetadata = function (tr) {
return tr.data('row-metadata') || {};
};
/**
* Base method for opening popovers.
* This method will remember the parameter in the url and call doOpenPopover().
*/
DataTable_RowAction.prototype.openPopover = function (parameter) {
broadcast.propagateNewPopoverParameter('RowAction', this.actionName + ':' + parameter);
};
broadcast.addPopoverHandler('RowAction', function (param) {
var paramParts = param.split(':');
var rowActionName = paramParts[0];
paramParts.shift();
param = paramParts.join(':');
var rowAction = DataTable_RowActions_Registry.getActionByName(rowActionName);
if (rowAction) {
rowAction.createInstance(null, param).doOpenPopover(param);
}
});
/** To be overridden */
DataTable_RowAction.prototype.performAction = function (label, tr, e) {
};
DataTable_RowAction.prototype.doOpenPopover = function (parameter) {
};
//
// ROW EVOLUTION
//
function DataTable_RowActions_RowEvolution(dataTable) {
this.dataTable = dataTable;
this.trEventName = 'piwikTriggerRowEvolution';
/** The rows to be compared in multi row evolution */
this.multiEvolutionRows = [];
this.multiEvolutionRowsPretty = [];
this.multiEvolutionRowsSeries = [];
this.popoverRequestParams = null;
this._popoverRequest = null;
this._themeModeChangeListener = null;
this._popoverRequestSequence = 0;
}
/** Static helper method to launch row evolution from anywhere */
DataTable_RowActions_RowEvolution.launch = function (apiMethod, label) {
var param = 'RowEvolution:' + apiMethod + ':0:' + label;
broadcast.propagateNewPopoverParameter('RowAction', param);
};
DataTable_RowActions_RowEvolution.prototype = new DataTable_RowAction;
DataTable_RowActions_RowEvolution.prototype.performAction = function (label, tr, e, originalRow) {
if (e.shiftKey) {
// only mark for multi row evolution if shift key is pressed
this.addMultiEvolutionRow(label, $(originalRow || tr).data('comparison-series'), originalRow || tr);
return;
}
this.addMultiEvolutionRow(label, $(originalRow || tr).data('comparison-series'), originalRow || tr);
// check whether we have rows marked for multi row evolution
var extraParams = $.extend({}, $(originalRow || tr).data('param-override'));
if (typeof extraParams !== 'object') {
extraParams = {};
}
if (this.multiEvolutionRows.length > 1) {
extraParams.action = 'getMultiRowEvolutionPopover';
label = this.multiEvolutionRows.join(',');
labelPretty = this.multiEvolutionRowsPretty.join(',');
if (label != labelPretty) {
extraParams.labelPretty = labelPretty;
}
if (this.multiEvolutionRowsSeries.length > 1) { // when comparison is active
var MatomoUrl = window.CoreHome.MatomoUrl;
extraParams.compareDates = MatomoUrl.parsed.value.compareDates;
extraParams.comparePeriods = MatomoUrl.parsed.value.comparePeriods;
extraParams.compareSegments = MatomoUrl.parsed.value.compareSegments;
extraParams.labelSeries = this.multiEvolutionRowsSeries.join(',');
// remove override period/date/segment since we are sending compare params so we can have the whole set of comparison
// serieses for LabelFilter
delete extraParams.period;
delete extraParams.date;
delete extraParams.segment;
}
} else {
var labelPretty = this.getPrettyLabel(originalRow || tr);
if (labelPretty && labelPretty != label) {
extraParams['labelPretty'] = labelPretty;
}
}
$.each(this.dataTable.param, function (index, value) {
// we automatically add fields like idDimension, idGoal etc.
if (DataTable_RowActions_RowEvolution.isAllowedIdExtraParam(index, value)) {
extraParams[index] = value;
}
});
if (this.dataTable && this.dataTable.jsViewDataTable === 'tableGoals') {
// When there is a idGoal parameter available, the user is currently viewing a Goal or Ecommerce page
// In this case we want to show the specific goal metrics in the row evolution
if (extraParams['idGoal']) {
extraParams['showGoalMetricsForGoal'] = extraParams['idGoal'];
delete(extraParams['idGoal']);
}
// If no idGoal is available it is a random report switched to goal visualization
// we then ensure the row evolution will show the goal overview metrics
else {
extraParams['showGoalMetricsForGoal'] = -1;
}
}
// check if abandonedCarts is in the dataTable params and if so, propagate to row evolution request
if (this.dataTable.param.abandonedCarts !== undefined) {
extraParams['abandonedCarts'] = this.dataTable.param.abandonedCarts;
}
if (this.dataTable.param.secondaryDimension !== undefined) {
extraParams['secondaryDimension'] = this.dataTable.param.secondaryDimension;
}
if (this.dataTable.param.flat !== undefined) {
var unflattenActionLabel = function(label) {
// To "unflatten" a label we need to convert labels from e.g.
// * @%2Fblog%2Fauthor%2Fjulien%2F
// * @%2Fcontact
// * @%2Fanalytics%2Fcultizer
// to e.g.
// * blog > author > julien > @%2Findex
// * @%2Fcontact
// * analytics > @%2Fcultizer
return label.split(',').map(function(item) {
var startsWithAt = item.startsWith('@');
if (startsWithAt ) {
item = item.slice(1);
}
item = decodeURIComponent(item);
if (item === '/') {
return (startsWithAt ? '@' : '') + encodeURIComponent('/index');
}
var isIndex = false;
if (item.endsWith('/')) {
item = item.slice(0, -1);
isIndex = true;
}
if (item.startsWith('/')) {
item = item.slice(1);
}
var parts = item.split('/').map(encodeURIComponent);
if (isIndex) {
parts.push(encodeURIComponent('/index'));
} else {
parts[parts.length - 1] = '/' + parts[parts.length - 1];
}
if (startsWithAt ) {
parts[parts.length - 1] = '@' + parts[parts.length - 1];
}
return parts.join(' > ');
}).join(',');
};
if (
this.dataTable.param.module === 'Actions' && this.dataTable.param.action === 'getPageUrls'
&& this.dataTable.param.flat && label.indexOf(' > ') === -1
) {
// Requesting a row evolution for a flattened page url report can easily reach memory limits
// This happens due to the fact, that requesting a report flattened, will currently process
// the data for ALL subtables, for all periods shown in the row evolution.
// We actually would only need to fetch the data for the requested labels.
// Till this was refactored in the backend, this hack will convert the flattened request
// into a request that would come from a subtable. This is handled differently by the backend
// and will only process the requested labels in the backend.
label = unflattenActionLabel(label);
extraParams['flat'] = 0;
} else {
extraParams['flat'] = this.dataTable.param.flat;
}
}
var apiMethod = this.dataTable.param.module + '.' + this.dataTable.param.action;
this.openPopover(apiMethod, extraParams, label);
};
DataTable_RowActions_RowEvolution.prototype.getPrettyLabel = function getPrettyLabel(tr) {
if (!this.dataTable.props.row_identifier || this.dataTable.props.row_identifier === 'label') {
return null; // only necessary if a custom row identifier is provided for the report
}
var prettyLabel = [];
var row = $(tr);
while (row.length) {
var label = row.data('label-pretty') || this.getLabelFromTr(row);
prettyLabel.unshift(label);
var subtable = row.closest('table');
if (subtable.is('.subDataTable')) {
row = subtable.closest('tr').prev();
} else {
break;
}
}
return prettyLabel.join(' > ');
};
DataTable_RowActions_RowEvolution.prototype.addMultiEvolutionRow = function (label, seriesIndex, tr) {
if (typeof seriesIndex !== 'undefined') {
var self = this;
var found = false;
this.multiEvolutionRows.forEach(function (rowLabel, index) {
var rowSeriesIndex = self.multiEvolutionRowsSeries[index];
if (label === rowLabel && seriesIndex === rowSeriesIndex) {
found = true;
return false;
}
});
if (!found) {
this.multiEvolutionRows.push(label);
this.multiEvolutionRowsPretty.push(this.getPrettyLabel(tr));
this.multiEvolutionRowsSeries.push(seriesIndex);
}
} else if ($.inArray(label, this.multiEvolutionRows) === -1) {
this.multiEvolutionRows.push(label);
this.multiEvolutionRowsPretty.push(this.getPrettyLabel(tr))
this.multiEvolutionRowsSeries = []; // for safety, make sure state is consistent
}
};
DataTable_RowActions_RowEvolution.prototype.openPopover = function (apiMethod, extraParams, label) {
var urlParam = apiMethod + ':' + encodeURIComponent(JSON.stringify(extraParams)) + ':' + label;
DataTable_RowAction.prototype.openPopover.apply(this, [urlParam]);
};
// Allowlist of `extraParams` keys that may flow from the popover URL hash into
// the Row Evolution XHR.
DataTable_RowActions_RowEvolution.allowedExtraParamKeys = [
'column',
'action',
'labelPretty',
'labelSeries',
'showGoalMetricsForGoal',
'abandonedCarts',
'secondaryDimension',
'flat',
'compareDates',
'comparePeriods',
'compareSegments',
'segment',
'period',
'date'
];
// Dynamic `id*` keys (idGoal, idDimension, idSubtable, ...). Used both when
// building the row-evolution URL in `performAction` and when parsing it back
// in `doOpenPopover`, so both sides accept the same set of params.
DataTable_RowActions_RowEvolution.isAllowedIdExtraParam = function (key, value) {
return key !== 'idSite'
&& key.indexOf('id') === 0
&& ($.isNumeric(value) || (typeof value === 'string' && value.indexOf('ecommerce') === 0));
};
// Filter a parsed extraParams object against the allowlist. Returns a new
// object containing only keys that are either in `allowedExtraParamKeys` or
// accepted by `isAllowedIdExtraParam`. The `action` key is additionally pinned
// to the multi-row marker value.
DataTable_RowActions_RowEvolution.filterAllowedExtraParams = function (parsed) {
var result = {};
var allowed = DataTable_RowActions_RowEvolution.allowedExtraParamKeys;
for (var key in parsed) {
if (!Object.prototype.hasOwnProperty.call(parsed, key)) {
continue;
}
var value = parsed[key];
// `action` is allowed only as the multi-row marker. Layer 2 in
// showRowEvolution unconditionally pins requestParams.action, but
// the value-check keeps the in-memory extraParams honest.
if (key === 'action' && value !== 'getMultiRowEvolutionPopover') {
continue;
}
if (allowed.indexOf(key) !== -1
|| DataTable_RowActions_RowEvolution.isAllowedIdExtraParam(key, value)) {
result[key] = value;
}
}
return result;
};
DataTable_RowActions_RowEvolution.prototype.doOpenPopover = function (urlParam) {
var urlParamParts = urlParam.split(':');
var apiMethod = urlParamParts.shift();
var extraParamsString = urlParamParts.shift();
var label = urlParamParts.join(':');
var extraParams = {};
try {
var parsed = JSON.parse(decodeURIComponent(extraParamsString));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('extraParams must be a JSON object');
}
extraParams = DataTable_RowActions_RowEvolution.filterAllowedExtraParams(parsed);
} catch (e) {
// Legacy short-form: bare "0"/"1"/<column-name> instead of a JSON envelope.
if (extraParamsString == '1') {
extraParams.action = 'getMultiRowEvolutionPopover';
} else if (extraParamsString != '0') {
extraParams.action = 'getMultiRowEvolutionPopover';
extraParams.column = String(extraParamsString);
}
}
this.showRowEvolution(apiMethod, label, extraParams);
};
DataTable_RowActions_RowEvolution.prototype.fetchRowEvolution = function (requestParams, callback) {
var ajaxRequest = new ajaxHelper();
ajaxRequest.addParams(requestParams, 'get');
ajaxRequest.withTokenInUrl();
ajaxRequest.setCallback(callback);
ajaxRequest.setFormat('html');
ajaxRequest.send();
return ajaxRequest;
};
DataTable_RowActions_RowEvolution.prototype.abortPopoverRequest = function () {
if (this._popoverRequest) {
this._popoverRequest.abort();
this._popoverRequest = null;
}
};
DataTable_RowActions_RowEvolution.prototype.loadLatestPopover = function (requestParams, callback) {
var self = this;
var requestSequence;
this.abortPopoverRequest();
requestSequence = ++this._popoverRequestSequence;
this._popoverRequest = this.fetchRowEvolution(requestParams, function (html) {
self._popoverRequest = null;
if (!Piwik_Popover.isOpen() || requestSequence !== self._popoverRequestSequence) {
return;
}
callback(html);
});
};
/** Open the row evolution popover */
DataTable_RowActions_RowEvolution.prototype.showRowEvolution = function (apiMethod, label, extraParams) {
var self = this;
// open the popover
var box = Piwik_Popover.showLoading('Row Evolution');
box.addClass('rowEvolutionPopover');
// prepare loading the popover contents
var requestParams = {
apiMethod: apiMethod,
label: label,
disableLink: 1
};
var callback = function (html) {
Piwik_Popover.setContent(html);
// use the popover title returned from the server
var title = box.find('div.popover-title');
if (title.length) {
Piwik_Popover.setTitle(title.html());
title.remove();
}
Piwik_Popover.onClose(function () {
if (!Piwik_Popover.isOpen()) {
// reset rows marked for multi row evolution on close
self.multiEvolutionRows = [];
self.multiEvolutionRowsPretty = [];
self.multiEvolutionRowsSeries = [];
self.popoverRequestParams = null;
self._popoverRequestSequence++;
self.abortPopoverRequest();
if (self._themeModeChangeListener) {
window.removeEventListener('themeModeChange', self._themeModeChangeListener);
self._themeModeChangeListener = null;
}
}
});
if (self.dataTable !== null) {
// remember label for multi row evolution
box.find('.rowevolution-startmulti').click(function () {
Piwik_Popover.onClose(false); // unbind listener that resets multiEvolutionRows
broadcast.propagateNewPopoverParameter(false);
return false;
});
} else {
// when the popover is launched by copy&pasting a url, we don't have the data table.
// in this case, we can't remember the row marked for multi row evolution so
// we disable the picker.
box.find('.compare-container, .rowevolution-startmulti').remove();
}
// switch metric in multi row evolution
box.find('select.multirowevoltion-metric').change(function () {
var metric = $(this).val();
Piwik_Popover.onClose(false); // unbind listener that resets multiEvolutionRows
extraParams.column = metric;
self.openPopover(apiMethod, extraParams, label);
return true;
});
};
requestParams.colors = JSON.stringify(piwik.getSparklineColors());
var idDimension;
if (broadcast.getValueFromUrl('module') === 'Widgetize') {
idDimension = broadcast.getValueFromUrl('subcategory');
} else {
idDimension = broadcast.getValueFromHash('subcategory');
}
if (idDimension && ('' + idDimension).indexOf('customdimension') === 0) {
idDimension = ('' + idDimension).replace('customdimension', '');
idDimension = parseInt(idDimension, 10);
if (idDimension > 0) {
requestParams.idDimension = idDimension;
}
}
var wantMultiRowEvolution = extraParams && extraParams.action === 'getMultiRowEvolutionPopover';
$.extend(requestParams, extraParams);
// Pin routing AFTER the merge so it cannot be overridden by any path.
requestParams.module = 'CoreHome';
requestParams.action = wantMultiRowEvolution ? 'getMultiRowEvolutionPopover' : 'getRowEvolutionPopover';
this.popoverRequestParams = $.extend(true, {}, requestParams);
if (this._themeModeChangeListener) {
window.removeEventListener('themeModeChange', this._themeModeChangeListener);
}
this._themeModeChangeListener = function () {
if (!self.popoverRequestParams) {
return;
}
self.popoverRequestParams.colors = JSON.stringify(piwik.getSparklineColors());
self.loadLatestPopover(self.popoverRequestParams, callback);
};
window.addEventListener('themeModeChange', this._themeModeChangeListener);
this.loadLatestPopover(requestParams, callback);
};