Commit 144084a2 authored by Mikel Martín Corrales's avatar Mikel Martín Corrales Committed by David Matamoros
Browse files

MDL-72588 reportbuilder: interface for managing report audiences.



Add new tab to editor, allowing user to select from available audience
types in order to grant access to their reports.

Co-authored-by: David Matamoros's avatarDavid Matamoros <davidmc@moodle.com>
parent 70085ef7
......@@ -60,6 +60,7 @@ $string['eventcohortupdated'] = 'Cohort updated';
$string['external'] = 'External cohort';
$string['invalidtheme'] = 'Cohort theme does not exist';
$string['idnumber'] = 'Cohort ID';
$string['memberofcohort'] = 'Member of cohort';
$string['memberscount'] = 'Cohort size';
$string['name'] = 'Name';
$string['namecolumnmissing'] = 'There is something wrong with the format of the CSV file. Please check that it includes the correct column names. To add users to a cohort, go to \'Upload users\' in the Site administration.';
......
......@@ -23,6 +23,8 @@
*/
$string['actions'] = 'Actions';
$string['addaudience'] = 'Add audience \'{$a}\'';
$string['addaudiences'] = 'Add an audience to this report';
$string['addcolumn'] = 'Add column \'{$a}\'';
$string['addusers'] = 'Add users manually';
$string['aggregatecolumn'] = 'Aggregate column \'{$a}\'';
......@@ -39,6 +41,11 @@ $string['aggregationsum'] = 'Sum';
$string['allsiteusers'] = 'All site users';
$string['allusers'] = 'All users';
$string['apply'] = 'Apply';
$string['audience'] = 'Audience';
$string['audienceadded'] = 'Added audience \'{$a}\'';
$string['audiencedeleted'] = 'Deleted audience \'{$a}\'';
$string['audiencemultiselectpostfix'] = '{$a->elements} plus {$a->morecount} more';
$string['audiencenotsaved'] = 'Audience not saved';
$string['columnadded'] = 'Added column \'{$a}\'';
$string['columnaggregated'] = 'Aggregated column \'{$a}\'';
$string['columndeleted'] = 'Deleted column \'{$a}\'';
......@@ -60,6 +67,8 @@ $string['courseidnumberewithlink'] = 'Course ID number with link';
$string['courseshortnamewithlink'] = 'Course short name with link';
$string['customfieldcolumn'] = '{$a}';
$string['customreports'] = 'Custom reports';
$string['deleteaudience'] = 'Delete audience \'{$a}\'';
$string['deleteaudienceconfirm'] = 'Are you sure you want to delete the audience \'{$a}\'?';
$string['deletecolumn'] = 'Delete column \'{$a}\'';
$string['deletecolumnconfirm'] = 'Are you sure you want to delete the column \'{$a}\'?';
$string['deletecondition'] = 'Delete condition \'{$a}\'';
......@@ -68,6 +77,7 @@ $string['deletefilter'] = 'Delete filter \'{$a}\'';
$string['deletefilterconfirm'] = 'Are you sure you want to delete the filter \'{$a}\'?';
$string['deletereport'] = 'Delete report';
$string['deletereportconfirm'] = 'Are you sure you want to delete the report \'{$a}\' and all associated data?';
$string['editaudience'] = 'Edit audience \'{$a}\'';
$string['editdetails'] = 'Edit details';
$string['editor'] = 'Editor';
$string['editreportcontent'] = 'Edit report content';
......@@ -130,6 +140,7 @@ $string['newreport'] = 'New report';
$string['noconditions'] = 'There are no conditions selected';
$string['nofilters'] = 'There are no filters selected';
$string['nosortablecolumns'] = 'There are no sortable columns';
$string['or'] = 'or';
$string['privacy:metadata:audience'] = 'Report audience definitions';
$string['privacy:metadata:audience:classname'] = 'Class used by the audience';
$string['privacy:metadata:audience:usercreated'] = 'The ID of the user who created the audience';
......
......@@ -2888,6 +2888,12 @@ $functions = array(
'type' => 'write',
'ajax' => true,
],
'core_reportbuilder_audiences_delete' => [
'classname' => 'core_reportbuilder\external\audiences\delete',
'description' => 'Delete audience from report',
'type' => 'write',
'ajax' => true,
],
);
$services = array(
......
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.
// 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 audiences
*
* @module core_reportbuilder/audience
* @copyright 2021 David Matamoros <davidmc@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
"use strict";
import Templates from 'core/templates';
import Notification from 'core/notification';
import Pending from 'core/pending';
import {get_string as getString, get_strings as getStrings} from 'core/str';
import DynamicForm from 'core_form/dynamicform';
import {add as addToast} from 'core/toast';
import {deleteAudience} from 'core_reportbuilder/local/repository/audiences';
import * as reportSelectors from 'core_reportbuilder/local/selectors';
import {loadFragment} from 'core/fragment';
let reportId = 0;
let contextId = 0;
/**
* Add audience card
*
* @param {String} className
* @param {String} title
*/
const addAudienceCard = (className, title) => {
const pendingPromise = new Pending('core_reportbuilder/audience:add');
const audiencesContainer = document.querySelector(reportSelectors.regions.audiencesContainer);
const audienceCardLength = audiencesContainer.querySelectorAll(reportSelectors.regions.audienceCard).length;
const params = {
classname: className,
reportid: reportId,
showormessage: (audienceCardLength > 0),
title: title,
};
// Load audience card fragment, render and then initialise the form within.
loadFragment('core_reportbuilder', 'audience_form', contextId, params)
.then((html, js) => {
const audienceCard = Templates.appendNodeContents(audiencesContainer, html, js)[0];
const audienceEmptyMessage = audiencesContainer.querySelector(reportSelectors.regions.audienceEmptyMessage);
initAudienceCardForm(audienceCard);
audienceEmptyMessage.classList.add('hidden');
return getString('audienceadded', 'core_reportbuilder', title);
})
.then(addToast)
.then(() => pendingPromise.resolve())
.catch(Notification.exception);
};
/**
* Edit audience card
*
* @param {Element} audienceCard
*/
const editAudienceCard = audienceCard => {
const pendingPromise = new Pending('core_reportbuilder/audience:edit');
const audienceForm = initAudienceCardForm(audienceCard);
const audienceFormData = {
reportid: reportId,
id: audienceCard.dataset.instanceid,
classname: audienceCard.dataset.classname
};
// Load audience form with data for editing, then toggle visible controls in the card.
audienceForm.load(audienceFormData)
.then(() => {
const audienceFormContainer = audienceCard.querySelector(reportSelectors.regions.audienceFormContainer);
const audienceDescription = audienceCard.querySelector(reportSelectors.regions.audienceDescription);
const audienceEdit = audienceCard.querySelector(reportSelectors.actions.audienceEdit);
audienceFormContainer.classList.remove('hidden');
audienceDescription.classList.add('hidden');
audienceEdit.disabled = true;
return pendingPromise.resolve();
})
.catch(Notification.exception);
};
/**
* Initialise dynamic form within given audience card
*
* @param {Element} audienceCard
* @return {DynamicForm}
*/
const initAudienceCardForm = audienceCard => {
const audienceFormContainer = audienceCard.querySelector(reportSelectors.regions.audienceFormContainer);
const audienceForm = new DynamicForm(audienceFormContainer, '\\core_reportbuilder\\form\\audience');
// After submitting the form, update the card instance and description properties.
audienceForm.addEventListener(audienceForm.events.FORM_SUBMITTED, data => {
const audienceDescription = audienceCard.querySelector(reportSelectors.regions.audienceDescription);
audienceCard.dataset.instanceid = data.detail.instanceid;
audienceDescription.innerHTML = data.detail.description;
closeAudienceCardForm(audienceCard);
});
// If cancelling the form, close the card or remove it if it was never created.
audienceForm.addEventListener(audienceForm.events.FORM_CANCELLED, () => {
if (audienceCard.dataset.instanceid > 0) {
closeAudienceCardForm(audienceCard);
} else {
removeAudienceCard(audienceCard);
}
});
return audienceForm;
};
/**
* Delete audience card
*
* @param {Element} audienceCard
*/
const deleteAudienceCard = audienceCard => {
const audienceTitle = audienceCard.dataset.title;
getStrings([
{key: 'deleteaudience', component: 'core_reportbuilder', param: audienceTitle},
{key: 'deleteaudienceconfirm', component: 'core_reportbuilder', param: audienceTitle},
{key: 'delete', component: 'moodle'},
]).then(([confirmTitle, confirmText, confirmButton]) => {
Notification.confirm(confirmTitle, confirmText, confirmButton, null, () => {
const pendingPromise = new Pending('core_reportbuilder/audience:delete');
deleteAudience(reportId, audienceCard.dataset.instanceid)
.then(() => getString('audiencedeleted', 'core_reportbuilder', audienceTitle))
.then(addToast)
.then(() => {
removeAudienceCard(audienceCard);
return pendingPromise.resolve();
})
.catch(Notification.exception);
});
return;
}).catch(Notification.exception);
};
/**
* Close audience card form
*
* @param {Element} audienceCard
*/
const closeAudienceCardForm = audienceCard => {
// Remove the [data-region="audience-form-container"] (with all the event listeners attached to it), and create it again.
const audienceFormContainer = audienceCard.querySelector(reportSelectors.regions.audienceFormContainer);
const NewAudienceFormContainer = audienceFormContainer.cloneNode(false);
audienceCard.querySelector(reportSelectors.regions.audienceForm).replaceChild(NewAudienceFormContainer, audienceFormContainer);
// Show the description container and enable the action buttons.
audienceCard.querySelector(reportSelectors.regions.audienceDescription).classList.remove('hidden');
audienceCard.querySelector(reportSelectors.actions.audienceEdit).disabled = false;
audienceCard.querySelector(reportSelectors.actions.audienceDelete).disabled = false;
};
/**
* Remove audience card
*
* @param {Element} audienceCard
*/
const removeAudienceCard = audienceCard => {
audienceCard.remove();
const audiencesContainer = document.querySelector(reportSelectors.regions.audiencesContainer);
const audienceCards = audiencesContainer.querySelectorAll(reportSelectors.regions.audienceCard);
// Show message if there are no cards remaining, ensure first card's separator is not present.
if (audienceCards.length === 0) {
const audienceEmptyMessage = document.querySelector(reportSelectors.regions.audienceEmptyMessage);
audienceEmptyMessage.classList.remove('hidden');
} else {
const audienceFirstCardSeparator = audienceCards[0].querySelector('.audience-separator');
audienceFirstCardSeparator?.remove();
}
};
let initialized = false;
/**
* Initialise audiences tab.
*
* @param {Number} id
* @param {Number} contextid
*/
export const init = (id, contextid) => {
reportId = id;
contextId = contextid;
if (initialized) {
// We already added the event listeners (can be called multiple times by mustache template).
return;
}
document.addEventListener('click', event => {
// Add instance.
const audienceAdd = event.target.closest(reportSelectors.actions.audienceAdd);
if (audienceAdd) {
event.preventDefault();
addAudienceCard(audienceAdd.dataset.uniqueIdentifier, audienceAdd.dataset.name);
}
// Edit instance.
const audienceEdit = event.target.closest(reportSelectors.actions.audienceEdit);
if (audienceEdit) {
const audienceEditCard = audienceEdit.closest(reportSelectors.regions.audienceCard);
event.preventDefault();
editAudienceCard(audienceEditCard);
}
// Delete instance.
const audienceDelete = event.target.closest(reportSelectors.actions.audienceDelete);
if (audienceDelete) {
const audienceDeleteCard = audienceDelete.closest(reportSelectors.regions.audienceCard);
event.preventDefault();
deleteAudienceCard(audienceDeleteCard);
}
});
initialized = true;
};
// 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/>.
/**
* Module to handle audiences AJAX requests
*
* @module core_reportbuilder/local/repository/audiences
* @copyright 2021 David Matamoros <davidmc@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Ajax from 'core/ajax';
/**
* Remove audience from given report
*
* @param {Number} reportId
* @param {Number} instanceId
* @return {Promise}
*/
export const deleteAudience = (reportId, instanceId) => {
const request = {
methodname: 'core_reportbuilder_audiences_delete',
args: {reportid: reportId, instanceid: instanceId}
};
return Ajax.call([request])[0];
};
......@@ -45,6 +45,13 @@ const SELECTORS = {
activeFilters: '[data-region="active-filters"]',
activeFilter: '[data-region="active-filter"]',
settingsSorting: '[data-region="settings-sorting"]',
audiencesContainer: '[data-region="audiences"]',
audienceFormContainer: '[data-region="audience-form-container"]',
audienceCard: '[data-region="audience-card"]',
audienceForm: '[data-region="audience-form"]',
audienceEmptyMessage: '[data-region=no-instances-message]',
audienceDescription: '[data-region=audience-description]',
audienceNotSavedLabel: '[data-region=audience-not-saved]',
},
actions: {
reportActionPopup: '[data-action="report-action-popup"]',
......@@ -61,6 +68,9 @@ const SELECTORS = {
reportToggleColumnSortDirection: '[data-action="report-toggle-sort-direction"]',
sidebarSearch: '[data-action="sidebar-search"]',
toggleEditPreview: '[data-action="toggle-edit-preview"]',
audienceAdd: '[data-action="add-audience"]',
audienceEdit: '[data-action="edit-audience"]',
audienceDelete: '[data-action="delete-audience"]',
},
};
......
......@@ -88,9 +88,11 @@ const expandCard = (card) => {
/**
* Initialise module
*
* @param {string} selectorId
*/
export const init = () => {
const sidebarMenu = document.querySelector(reportSelectors.regions.sidebarMenu);
export const init = (selectorId) => {
const sidebarMenu = document.querySelector(selectorId + reportSelectors.regions.sidebarMenu);
const sidebarSearch = sidebarMenu.querySelector(reportSelectors.actions.sidebarSearch);
// Debounce the event listener to allow the user to finish typing.
......
<?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\audiences;
use context_system;
use core_reportbuilder\local\audiences\base;
use external_api;
use external_function_parameters;
use external_value;
use core_reportbuilder\manager;
use core_reportbuilder\permission;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once("{$CFG->libdir}/externallib.php");
/**
* External method for deleting a report audience
*
* @package core_reportbuilder
* @copyright 2021 David Matamoros <davidmc@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class delete extends external_api {
/**
* Describes the parameters for get_users_courses.
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters(
[
'reportid' => new external_value(PARAM_INT, 'Report id'),
'instanceid' => new external_value(PARAM_INT, 'Audience instance id'),
]
);
}
/**
* External function to delete a report audience instance.
*
* @param int $reportid
* @param int $instanceid
* @return bool
*/
public static function execute(int $reportid, int $instanceid): bool {
[
'reportid' => $reportid,
'instanceid' => $instanceid,
] = self::validate_parameters(self::execute_parameters(), [
'reportid' => $reportid,
'instanceid' => $instanceid,
]);
$report = manager::get_report_from_id($reportid);
self::validate_context(context_system::instance());
permission::require_can_edit_report($report->get_report_persistent());
$baseinstance = base::instance($instanceid);
if ($baseinstance && $baseinstance->user_can_edit()) {
$persistent = $baseinstance->get_persistent();
$persistent->delete();
return true;
}
return false;
}
/**
* Describes the data returned from the external function.
*
* @return external_value
*/
public static function execute_returns(): external_value {
return new external_value(PARAM_BOOL, '', VALUE_REQUIRED);
}
}
......@@ -72,6 +72,11 @@ class custom_report_menu_cards_exporter extends exporter {
'action' => [
'type' => PARAM_TEXT,
],
'disabled' => [
'type' => PARAM_BOOL,
'optional' => true,
'default' => false,
],
],
'optional' => true,
'multiple' => true,
......
<?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\form;
use context;
use context_system;
use core_form\dynamic_form;
use core_reportbuilder\local\audiences\base;
use core_reportbuilder\manager;
use core_reportbuilder\permission;
use core_reportbuilder\report_access_exception;
use moodle_exception;
use moodle_url;
use stdClass;
/**
* Dynamic audience form
*
* @package core_reportbuilder
* @copyright 2021 David Matamoros <davidmc@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class audience extends dynamic_form {
/**
* Audience we work with
*
* @return base
*/
protected function get_audience(): base {
$id = $this->optional_param('id', 0, PARAM_INT);
$record = new stdClass();
if (!$id) {
// New instance, pre-define report id and classname.
$record->reportid = $this->optional_param('reportid', null, PARAM_INT);
$record->classname = $this->optional_param('classname', null, PARAM_RAW_TRIMMED);
}
return base::instance($id, $record);
}
/**
* Form definition.
*/
public function definition() {
$mform = $this->_form;
$mform->addElement('hidden', 'id');
$mform->setType('id', PARAM_INT);
$mform->addElement('hidden', 'reportid');
$mform->setType('reportid', PARAM_INT);
$mform->addElement('hidden', 'classname');
$mform->setType('classname', PARAM_RAW_TRIMMED);
// Embed form defined in audience class.
$audience = $this->get_audience();
$audience->get_config_form($mform);
$this->add_action_buttons();
}
/**
* Form validation.
*
* @param array $data array of ("fieldname"=>value) of submitted data
* @param array $files array of uploaded files "element_name"=>tmp_file_path