Commit 09b69d04 authored by David Matamoros's avatar David Matamoros Committed by Paul Holden
Browse files

MDL-70795 reportbuilder: custom report filters editor.



Implement module for filters editing, allowing the report
editor to define which filters are available when users
are viewing the report.

These filters allow those users viewing the report to further
limit the data being displayed (after conditions have been
applied), by selecting their own values for each filter.

Co-authored-by: Paul Holden's avatarPaul Holden <paulh@moodle.com>
parent ab596ec1
......@@ -48,11 +48,14 @@ $string['columnmoved'] = 'Moved column \'{$a}\'';
$string['customreports'] = 'Custom reports';
$string['deletecolumn'] = 'Delete column \'{$a}\'';
$string['deletecolumnconfirm'] = 'Are you sure you want to delete the column \'{$a}\'?';
$string['deletefilter'] = 'Delete filter \'{$a}\'';
$string['deletefilterconfirm'] = 'Are you sure you want to delete the filter \'{$a}\'?';
$string['entitycourse'] = 'Course';
$string['entityuser'] = 'User';
$string['errorreportaccess'] = 'You can not view this report';
$string['errorsourceinvalid'] = 'Could not find valid report source';
$string['errorsourceunavailable'] = 'Report source is not available';
$string['filteradded'] = 'Added filter \'{$a}\'';
$string['filtercontains'] = 'Contains';
$string['filterdatecurrent'] = 'Current';
$string['filterdatedays'] = 'day(s)';
......@@ -66,6 +69,7 @@ $string['filterdateseconds'] = 'second(s)';
$string['filterdateto'] = 'Date to';
$string['filterdateweeks'] = 'week(s)';
$string['filterdateyears'] = 'year(s)';
$string['filterdeleted'] = 'Deleted filter \'{$a}\'';
$string['filterdoesnotcontain'] = 'Does not contain';
$string['filterdurationunit'] = '{$a} unit';
$string['filterendswith'] = 'Ends with';
......@@ -81,17 +85,22 @@ $string['filterisequalto'] = 'Is equal to';
$string['filterisnotempty'] = 'Is not empty';
$string['filterisnotequalto'] = 'Is not equal to';
$string['filterlessthan'] = 'Less than';
$string['filtermoved'] = 'Moved filter \'{$a}\'';
$string['filterrange'] = 'Range';
$string['filtersapplied'] = 'Filters applied';
$string['filtersappliedx'] = 'Filters ({$a})';
$string['filters'] = 'Filters';
$string['filters_help'] = 'Report filters allow users viewing the report to further limit the data being displayed, by selecting their own values for each filter. Filter values are stored on a per-user basis.';
$string['filtersreset'] = 'Filters reset';
$string['filterstartswith'] = 'Starts with';
$string['includedefaultsetup'] = 'Include default setup';
$string['includedefaultsetup_help'] = 'Populate report with default layout as defined by the selected source. These include pre-defined columns, filters and conditions.';
$string['movecolumn'] = 'Move column \'{$a}\'';
$string['movecondition'] = 'Move condition \'{$a}\'';
$string['movefilter'] = 'Move filter \'{$a}\'';
$string['newreport'] = 'New report';
$string['noconditions'] = 'There are no conditions selected';
$string['nofilters'] = 'There are no filters selected';
$string['privacy:metadata:column'] = 'Report column definitions';
$string['privacy:metadata:column:uniqueidentifier'] = 'Unique identifier of the column';
$string['privacy:metadata:column:usercreated'] = 'The ID of the user who created the column';
......@@ -106,6 +115,7 @@ $string['privacy:metadata:report:name'] = 'The name of the report';
$string['privacy:metadata:report:usercreated'] = 'The ID of the user who created the report';
$string['privacy:metadata:report:usermodified'] = 'The ID of the user who last modified the report';
$string['renamecolumn'] = 'Rename column \'{$a}\'';
$string['renamefilter'] = 'Rename filter \'{$a}\'';
$string['reportbuilder'] = 'Report builder';
$string['reportcreated'] = 'Report created';
$string['reportdeleted'] = 'Report deleted';
......@@ -115,6 +125,7 @@ $string['reportsource_help'] = 'The report source defines where the data for the
$string['reportupdated'] = 'Report updated';
$string['resetall'] = 'Reset all';
$string['selectacondition'] = 'Select a condition';
$string['selectafilter'] = 'Select a filter';
$string['selectareportsource'] = 'Select a report source';
$string['selectcourses'] = 'Select courses';
$string['showhide'] = 'Show/hide \'{$a}\'';
......
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
......@@ -31,6 +31,7 @@ import Templates from 'core/templates';
import * as reportSelectors from 'core_reportbuilder/local/selectors';
import {init as columnsEditorInit} from 'core_reportbuilder/local/editor/columns';
import {init as conditionsEditorInit} from 'core_reportbuilder/local/editor/conditions';
import {init as filtersEditorInit} from 'core_reportbuilder/local/editor/filters';
import {getReport} from 'core_reportbuilder/local/repository/reports';
let initialized = false;
......@@ -43,6 +44,7 @@ export const init = () => {
columnsEditorInit(reportElement, initialized);
conditionsEditorInit(reportElement, initialized);
filtersEditorInit(reportElement, initialized);
// Ensure we only add our listeners once (can be called multiple times by mustache template).
if (initialized) {
......
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Report builder filters editor
*
* @module core_reportbuilder/local/editor/filters
* @package core_reportbuilder
* @copyright 2021 David Matamoros <davidmc@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
"use strict";
import $ from 'jquery';
import {dispatchEvent} from 'core/event_dispatcher';
import 'core/inplace_editable';
import Notification from 'core/notification';
import Pending from 'core/pending';
import SortableList from 'core/sortable_list';
import {get_string as getString, get_strings as getStrings} from 'core/str';
import Templates from 'core/templates';
import {add as addToast} from 'core/toast';
import * as reportEvents from 'core_reportbuilder/local/events';
import * as reportSelectors from 'core_reportbuilder/local/selectors';
import {addFilter, deleteFilter, reorderFilter} from 'core_reportbuilder/local/repository/filters';
/**
* Reload filters settings region
*
* @param {Element} reportElement
* @param {Object} templateContext
* @return {Promise}
*/
const reloadSettingsFiltersRegion = (reportElement, templateContext) => {
const pendingPromise = new Pending('core_reportbuilder/filters:reload');
const settingsFiltersRegion = reportElement.querySelector(reportSelectors.regions.settingsFilters);
return Templates.renderForPromise('core_reportbuilder/local/settings/filters', {filters: templateContext})
.then(({html, js}) => {
Templates.replaceNode(settingsFiltersRegion, html, js);
return pendingPromise.resolve();
});
};
/**
* Initialise module
*
* @param {Element} reportElement
* @param {Boolean} initialized Ensure we only add our listeners once
*/
export const init = (reportElement, initialized) => {
if (initialized) {
return;
}
reportElement.addEventListener('click', event => {
// Add filter to report.
const reportAddFilter = event.target.closest(reportSelectors.actions.reportAddFilter);
if (reportAddFilter) {
event.preventDefault();
// Check if dropdown is closed with no filter selected.
if (reportAddFilter.value === '0') {
return;
}
const pendingPromise = new Pending('core_reportbuilder/filters:add');
addFilter(reportElement.dataset.reportId, reportAddFilter.value)
.then(data => reloadSettingsFiltersRegion(reportElement, data))
.then(() => getString('filteradded', 'core_reportbuilder',
reportAddFilter.options[reportAddFilter.selectedIndex].text))
.then(addToast)
.then(() => pendingPromise.resolve())
.catch(Notification.exception);
}
// Remove filter from report.
const reportRemoveFilter = event.target.closest(reportSelectors.actions.reportRemoveFilter);
if (reportRemoveFilter) {
event.preventDefault();
const filterContainer = reportRemoveFilter.closest(reportSelectors.regions.activeFilter);
const filterName = filterContainer.dataset.filterName;
getStrings([
{key: 'deletefilter', component: 'core_reportbuilder', param: filterName},
{key: 'deletefilterconfirm', component: 'core_reportbuilder', param: filterName},
{key: 'delete', component: 'moodle'},
]).then(([confirmTitle, confirmText, confirmButton]) => {
Notification.confirm(confirmTitle, confirmText, confirmButton, null, () => {
const pendingPromise = new Pending('core_reportbuilder/filters:remove');
deleteFilter(reportElement.dataset.reportId, filterContainer.dataset.filterId)
.then(data => reloadSettingsFiltersRegion(reportElement, data))
.then(() => getString('filterdeleted', 'core_reportbuilder', filterName))
.then(addToast)
.then(() => {
dispatchEvent(reportEvents.tableReload, {}, reportElement);
return pendingPromise.resolve();
})
.catch(Notification.exception);
});
return;
}).catch(Notification.exception);
}
});
// Initialize sortable list to handle active filters moving (note JQuery dependency, see MDL-72293 for resolution).
var activeFiltersSortableList = new SortableList(`${reportSelectors.regions.activeFilters} ul`, {isHorizontal: false});
activeFiltersSortableList.getElementName = element => Promise.resolve(element.data('filterName'));
$(reportElement).on(SortableList.EVENTS.DROP, 'li[data-filter-id]', (event, info) => {
if (info.positionChanged) {
const pendingPromise = new Pending('core_reportbuilder/filters:reorder');
const filterId = info.element.data('filterId');
const filterPosition = info.element.data('filterPosition');
// Select target position, if moving to the end then count number of element siblings.
let targetFilterPosition = info.targetNextElement.data('filterPosition') || info.element.siblings().length + 2;
if (targetFilterPosition > filterPosition) {
targetFilterPosition--;
}
reorderFilter(reportElement.dataset.reportId, filterId, targetFilterPosition)
.then(data => reloadSettingsFiltersRegion(reportElement, data))
.then(() => getString('filtermoved', 'core_reportbuilder', info.element.data('filterName')))
.then(addToast)
.then(() => pendingPromise.resolve())
.catch(Notification.exception);
}
});
};
......@@ -38,3 +38,52 @@ export const resetFilters = reportId => {
return Ajax.call([request])[0];
};
/**
* Add a filter to the given report
*
* @param {Number} reportId
* @param {String} uniqueIdentifier
* @return {Promise}
*/
export const addFilter = (reportId, uniqueIdentifier) => {
const request = {
methodname: 'core_reportbuilder_filters_add',
args: {reportid: reportId, uniqueidentifier: uniqueIdentifier}
};
return Ajax.call([request])[0];
};
/**
* Remove filter from given report
*
* @param {Number} reportId
* @param {Number} filterId
* @return {Promise}
*/
export const deleteFilter = (reportId, filterId) => {
const request = {
methodname: 'core_reportbuilder_filters_delete',
args: {reportid: reportId, filterid: filterId}
};
return Ajax.call([request])[0];
};
/**
* Reorder a filter in a given report
*
* @param {Number} reportId
* @param {Number} filterId
* @param {Number} position
* @return {Promise}
*/
export const reorderFilter = (reportId, filterId, position) => {
const request = {
methodname: 'core_reportbuilder_filters_reorder',
args: {reportid: reportId, filterid: filterId, position: position}
};
return Ajax.call([request])[0];
};
......@@ -41,6 +41,9 @@ const SELECTORS = {
settingsConditions: '[data-region="settings-conditions"]',
activeConditions: '[data-region="active-conditions"]',
activeCondition: '[data-region="active-condition"]',
settingsFilters: '[data-region="settings-filters"]',
activeFilters: '[data-region="active-filters"]',
activeFilter: '[data-region="active-filter"]',
},
actions: {
reportActionPopup: '[data-action="report-action-popup"]',
......@@ -51,6 +54,8 @@ const SELECTORS = {
reportRemoveColumn: '[data-action="report-remove-column"]',
reportAddCondition: '[data-action="report-add-condition"]',
reportRemoveCondition: '[data-action="report-remove-condition"]',
reportAddFilter: '[data-action="report-add-filter"]',
reportRemoveFilter: '[data-action="report-remove-filter"]',
sidebarSearch: '[data-action="sidebar-search"]',
},
};
......
......@@ -95,6 +95,80 @@ abstract class datasource extends base {
*/
abstract public function get_default_columns(): array;
/**
* Add filters from the given entity name to be available to use in a custom report
*
* @param string $entityname
* @param array $include Include only these filters, if omitted then include all
* @param array $exclude Exclude these filters, if omitted then exclude none
* @throws coding_exception If both $include and $exclude are non-empty
*/
final protected function add_filters_from_entity(string $entityname, array $include = [], array $exclude = []): void {
if (!empty($include) && !empty($exclude)) {
throw new coding_exception('Cannot specify filters to include and exclude simultaneously');
}
$entity = $this->get_entity($entityname);
// Retrieve filtered filters from entity, respecting given $include/$exclude parameters.
$filters = array_filter($entity->get_filters(), static function(filter $filter) use ($include, $exclude): bool {
if (!empty($include)) {
return in_array($filter->get_name(), $include);
}
if (!empty($exclude)) {
return !in_array($filter->get_name(), $exclude);
}
return true;
});
foreach ($filters as $filter) {
$this->add_filter($filter);
}
}
/**
* Add default datasource filters to the report
*
* This method is optional and can be called when the report is created to add the default filters defined in the
* selected datasource.
*/
public function add_default_filters(): void {
$reportid = $this->get_report_persistent()->get('id');
$filteridentifiers = $this->get_default_filters();
foreach ($filteridentifiers as $uniqueidentifier) {
report::add_report_filter($reportid, $uniqueidentifier);
}
}
/**
* Return the filters that will be added to the report once is created
*
* @return string[]
*/
abstract public function get_default_filters(): array;
/**
* Return all configured report filters
*
* @return filter[]
*/
public function get_active_filters(): array {
$filters = [];
$activefilters = filter_model::get_filter_records($this->get_report_persistent()->get('id'), 'filterorder');
foreach ($activefilters as $filter) {
$instance = $this->get_filter($filter->get('uniqueidentifier'));
if ($instance !== null && $instance->get_is_available()) {
$filters[$instance->get_unique_identifier()] = $instance
->set_persistent($filter);
}
}
return $filters;
}
/**
* Add conditions from the given entity name to be available to use in a custom report
*
......
......@@ -89,6 +89,8 @@ class custom_report_exporter extends persistent_exporter {
'table' => ['type' => PARAM_RAW],
'sidebarmenucards' => ['type' => custom_report_menu_cards_exporter::read_properties_definition()],
'conditions' => ['type' => custom_report_conditions_exporter::read_properties_definition()],
'filters' => ['type' => custom_report_filters_exporter::read_properties_definition()],
'filtersapplied' => ['type' => PARAM_INT],
'filtersform' => [
'type' => PARAM_RAW,
'optional' => true,
......@@ -119,6 +121,7 @@ class custom_report_exporter extends persistent_exporter {
$report = manager::get_report_from_persistent($this->persistent);
$conditionsexporter = new custom_report_conditions_exporter(null, ['report' => $report]);
$filtersexporter = new custom_report_filters_exporter(null, ['report' => $report]);
if ($this->editmode) {
$menucardexporter = new custom_report_menu_cards_exporter(null, [
'menucards' => report_helper::get_available_columns($report->get_report_persistent())
......@@ -130,6 +133,8 @@ class custom_report_exporter extends persistent_exporter {
'table' => $output->render($table),
'sidebarmenucards' => $menucards,
'conditions' => (array) $conditionsexporter->export($output),
'filters' => (array) $filtersexporter->export($output),
'filtersapplied' => $report->get_applied_filter_count(),
'filtersform' => $filtersform,
'editmode' => (int)$this->editmode,
'showeditbutton' => $this->showeditbutton,
......
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
declare(strict_types=1);
namespace core_reportbuilder\external;
use renderer_base;
use core\external\exporter;
use core_reportbuilder\local\report\base;
use core_reportbuilder\local\models\filter;
use core_reportbuilder\output\filter_heading_editable;
/**
* Custom report filters exporter class
*
* @package core_reportbuilder
* @copyright 2021 Paul Holden <paulh@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class custom_report_filters_exporter extends exporter {
/**
* Return a list of objects that are related to the exporter
*
* @return array
*/
protected static function define_related(): array {
return [
'report' => base::class,
];
}
/**
* Return the list of additional properties for read structure and export
*
* @return array[]
*/
protected static function define_other_properties(): array {
return [
'hasavailablefilters' => [
'type' => PARAM_BOOL,
],
'availablefilters' => [
'type' => [
'optiongroup' => [
'type' => [
'text' => ['type' => PARAM_TEXT],
'values' => [
'type' => [
'value' => ['type' => PARAM_TEXT],
'visiblename' => ['type' => PARAM_TEXT],
],
'multiple' => true,
],
],
],
],
'multiple' => true,
'optional' => true
],
'hasactivefilters' => [
'type' => PARAM_BOOL,
],
'activefilters' => [
'type' => [
'id' => ['type' => PARAM_INT],
'heading' => ['type' => PARAM_TEXT],
'headingeditable' => ['type' => PARAM_RAW],
'sortorder' => ['type' => PARAM_INT],
'movetitle' => ['type' => PARAM_TEXT],
'entityname' => ['type' => PARAM_TEXT],
],
'multiple' => true,
'optional' => true
],
'helpicon' => [
'type' => PARAM_RAW,
],
];
}
/**
* Get the additional values to inject while exporting
*
* @param renderer_base $output
* @return array
*/
protected function get_other_values(renderer_base $output): array {
/** @var base $report */
$report = $this->related['report'];
// Current filter instances contained in the report.
$filterinstances = filter::get_filter_records($report->get_report_persistent()->get('id'), 'filterorder');
$filteridentifiers = array_map(static function(filter $filter): string {
return $filter->get('uniqueidentifier');
}, $filterinstances);
$availablefilters = $activefilters = [];
// Populate available filters.
foreach ($report->get_filters() as $filter) {
if (in_array($filter->get_unique_identifier(), $filteridentifiers)) {
continue;
}
$entityname = $filter->get_entity_name();
if (!array_key_exists($entityname, $availablefilters)) {
$availablefilters[$entityname] = [
'optiongroup' => [
'text' => $report->get_entity_title($entityname)->out(),
'values' => [],
],
];
}
$availablefilters[$entityname]['optiongroup']['values'][] = [
'value' => $filter->get_unique_identifier(),
'visiblename' => $filter->get_header(),
];
}
// Populate active filters.
foreach ($filterinstances as $filter) {
$editable = new filter_heading_editable($filter->get('id'));
$filterinstance = $report->get_filter($filter->get('uniqueidentifier'));
$entityname = $filterinstance->get_entity_name();
$displayvalue = $filterinstance->get_header();
$activefilters[] = [
'id' => $filter->get('id'),
'entityname' => $report->get_entity_title($entityname)->out(),
'heading' => $displayvalue,
'headingeditable' => $editable->render($output),
'sortorder' => $filter->get('filterorder'),
'movetitle' => get_string('movefilter', 'core_reportbuilder', $displayvalue),
];
}
return [
'hasavailablefilters' => !empty($availablefilters),
'availablefilters' => array_values($availablefilters),
'hasactivefilters' => !empty($activefilters),
'activefilters' => $activefilters,
'helpicon' => $output->help_icon('filters', 'core_reportbuilder'),
];
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of