MDL-75274 gradereport_grader: Column collapsing

This commit is contained in:
Mathew May 2023-02-02 16:31:23 +08:00
parent e285841a9a
commit 1a1939ac29
34 changed files with 1333 additions and 43 deletions

View File

@ -0,0 +1,106 @@
<?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/>.
namespace core_grades\external;
defined('MOODLE_INTERNAL') || die;
use context_course;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_multiple_structure;
use core_external\external_single_structure;
use core_external\external_value;
use core_external\external_warnings;
use core_external\restricted_context_exception;
use grade_item;
require_once($CFG->libdir . '/gradelib.php');
/**
* External grade get gradeitems API implementation
*
* @package core_grades
* @copyright 2023 Mathew May <mathew.solutions>
* @category external
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class get_gradeitems extends external_api {
/**
* Returns description of method parameters.
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters (
[
'courseid' => new external_value(PARAM_INT, 'Course ID', VALUE_REQUIRED)
]
);
}
/**
* Given a course ID find the grading objects and return their names & IDs.
*
* @param int $courseid
* @return array
* @throws restricted_context_exception
* @throws \invalid_parameter_exception
*/
public static function execute(int $courseid): array {
$params = self::validate_parameters(
self::execute_parameters(),
[
'courseid' => $courseid
]
);
$warnings = [];
$context = context_course::instance($params['courseid']);
parent::validate_context($context);
$allgradeitems = grade_item::fetch_all(['courseid' => $params['courseid']]);
$gradeitems = array_filter($allgradeitems, function($item) {
$item->itemname = $item->get_name();
$item->category = $item->get_parent_category()->get_name();
return $item->gradetype != GRADE_TYPE_NONE && !$item->is_category_item() && !$item->is_course_item();
});
return [
'gradeItems' => $gradeitems,
'warnings' => $warnings,
];
}
/**
* Returns description of what gradeitems fetch should return.
*
* @return external_single_structure
*/
public static function execute_returns(): external_single_structure {
return new external_single_structure([
'gradeItems' => new external_multiple_structure(
new external_single_structure([
'id' => new external_value(PARAM_ALPHANUM, 'An ID for the grade item', VALUE_REQUIRED),
'itemname' => new external_value(PARAM_TEXT, 'The full name of the grade item', VALUE_REQUIRED),
'category' => new external_value(PARAM_TEXT, 'The grade category of the grade item', VALUE_OPTIONAL),
])
),
'warnings' => new external_warnings(),
]);
}
}

View File

@ -1539,7 +1539,7 @@ class grade_structure {
* @param bool $withdescription Show description if defined by this item.
* @param bool $fulltotal If the item is a category total, returns $categoryname."total"
* instead of "Category total" or "Course total"
* @param moodle_url|null $sortlink Link to sort column.
* @param moodle_url|null $sortlink Link to sort column.
*
* @return string header
*/
@ -1563,19 +1563,25 @@ class grade_structure {
if ($sortlink) {
$url = $sortlink;
$header = html_writer::link($url, $header,
['title' => $titleunescaped, 'class' => 'gradeitemheader']);
}
if (!$sortlink) {
$header = html_writer::link($url, $header, [
'title' => $titleunescaped,
'class' => 'gradeitemheader '
]);
} else {
if ($withlink && $url = $this->get_activity_link($element)) {
$a = new stdClass();
$a->name = get_string('modulename', $element['object']->itemmodule);
$a->title = $titleunescaped;
$title = get_string('linktoactivity', 'grades', $a);
$header = html_writer::link($url, $header, ['title' => $title, 'class' => 'gradeitemheader']);
$header = html_writer::link($url, $header, [
'title' => $title,
'class' => 'gradeitemheader ',
]);
} else {
$header = html_writer::span($header, 'gradeitemheader', ['title' => $titleunescaped, 'tabindex' => '0']);
$header = html_writer::span($header, 'gradeitemheader ', [
'title' => $titleunescaped,
'tabindex' => '0'
]);
}
}
@ -2460,7 +2466,7 @@ class grade_structure {
}
}
$class = 'grade_icons';
$class = 'grade_icons data-collapse_gradeicons';
if (isset($element['type']) && ($element['type'] == 'category')) {
$class = 'category_grade_icons';
}
@ -2526,14 +2532,18 @@ class grade_structure {
$element, $gpr, $mode, $context, true);
$context->advancedgradingurl = $this->get_advanced_grading_link($element, $gpr);
}
}
if ($element['type'] == 'item') {
$context->divider1 = true;
}
if (($element['type'] == 'item') ||
(($element['type'] == 'userfield') && ($element['name'] !== 'fullname'))) {
$context->divider2 = true;
}
if (!empty($USER->editing) || $mode == 'setup') {
if (($element['type'] !== 'userfield') && ($mode !== 'setup')) {
if (($element['type'] == 'userfield') && ($element['name'] !== 'fullname')) {
$context->divider2 = true;
} else if (($mode !== 'setup') && ($element['type'] !== 'userfield')) {
$context->divider1 = true;
$context->divider2 = true;
}
@ -2577,6 +2587,10 @@ class grade_structure {
$context->descendingurl = $this->get_sorting_link($sortlink, $gpr, 'desc');
}
}
if ($mode !== 'setup') {
$context = grade_report::get_additional_context($this->context, $this->courseid,
$element, $gpr, $mode, $context);
}
} else if ($element['type'] == 'category') {
$context->datatype = 'category';
if ($mode !== 'setup') {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,10 @@
define("gradereport_grader/collapse/repository",["exports","core/ajax"],(function(_exports,_ajax){var obj;
/**
* A repo for the collapsing in the grader report.
*
* @module gradereport_grader/collapse/repository
* @copyright 2022 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.gradeItems=void 0,_ajax=(obj=_ajax)&&obj.__esModule?obj:{default:obj};_exports.gradeItems=courseid=>{const request={methodname:"core_grades_get_gradeitems",args:{courseid:courseid}};return _ajax.default.call([request])[0]}}));
//# sourceMappingURL=repository.min.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"repository.min.js","sources":["../../src/collapse/repository.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * A repo for the collapsing in the grader report.\n *\n * @module gradereport_grader/collapse/repository\n * @copyright 2022 Mathew May <mathew.solutions>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport ajax from 'core/ajax';\n\n/**\n * Fetch all the information on gradeitems we'll need in the column collapser.\n *\n * @method gradeItems\n * @param {Number} courseid What course to fetch the gradeitems for\n * @return {object} jQuery promise\n */\nexport const gradeItems = (courseid) => {\n const request = {\n methodname: 'core_grades_get_gradeitems',\n args: {\n courseid: courseid,\n },\n };\n return ajax.call([request])[0];\n};\n"],"names":["courseid","request","methodname","args","ajax","call"],"mappings":";;;;;;;gKAgC2BA,iBACjBC,QAAU,CACZC,WAAY,6BACZC,KAAM,CACFH,SAAUA,kBAGXI,cAAKC,KAAK,CAACJ,UAAU"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,524 @@
// 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/>.
/**
* Allow the user to show and hide columns of the report at will.
*
* @module gradereport_grader/collapse
* @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import * as Repository from 'gradereport_grader/collapse/repository';
import GradebookSearchClass from 'gradereport_grader/search/search_class';
import {renderForPromise, replaceNodeContents, replaceNode} from 'core/templates';
import {debounce} from 'core/utils';
import $ from 'jquery';
import {get_strings as getStrings} from 'core/str';
import CustomEvents from "core/custom_interaction_events";
import storage from 'core/localstorage';
import {addIconToContainer} from 'core/loadingicon';
import Notification from 'core/notification';
import Pending from 'core/pending';
// Contain our selectors within this file until they could be of use elsewhere.
const selectors = {
component: '.collapse-columns',
formDropdown: '.columnsdropdownform',
formItems: {
cancel: 'cancel',
save: 'save',
checked: 'input[type="checkbox"]:checked'
},
hider: 'hide',
expand: 'expand',
colVal: '[data-col]',
itemVal: '[data-itemid]',
content: '[data-collapse="content"]',
sort: '[data-collapse="sort"]',
expandbutton: '[data-collapse="expandbutton"]',
avgrowcell: '[data-collapse="avgrowcell"]',
menu: '[data-collapse="menu"]',
icons: '.data-collapse_gradeicons',
count: '[data-collapse="count"]',
placeholder: '.collapsecolumndropdown [data-region="placeholder"]',
fullDropdown: '.collapsecolumndropdown',
};
const countIndicator = document.querySelector(selectors.count);
export default class ColumnSearch extends GradebookSearchClass {
userID = -1;
courseID = null;
defaultSort = '';
nodes = [];
gradeStrings = null;
userStrings = null;
stringMap = [];
static init(userID, courseID, defaultSort) {
return new ColumnSearch(userID, courseID, defaultSort);
}
constructor(userID, courseID, defaultSort) {
super();
this.userID = userID;
this.courseID = courseID;
this.defaultSort = defaultSort;
this.component = document.querySelector(selectors.component);
const pendingPromise = new Pending();
// Display a loader whilst collapsing appropriate columns (based on the locally stored state for the current user).
addIconToContainer(document.querySelector('.gradeparent')).then((loader) => {
setTimeout(() => {
// Get the users' checked columns to change.
this.getDataset().forEach((item) => {
this.nodesUpdate(item);
});
this.renderDefault();
// Once the grade categories have been re-collapsed, remove the loader and display the Gradebook setup content.
loader.remove();
document.querySelector('.gradereport-grader-table').classList.remove('d-none');
}, 10);
}).then(() => pendingPromise.resolve()).catch(Notification.exception);
}
/**
* The overall div that contains the searching widget.
*
* @returns {string}
*/
setComponentSelector() {
return '.collapse-columns';
}
/**
* The dropdown div that contains the searching widget result space.
*
* @returns {string}
*/
setDropdownSelector() {
return '.searchresultitemscontainer';
}
/**
* The triggering div that contains the searching widget.
*
* @returns {string}
*/
setTriggerSelector() {
return '.collapsecolumn';
}
/**
* Return the dataset that we will be searching upon.
*
* @returns {Array}
*/
getDataset() {
if (!this.dataset) {
const cols = this.fetchDataset();
this.dataset = JSON.parse(cols) ? JSON.parse(cols).split(',') : [];
}
this.datasetSize = this.dataset.length;
return this.dataset;
}
/**
* Get the data we will be searching against in this component.
*
* @returns {string}
*/
fetchDataset() {
return storage.get(`gradereport_grader_collapseditems_${this.courseID}_${this.userID}`);
}
/**
* Given a user performs an action, update the users' preferences.
*/
setPreferences() {
storage.set(`gradereport_grader_collapseditems_${this.courseID}_${this.userID}`,
JSON.stringify(this.getDataset().join(','))
);
}
/**
* Register clickable event listeners.
*/
registerClickHandlers() {
// Register click events within the component.
this.component.addEventListener('click', this.clickHandler.bind(this));
document.addEventListener('click', this.docClickHandler.bind(this));
}
/**
* The handler for when a user interacts with the component.
*
* @param {MouseEvent} e The triggering event that we are working with.
*/
clickHandler(e) {
super.clickHandler(e);
// Prevent BS from closing the dropdown if they click elsewhere within the dropdown besides the form.
if (e.target.closest(selectors.fullDropdown)) {
e.stopPropagation();
}
}
/**
* Externally defined click function to improve memory handling.
*
* @param {MouseEvent} e
* @returns {Promise<void>}
*/
async docClickHandler(e) {
if (e.target.dataset.hider === selectors.hider) {
e.preventDefault();
const desiredToHide = e.target.closest(selectors.colVal) ?
e.target.closest(selectors.colVal)?.dataset.col :
e.target.closest(selectors.itemVal)?.dataset.itemid;
const idx = this.getDataset().indexOf(desiredToHide);
if (idx === -1) {
this.getDataset().push(desiredToHide);
}
await this.prefcountpippe();
this.nodesUpdate(desiredToHide);
}
if (e.target.closest('button')?.dataset.hider === selectors.expand) {
e.preventDefault();
const desiredToHide = e.target.closest(selectors.colVal) ?
e.target.closest(selectors.colVal)?.dataset.col :
e.target.closest(selectors.itemVal)?.dataset.itemid;
const idx = this.getDataset().indexOf(desiredToHide);
this.getDataset().splice(idx, 1);
await this.prefcountpippe();
this.nodesUpdate(e.target.closest(selectors.colVal)?.dataset.col);
this.nodesUpdate(e.target.closest(selectors.colVal)?.dataset.itemid);
}
}
/**
* Handle any keyboard inputs.
*/
registerInputEvents() {
// Register & handle the text input.
this.searchInput.addEventListener('input', debounce(async() => {
this.setSearchTerms(this.searchInput.value);
// We can also require a set amount of input before search.
if (this.searchInput.value === '') {
// Hide the "clear" search button in the search bar.
this.clearSearchButton.classList.add('d-none');
} else {
// Display the "clear" search button in the search bar.
this.clearSearchButton.classList.remove('d-none');
}
// User has given something for us to filter against.
await this.filterrenderpipe();
}, 300));
}
/**
* Handle the form submission within the dropdown.
*/
registerFormEvents() {
const form = this.component.querySelector(selectors.formDropdown);
const events = [
'click',
CustomEvents.events.activate,
CustomEvents.events.keyboardActivate
];
CustomEvents.define(document, events);
// Register clicks & keyboard form handling.
events.forEach((event) => {
form.addEventListener(event, (e) => {
// Stop Bootstrap from being clever.
e.stopPropagation();
const submitBtn = form.querySelector(`[data-action="${selectors.formItems.save}"`);
if (e.target.closest('input')) {
const checkedCount = Array.from(form.querySelectorAll(selectors.formItems.checked)).length;
// Check if any are clicked or not then change disabled.
submitBtn.disabled = checkedCount <= 0;
}
}, false);
// Stop Bootstrap from being clever.
this.searchInput.addEventListener(event, e => e.stopPropagation());
this.clearSearchButton.addEventListener(event, async(e) => {
e.stopPropagation();
this.searchInput.value = '';
this.setSearchTerms(this.searchInput.value);
await this.filterrenderpipe();
});
});
form.addEventListener('submit', async(e) => {
e.preventDefault();
if (e.submitter.dataset.action === selectors.formItems.cancel) {
$(this.component).dropdown('toggle');
return;
}
// Get the users' checked columns to change.
const checkedItems = [...form.elements].filter(item => item.checked);
checkedItems.forEach((item) => {
const idx = this.getDataset().indexOf(item.dataset.collapse);
this.getDataset().splice(idx, 1);
this.nodesUpdate(item.dataset.collapse);
});
await this.prefcountpippe();
});
}
nodesUpdate(item) {
const colNodesToHide = [...document.querySelectorAll(`[data-col="${item}"]`)];
const itemIDNodesToHide = [...document.querySelectorAll(`[data-itemid="${item}"]`)];
this.nodes = [...colNodesToHide, ...itemIDNodesToHide];
this.updateDisplay();
}
/**
* Update the user preferences, count display then render the results.
*
* @returns {Promise<void>}
*/
async prefcountpippe() {
this.setPreferences();
this.countUpdate();
await this.filterrenderpipe();
}
/**
* Dictate to the search component how and what we want to match upon.
*
* @param {Array} filterableData
* @returns {Array} An array of objects containing the system reference and the user readable value.
*/
async filterDataset(filterableData) {
const stringUserMap = await this.fetchRequiredUserStrings();
const stringGradeMap = await this.fetchRequiredGradeStrings();
// Custom user profile fields are not in our string map and need a bit of extra love.
const customFieldMap = this.fetchCustomFieldValues();
this.stringMap = new Map([...stringGradeMap, ...stringUserMap, ...customFieldMap]);
const searching = filterableData.map(s => {
const mapObj = this.stringMap.get(s);
if (mapObj === undefined) {
return {key: s, string: s};
}
return {
key: s,
string: mapObj.itemname ?? this.stringMap.get(s),
category: mapObj.category ?? '',
};
});
// Sometimes we just want to show everything.
if (this.getPreppedSearchTerm() === '') {
return searching;
}
// Other times we want to actually filter the content.
return searching.filter((col) => {
return col.string.toString().toLowerCase().includes(this.getPreppedSearchTerm());
});
}
/**
* Given we have a subset of the dataset, set the field that we matched upon to inform the end user.
*/
filterMatchDataset() {
this.setMatchedResults(
this.getMatchedResults().map((column) => {
return {
name: column.key,
displayName: column.string ?? column.key,
category: column.category ?? '',
};
})
);
}
/**
* Update any changeable nodes, filter and then render the result.
*
* @returns {Promise<void>}
*/
async filterrenderpipe() {
this.updateNodes();
this.setMatchedResults(await this.filterDataset(this.getDataset()));
this.filterMatchDataset();
await this.renderDropdown();
}
/**
* With an array of nodes, switch their classes and values.
*/
updateDisplay() {
this.nodes.forEach((element) => {
const content = element.querySelector(selectors.content);
const sort = element.querySelector(selectors.sort);
const expandButton = element.querySelector(selectors.expandbutton);
const avgRowCell = element.querySelector(selectors.avgrowcell);
const nodeSet = [
element.querySelector(selectors.menu),
element.querySelector(selectors.icons),
content
];
// This can be further improved to reduce redundant similar calls.
if (element.classList.contains('cell')) {
// The column is actively being sorted, lets reset that and reload the page.
if (sort !== null) {
window.location = this.defaultSort;
}
if (content === null) {
if (avgRowCell.classList.contains('d-none')) {
avgRowCell?.classList.remove('d-none');
avgRowCell?.setAttribute('aria-hidden', 'false');
} else {
avgRowCell?.classList.add('d-none');
avgRowCell?.setAttribute('aria-hidden', 'true');
}
} else if (content.classList.contains('d-none')) {
// We should always have content but some cells do not contain menus or other actions.
element.classList.remove('collapsed');
content.classList.add('d-flex');
nodeSet.forEach(node => {
node?.classList.remove('d-none');
node?.setAttribute('aria-hidden', 'false');
});
expandButton?.classList.add('d-none');
expandButton?.setAttribute('aria-hidden', 'true');
} else {
element.classList.add('collapsed');
content.classList.remove('d-flex');
nodeSet.forEach(node => {
node?.classList.add('d-none');
node?.setAttribute('aria-hidden', 'true');
});
expandButton?.classList.remove('d-none');
expandButton?.setAttribute('aria-hidden', 'false');
}
}
});
}
/**
* Update the visual count of collapsed columns or hide the count all together.
*/
countUpdate() {
countIndicator.textContent = this.getDatasetSize();
if (this.getDatasetSize() > 0) {
this.component.parentElement.classList.add('d-flex');
this.component.parentElement.classList.remove('d-none');
} else {
this.component.parentElement.classList.remove('d-flex');
this.component.parentElement.classList.add('d-none');
}
}
/**
* Build the content then replace the node by default we want our form to exist.
*/
async renderDefault() {
this.setMatchedResults(await this.filterDataset(this.getDataset()));
this.filterMatchDataset();
// Update the collapsed button pill.
this.countUpdate();
const {html, js} = await renderForPromise('gradereport_grader/collapse/collapsebody', {
'results': this.getMatchedResults(),
'userid': this.userID,
});
replaceNode(selectors.placeholder, html, js);
this.updateNodes();
// Given we now have the body, we can set up more triggers.
this.registerFormEvents();
this.registerInputEvents();
}
/**
* Build the content then replace the node.
*/
async renderDropdown() {
const {html, js} = await renderForPromise('gradereport_grader/collapse/collapseresults', {
'results': this.getMatchedResults(),
'searchTerm': this.getSearchTerm(),
});
replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);
}
/**
* If we have any custom user profile fields, grab their system & readable names to add to our string map.
*
* @returns {[string,*][]} An array of associated string arrays ready for our map.
*/
fetchCustomFieldValues() {
const customFields = document.querySelectorAll('[data-collapse-name]');
// Cast from NodeList to array to grab all the values.
return [...customFields].map(field => [field.parentElement.dataset.col, field.dataset.collapseName]);
}
/**
* Given the set of profile fields we can possibly search, fetch their strings,
* so we can report to screen readers the field that matched.
*
* @returns {Promise<void>}
*/
fetchRequiredUserStrings() {
if (!this.userStrings) {
const requiredStrings = [
'username',
'firstname',
'lastname',
'email',
'city',
'country',
'department',
'institution',
'idnumber',
'phone1',
'phone2',
];
this.userStrings = getStrings(requiredStrings.map((key) => ({key})))
.then((stringArray) => new Map(
requiredStrings.map((key, index) => ([key, stringArray[index]]))
));
}
return this.userStrings;
}
/**
* Given the set of gradable items we can possibly search, fetch their strings,
* so we can report to screen readers the field that matched.
*
* @returns {Promise<void>}
*/
fetchRequiredGradeStrings() {
if (!this.gradeStrings) {
this.gradeStrings = Repository.gradeItems(this.courseID)
.then((result) => new Map(
result.gradeItems.map(key => ([key.id, key]))
));
}
return this.gradeStrings;
}
}

View File

@ -0,0 +1,41 @@
// 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/>.
/**
* A repo for the collapsing in the grader report.
*
* @module gradereport_grader/collapse/repository
* @copyright 2022 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import ajax from 'core/ajax';
/**
* Fetch all the information on gradeitems we'll need in the column collapser.
*
* @method gradeItems
* @param {Number} courseid What course to fetch the gradeitems for
* @return {object} jQuery promise
*/
export const gradeItems = (courseid) => {
const request = {
methodname: 'core_grades_get_gradeitems',
args: {
courseid: courseid,
},
};
return ajax.call([request])[0];
};

View File

@ -76,11 +76,15 @@ export default class {
$component = $(this.component);
constructor() {
this.setSearchTerms(this.searchInput.value ?? '');
// If we have a search input, try to get the value otherwise fallback.
this.setSearchTerms(this.searchInput?.value ?? '');
// Begin handling the base search component.
this.registerClickHandlers();
this.registerKeyHandlers();
this.registerInputHandlers();
// Conditionally set up the input handler since we don't know exactly how we were called.
if (this.searchInput !== null) {
this.registerInputHandlers();
}
}
/**
@ -252,6 +256,9 @@ export default class {
this.resultNodes = [...this.component.querySelectorAll(this.selectors.resultitems)];
this.currentNode = this.resultNodes.find(r => r.id === document.activeElement.id);
this.currentViewAll = this.component.querySelector(this.selectors.viewall);
this.clearSearchButton = this.component.querySelector(this.selectors.clearSearch);
this.searchInput = this.component.querySelector(this.selectors.input);
this.searchDropdown = this.component.querySelector(this.selectors.dropdown);
}
/**

View File

@ -107,6 +107,21 @@ class action_bar extends \core_grades\output\action_bar {
false,
);
$data['searchdropdown'] = $searchdropdown->export_for_template($output);
$collapse = new gradebook_dropdown(
true,
get_string('collapsedcolumns', 'gradereport_grader', 0),
null,
'collapse-columns',
'collapsecolumn',
'collapsecolumndropdown p-3 flex-column',
null,
true,
);
$data['collapsedcolumns'] = [
'classes' => 'd-none',
'content' => $collapse->export_for_template($output)
];
}
return $data;

View File

@ -127,6 +127,14 @@ if ($sort) {
$sort = strtoupper($sort);
}
$report = new grade_report_grader($courseid, $gpr, $context, $page, $sortitemid, $sort);
// We call this a little later since we need some info from the grader report.
$PAGE->requires->js_call_amd('gradereport_grader/collapse', 'init', [
'userID' => $USER->id,
'courseID' => $courseid,
'defaultSort' => $report->get_default_sortable()
]);
$numusers = $report->get_numusers(true, true);
$actionbar = new \gradereport_grader\output\action_bar($context, $report, $numusers);

View File

@ -23,6 +23,7 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
$string['collapsedcolumns'] = 'Collapsed columns <span class="badge badge-pill badge-primary ml-1" data-collapse="count">{$a}</span>';
$string['eventgradereportviewed'] = 'Grader report viewed';
$string['grader:manage'] = 'Manage the grader report';
$string['grader:view'] = 'View grader report';
@ -43,6 +44,7 @@ $string['privacy:metadata:preference:grade_report_showranges'] = 'Whether to dis
$string['privacy:metadata:preference:grade_report_showuserimage'] = 'Whether to show the user\'s profile image next to the name';
$string['privacy:metadata:preference:grade_report_studentsperpage'] = 'The number of students displayed per page in the grader report';
$string['privacy:request:preference:grade_report_grader_collapsed_categories'] = 'You have some gradebook categories collapsed in the "{$a->name}" course';
$string['reopencolumn'] = 'Reopen {$a} column';
$string['summarygrader'] = 'A table with the names of students in the first column, with assessable activities grouped by course and category across the top.';
$string['showingxofy'] = 'Showing {$a->found} of {$a->total}';
$string['useractivitygrade'] = '{$a} grade';

View File

@ -666,11 +666,19 @@ class grade_report_grader extends grade_report {
foreach ($extrafields as $field) {
$fieldheader = new html_table_cell();
$fieldheader->attributes['class'] = 'userfield user' . $field;
$fieldheader->attributes['data-col'] = $field;
$fieldheader->scope = 'col';
$fieldheader->header = true;
$collapsecontext = ['field' => $field, 'name' => $field];
$collapsedicon = $OUTPUT->render_from_template('gradereport_grader/collapse/icon', $collapsecontext);
// Need to wrap the button into a div with our hooking element for user items, gradeitems already have this.
$collapsedicon = html_writer::div($collapsedicon, 'd-none', ['data-collapse' => 'expandbutton']);
$element = ['type' => 'userfield', 'name' => $field];
$fieldheader->text = $arrows[$field] .
$this->gtree->get_cell_action_menu($element, 'gradeitem', $this->gpr, $this->baseurl);
$this->gtree->get_cell_action_menu($element, 'gradeitem', $this->gpr, $this->baseurl) . $collapsedicon;
$headerrow->cells[] = $fieldheader;
}
@ -723,8 +731,12 @@ class grade_report_grader extends grade_report {
foreach ($extrafields as $field) {
$fieldcell = new html_table_cell();
$fieldcell->attributes['class'] = 'userfield user' . $field;
$fieldcell->attributes['data-col'] = $field;
$fieldcell->header = false;
$fieldcell->text = s($user->{$field});
$fieldcell->text = html_writer::tag('div', s($user->{$field}), [
'data-collapse' => 'content'
]);
$userrow->cells[] = $fieldcell;
}
@ -836,6 +848,15 @@ class grade_report_grader extends grade_report {
}
}
$collapsecontext = [
'field' => $element['object']->id,
'name' => $element['object']->get_name(),
];
$collapsedicon = '';
// We do not want grade category total items to be hidden away as it is controlled by something else.
if (!$element['object']->is_aggregate_item()) {
$collapsedicon = $OUTPUT->render_from_template('gradereport_grader/collapse/icon', $collapsecontext);
}
$headerlink = $this->gtree->get_element_header($element, true,
true, false, false, true, $sortlink);
@ -871,6 +892,7 @@ class grade_report_grader extends grade_report {
$context->arrow = $arrow;
$context->singleview = $singleview;
$context->statusicons = $statusicons;
$context->collapsedicon = $collapsedicon;
$itemcell->text = $OUTPUT->render_from_template('gradereport_grader/headercell', $context);
@ -1166,7 +1188,7 @@ class grade_report_grader extends grade_report {
$html = '';
$fulltable = new html_table();
$fulltable->attributes['class'] = 'gradereport-grader-table';
$fulltable->attributes['class'] = 'gradereport-grader-table d-none';
$fulltable->id = 'user-grades';
$fulltable->caption = get_string('summarygrader', 'gradereport_grader');
$fulltable->captionhide = true;
@ -1370,7 +1392,6 @@ class grade_report_grader extends grade_report {
*/
public function get_right_avg_row($rows=array(), $grouponly=false) {
global $USER, $DB, $OUTPUT, $CFG;
if (!$this->canviewhidden) {
// Totals might be affected by hiding, if user can not see hidden grades the aggregations might be altered
// better not show them at all if user can not see all hidden grades.
@ -1518,9 +1539,9 @@ class grade_report_grader extends grade_report {
if (!isset($sumarray[$item->id]) || $meancount == 0) {
$avgcell = new html_table_cell();
$avgcell->attributes['class'] = $gradetypeclass . ' i'. $itemid;
$avgcell->text = '-';
$avgcell->attributes['data-itemid'] = $itemid;
$avgcell->text = html_writer::div('-', '', ['data-collapse' => 'avgrowcell']);
$avgrow->cells[] = $avgcell;
} else {
$sum = $sumarray[$item->id];
$avgradeval = $sum/$meancount;
@ -1533,7 +1554,8 @@ class grade_report_grader extends grade_report {
$avgcell = new html_table_cell();
$avgcell->attributes['class'] = $gradetypeclass . ' i'. $itemid;
$avgcell->text = $gradehtml.$numberofgrades;
$avgcell->attributes['data-itemid'] = $itemid;
$avgcell->text = html_writer::div($gradehtml.$numberofgrades, '', ['data-collapse' => 'avgrowcell']);
$avgrow->cells[] = $avgcell;
}
}
@ -1839,7 +1861,7 @@ class grade_report_grader extends grade_report {
* user idnumber
* @return array An associative array of HTML sorting links+arrows
*/
public function get_sort_arrows(array $extrafields = array()) {
public function get_sort_arrows(array $extrafields = []) {
global $CFG;
$arrows = array();
$sortlink = clone($this->baseurl);
@ -1879,8 +1901,15 @@ class grade_report_grader extends grade_report {
}
foreach ($extrafields as $field) {
$fieldlink = html_writer::link(new moodle_url($this->baseurl,
array('sortitemid' => $field)), \core_user\fields::get_display_name($field));
$attributes = [
'data-collapse' => 'content'
];
// With additional user profile fields, we can't grab the name via WS, so conditionally add it to rip out of the DOM.
if (preg_match(\core_user\fields::PROFILE_FIELD_REGEX, $field)) {
$attributes['data-collapse-name'] = \core_user\fields::get_display_name($field);
}
$fieldlink = html_writer::link(new moodle_url($this->baseurl, ['sortitemid' => $field]),
\core_user\fields::get_display_name($field), $attributes);
$arrows[$field] = $fieldlink;
if ($field == $this->sortitemid) {
@ -1925,6 +1954,38 @@ class grade_report_grader extends grade_report {
return html_writer::link($urlnew, $title,
['class' => 'dropdown-item', 'aria-label' => $title, 'aria-current' => $active, 'role' => 'menuitem']);
}
/**
* Return the link to allow the field to collapse from the users view.
*
* @return string Dropdown menu link that'll trigger the collapsing functionality.
* @throws coding_exception
* @throws moodle_exception
*/
public function get_hide_show_link(): string {
$link = new moodle_url('#', []);
return html_writer::link(
$link->out(false),
get_string('collapse'),
['class' => 'dropdown-item', 'data-hider' => 'hide', 'aria-label' => get_string('collapse'), 'role' => 'menuitem'],
);
}
/**
* Return the base report link with some default sorting applied.
*
* @return string
* @throws moodle_exception
*/
public function get_default_sortable(): string {
$sortlink = new moodle_url('/grade/report/grader/index.php', [
'id' => $this->courseid,
'sortitemid' => 'firstname',
'sort' => 'asc'
]);
$this->gpr->add_url_params($sortlink);
return $sortlink->out(false);
}
}
/**
@ -1941,12 +2002,12 @@ class grade_report_grader extends grade_report {
function gradereport_grader_get_report_link(context_course $context, int $courseid,
array $element, grade_plugin_return $gpr, string $mode, ?stdClass $templatecontext): ?stdClass {
if ($mode == 'category') {
static $report = null;
if (!$report) {
$report = new grade_report_grader($courseid, $gpr, $context);
}
static $report = null;
if (!$report) {
$report = new grade_report_grader($courseid, $gpr, $context);
}
if ($mode == 'category') {
if (!isset($templatecontext)) {
$templatecontext = new stdClass();
}
@ -1977,6 +2038,17 @@ function gradereport_grader_get_report_link(context_course $context, int $course
$templatecontext->fullmodeurl =
$report->get_category_view_mode_link($url, $strswitchwhole, 'switch_whole', $fullmode);
return $templatecontext;
} else if ($mode == 'gradeitem') {
if (($element['type'] == 'userfield') && ($element['name'] !== 'fullname')) {
$templatecontext->columncollapse = $report->get_hide_show_link();
$templatecontext->dataid = $element['name'];
}
// We do not want grade category total items to be hidden away as it is controlled by something else.
if (isset($element['object']->id) && !$element['object']->is_aggregate_item()) {
$templatecontext->columncollapse = $report->get_hide_show_link();
}
return $templatecontext;
}
return null;
}

View File

@ -110,6 +110,10 @@
min-width: 200px;
}
.path-grade-report-grader .gradeparent .highlightable.cell.collapsed {
min-width: unset;
}
.path-grade-report-grader .gradeparent .user.cell .userpicture {
border: none;
vertical-align: middle;
@ -249,3 +253,8 @@
border-top: 1px solid #dee2e6;
font-size: 90%;
}
.collapsecolumndropdown.show {
width: 275px;
max-height: 300px;
}

View File

@ -60,7 +60,8 @@
}
]
},
"groupselector": "<div class='group-selector'></div>"
"groupselector": "<div class='group-selector'></div>",
"collapsedcolumns": "<div class='collapse-columns'></div>"
}
}}
<div class="container-fluid tertiary-navigation full-width-bottom-border">
@ -89,5 +90,12 @@
</div>
<div class="navitem-divider"></div>
{{/initialselector}}
{{#collapsedcolumns}}
<div class="navitem flex-column align-self-center ml-auto {{#classes}}{{.}}{{/classes}}" aria-live="polite">
{{#content}}
{{>core_grades/tertiary_navigation_dropdown}}
{{/content}}
</div>
{{/collapsedcolumns}}
</div>
</div>

View File

@ -33,7 +33,7 @@
"name": "grade[313][624]"
}
}}
<div class="d-flex flex-column h-100">
<div class="d-flex flex-column h-100" data-collapse="content">
<div class="d-flex">
<div class="d-flex flex-grow-1">
{{#iseditable}}

View File

@ -0,0 +1,53 @@
{{!
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/>.
}}
{{!
@template gradereport_grader/collapse/collapsebody
The body of the column collapsing dropdown that contains the form and subsequent results from the search.
Example context (json):
{
"results": [
{
"name": "42",
"displayName": "The meaning of life",
"category": "Hitchhikers grade category"
}
],
"searchTerm": "Meaning of",
"userid": "42"
}
}}
<div class="flex-column h-100 w-100">
<span class="d-none" data-region="userid" data-userid="{{userid}}" aria-hidden="true"></span>
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchcollapsedcolumns, core_grades
{{/str}}{{/label}}
{{$placeholder}}{{#str}}
searchcollapsedcolumns, core_grades
{{/str}}{{/placeholder}}
{{/ core/search_input_auto }}
<form class="columnsdropdownform flex-column h-100">
<ul class="searchresultitemscontainer overflow-auto py-2 px-1 list-group mx-0 text-truncate" role="menu" data-region="search-result-items-container" tabindex="-1">
{{>gradereport_grader/collapse/collapseresults}}
</ul>
<div class="d-flex flex-row justify-content-end mt-2">
<input class="btn btn-outline-secondary pull-right mx-2" data-action="cancel" type="submit" value="{{#str}}closebuttontitle{{/str}}">
<input disabled class="btn btn-primary pull-right" data-action="save" type="submit" value="{{#str}}expand{{/str}}">
</div>
</form>
</div>

View File

@ -0,0 +1,41 @@
{{!
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/>.
}}
{{!
@template gradereport_grader/collapse/collapseresultitems
Context variables required for this template:
* name - The 'system' name that we search against.
* displayName - The 'end user' name that the user can search against.
* category - The category that a gradable item is within.
Example context (json):
{
"name": "42",
"displayName": "The meaning of life",
"category": "Hitchhikers grade category"
}
}}
<li class="w-100 result-row form-check mb-1" role="none">
<input class="form-check-input" data-collapse="{{name}}" type="checkbox" value="" id="check-{{name}}">
<label class="selected-option-info d-block pr-3 text-truncate form-check-label" for="check-{{name}}" role="menuitem" tabindex="-1" aria-label="{{#str}}viewresultsuser, gradereport_grader, {{displayName}}{{/str}}">
<span class="selected-option-text w-100 p-0">
{{displayName}}
</span>
{{#category}}
<span class="d-block w-100 pull-left text-muted text-truncate small">
{{category}}
</span>
{{/category}}
</label>
</li>

View File

@ -0,0 +1,38 @@
{{!
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/>.
}}
{{!
@template gradereport_grader/collapse/collapseresults
Context variables required for this template:
* results - An array of found columns that are currently hidden.
* searchTerm - What the user is currently searching for.
Example context (json):
{
"results": [
{
"name": "42",
"displayName": "The meaning of life",
"category": "Hitchhikers grade category"
}
],
"searchTerm": "Meaning of"
}
}}
{{#results}}
{{>gradereport_grader/collapse/collapseresultitems}}
{{/results}}
{{^results}}
<span class="d-block my-4">{{#str}} noresultsfor, core_grades, {{searchTerm}}{{/str}}</span>
{{/results}}

View File

@ -0,0 +1,29 @@
{{!
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/>.
}}
{{!
@template gradereport_grader/collapse/icon
Context variables required for this template:
* field - Either the shortname of the user field or a grade item ID.
Example context (json):
{
"field": "42",
"name": "Meaning of life"
}
}}
<button class="btn btn-link btn-icon icon-size-3" data-hider="expand" data-col="{{field}}">
<i class="icon fa fa-plus m-0" title="Reopen column" aria-hidden="true"></i>
<span class="sr-only">{{#str}}reopencolumn, gradereport_grader, {{name}}{{/str}}</span>
</button>

View File

@ -28,7 +28,10 @@
}
}}
<div class="d-flex flex-column h-100">
<div class="d-flex">
<div class="d-none flex-grow-1 align-items-start" data-collapse="expandbutton">
{{{collapsedicon}}}
</div>
<div class="d-flex" data-collapse="content">
<div class="d-flex flex-grow-1">
{{{headerlink}}}
{{{arrow}}}

View File

@ -45,10 +45,10 @@
{{/profileimageurl}}
</div>
<div class="d-block pr-3 w-75">
<span class="w-100 p-0 font-weight-bold">
<span class="d-flex w-100 p-0 font-weight-bold">
{{fullname}}
</span>
<span class="w-100 pull-left text-truncate small" aria-hidden="true">
<span class="d-flex w-100 pull-left text-truncate small" aria-hidden="true">
{{{matchingField}}}
</span>
<span class="sr-only" aria-label="{{#str}}usermatchedon, core_grades{{/str}}">{{matchingFieldName}}</span>

View File

@ -127,8 +127,21 @@ class behat_gradereport_grader extends behat_base {
*/
public function i_click_on_user_profile_field_menu(string $field) {
$xpath = "//table[@id='user-grades']//*[@data-type='" . $field . "']";
$xpath = "//table[@id='user-grades']//*[@data-type='" . mb_strtolower($field) . "']";
$this->execute("behat_general::i_click_on", array($this->escape($xpath), "xpath_element"));
}
/**
* Return the list of partial named selectors.
*
* @return array
*/
public static function get_partial_named_selectors(): array {
return [
new behat_component_named_selector(
'collapse search',
[".//*[contains(concat(' ', @class, ' '), ' collapsecolumndropdown ')]"]
),
];
}
}

View File

@ -0,0 +1,217 @@
@core @javascript @gradereport @gradereport_grader
Feature: Within the grader report, test that we can collapse columns
In order to reduce usage of visual real estate
As a teacher
I need to be able to change how the report is displayed
Background:
Given the following "courses" exist:
| fullname | shortname | category | groupmode |
| Course 1 | C1 | 0 | 1 |
And the following "grade categories" exist:
| fullname | course |
| Some cool grade category | C1 |
And the following "custom profile fields" exist:
| datatype | shortname | name |
| text | enduro | Favourite enduro race |
And the following "users" exist:
| username | firstname | lastname | email | idnumber | phone1 | phone2 | department | institution | city | country |
| teacher1 | Teacher | 1 | teacher1@example.com | t1 | 1234567892 | 1234567893 | ABC1 | ABCD | Perth | AU |
| student1 | Student | 1 | student1@example.com | s1 | 3213078612 | 8974325612 | ABC1 | ABCD | Hanoi | VN |
| student2 | Dummy | User | student2@example.com | s2 | 4365899871 | 7654789012 | ABC2 | ABCD | Tokyo | JP |
| student3 | User | Example | student3@example.com | s3 | 3243249087 | 0875421745 | ABC2 | ABCD | Olney | GB |
| student4 | User | Test | student4@example.com | s4 | 0987532523 | 2149871323 | ABC3 | ABCD | Tokyo | JP |
| student5 | Turtle | Manatee | student5@example.com | s5 | 1239087780 | 9873623589 | ABC3 | ABCD | Perth | AU |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
| student2 | C1 | student |
| student3 | C1 | student |
| student4 | C1 | student |
| student5 | C1 | student |
And the following "activities" exist:
| activity | course | idnumber | name | gradecategory |
| assign | C1 | a1 | Test assignment one | Some cool grade category |
| assign | C1 | a3 | Test assignment three | Some cool grade category |
And the following "activities" exist:
| activity | course | idnumber | name |
| assign | C1 | a2 | Test assignment two |
| assign | C1 | a4 | Test assignment four |
And the following config values are set as admin:
| showuseridentity | idnumber,email,city,country,phone1,phone2,department,institution,profile_field_enduro |
And I am on the "Course 1" "Course" page logged in as "teacher1"
And I change window size to "large"
And I navigate to "View > Grader report" in the course gradebook
Scenario: An admin collapses a user info column and then reloads the page to find the column still collapsed
Given I should see "Email" in the "First name / Last name" "table_row"
And I click on user profile field menu "Email"
And I choose "Collapse" in the open action menu
And I should not see "Email" in the "First name / Last name" "table_row"
And I click on user profile field menu "profile_field_enduro"
And I choose "Collapse" in the open action menu
And I should not see "Favourite enduro race" in the "First name / Last name" "table_row"
When I reload the page
Then I should not see "Email" in the "First name / Last name" "table_row"
# Check that the collapsed column is only for the user that set it.
And I log out
And I am on the "Course 1" "Course" page logged in as "admin"
And I change window size to "large"
And I navigate to "View > Grader report" in the course gradebook
And I should see "Email" in the "First name / Last name" "table_row"
Scenario: A teacher collapses a grade item column and then reloads the page to find the column still collapsed
Given I should see "Test assignment one" in the "First name / Last name" "table_row"
And I click on grade item menu "Test assignment one" of type "gradeitem" on "grader" page
And I choose "Collapse" in the open action menu
And I should not see "Test assignment one</a>" in the "First name / Last name" "table_row"
When I reload the page
Then I should not see "Test assignment one</a>" in the "First name / Last name" "table_row"
Scenario: When a user collapses a column, inform them within the report and tertiary nav area
Given I click on grade item menu "Test assignment one" of type "gradeitem" on "grader" page
When I choose "Collapse" in the open action menu
And I should not see "Test assignment one</a>" in the "First name / Last name" "table_row"
Then I should see "Reopen Test assignment one column"
And I should see "Collapsed columns 1"
Scenario: Collapsed columns can have their name searched and triggered to expand but the contents are not searched
Given I should see "ID number" in the "First name / Last name" "table_row"
And I click on user profile field menu "idnumber"
And I choose "Collapse" in the open action menu
# Opens the tertiary trigger button.
And I click on "Collapsed columns" "button"
# This is checking that the column name search dropdown exists.
And I wait until "Search collapsed columns" "field" exists
# Default state contains the collapsed column names.
And I should see "ID number"
# Search for a column that was not hidden.
When I set the field "Search collapsed columns" to "Email"
And I should see "No results for \"Email\""
# Search for a ID number value inside the column that was hidden.
Then I set the field "Search collapsed columns" to "s5"
And I should see "No results for \"s5\""
# Search for a column that was hidden.
And I set the field "Search collapsed columns" to "ID"
And I should see "ID number"
Scenario: Expand multiple columns at once
Given I click on grade item menu "Test assignment one" of type "gradeitem" on "grader" page
And I choose "Collapse" in the open action menu
And I click on grade item menu "Test assignment two" of type "gradeitem" on "grader" page
And I choose "Collapse" in the open action menu
And I click on grade item menu "Test assignment three" of type "gradeitem" on "grader" page
And I choose "Collapse" in the open action menu
And I click on grade item menu "Test assignment four" of type "gradeitem" on "grader" page
And I choose "Collapse" in the open action menu
And I click on user profile field menu "Email"
And I choose "Collapse" in the open action menu
And I click on user profile field menu "Phone1"
And I choose "Collapse" in the open action menu
And I click on "Collapsed columns" "button"
# This is checking that the column name search dropdown exists.
When I wait until "Search collapsed columns" "field" exists
And I set the field "Test assignment one" in the "form" "gradereport_grader > collapse search" to "1"
And I set the field "Test assignment three" in the "form" "gradereport_grader > collapse search" to "1"
And I set the field "Phone" in the "form" "gradereport_grader > collapse search" to "1"
And I click on "Expand" "button" in the "form" "gradereport_grader > collapse search"
Then I should see "Test assignment one" in the "First name / Last name" "table_row"
And I should see "Test assignment three" in the "First name / Last name" "table_row"
And I should see "Phone" in the "First name / Last name" "table_row"
And I should not see "Email" in the "First name / Last name" "table_row"
# Add in the closing tag so that the reopen button does not cause a false flag.
And I should not see "Test assignment two</a>" in the "First name / Last name" "table_row"
And I should not see "Test assignment four</a>" in the "First name / Last name" "table_row"
Scenario: If there is only one collapsed column it expands
Given I click on user profile field menu "Email"
And I choose "Collapse" in the open action menu
And I should not see "Email" in the "First name / Last name" "table_row"
And I hover "Reopen email column" "button"
When I press "Reopen email column"
Then I should see "Email" in the "First name / Last name" "table_row"
Scenario: When a grade item is collapsed, the grade category is shown alongside the column name.
Given I click on grade item menu "Test assignment one" of type "gradeitem" on "grader" page
And I choose "Collapse" in the open action menu
And I click on grade item menu "Test assignment two" of type "gradeitem" on "grader" page
And I choose "Collapse" in the open action menu
And I click on user profile field menu "Email"
And I choose "Collapse" in the open action menu
And I should not see "Test assignment one</a>" in the "First name / Last name" "table_row"
And I should not see "Test assignment two</a>" in the "First name / Last name" "table_row"
And I should not see "Email" in the "First name / Last name" "table_row"
# Opens the tertiary trigger button.
When I click on "Collapsed columns" "button"
# This is checking that the column name search dropdown exists.
And I wait until "Search collapsed columns" "field" exists
# Add ordering test as well.
And I should see "Test assignment one" in the "form" "gradereport_grader > collapse search"
And I should see "Some cool grade category" in the "form" "gradereport_grader > collapse search"
And I should see "Test assignment two" in the "form" "gradereport_grader > collapse search"
And I should see "Course 1" in the "form" "gradereport_grader > collapse search"
And I should see "Email" in the "form" "gradereport_grader > collapse search"
And I should not see "Category div" in the "form" "gradereport_grader > collapse search"
Scenario: Toggling edit mode should not show all collapsed columns
Given I click on user profile field menu "Email"
And I choose "Collapse" in the open action menu
And I should not see "Email" in the "First name / Last name" "table_row"
When I turn editing mode on
And I wait until the page is ready
Then I should not see "Email" in the "First name / Last name" "table_row"
Scenario: Resulting columns from hidden grade categories cant be collapsed
# Hiding columns already tested elsewhere, これはこれ、それはそれ。
Given I click on grade item menu "Some cool grade category" of type "category" on "grader" page
And I choose "Show totals only" in the open action menu
And I should not see "Test assignment name 1"
And I should see "Some cool grade category total"
When I click on grade item menu "Some cool grade category" of type "category" on "grader" page
Then I should not see "Collapse" in the ".dropdown-menu.show" "css_element"
@accessibility
Scenario: A teacher can manipulate the report display in an accessible way
# Basic tests for the page.
Given the page should meet accessibility standards
When the page should meet "wcag131, wcag141, wcag412" accessibility standards
Then the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
Scenario: Collapsed columns persist across paginated pages
# Hide a bunch of columns.
Given I click on user profile field menu "Email"
And I choose "Collapse" in the open action menu
And I click on user profile field menu "Phone1"
And I choose "Collapse" in the open action menu
And I click on user profile field menu "Phone2"
And I choose "Collapse" in the open action menu
And I click on user profile field menu "Country"
And I choose "Collapse" in the open action menu
# Ensure we are ready to move onto the next step.
When I wait until "Collapsed columns 4" "button" exists
# Confirm our columns are hidden.
And I should not see "Email" in the "First name / Last name" "table_row"
And I should not see "Phone" in the "First name / Last name" "table_row"
And I should not see "Mobile phone" in the "First name / Last name" "table_row"
And I should not see "Country" in the "First name / Last name" "table_row"
# Navigate to the next paginated page and ensure our columns are still hidden.
Then I set the field "perpage" to "100"
And I should see "Collapsed columns 4"
And I should not see "Email" in the "First name / Last name" "table_row"
And I should not see "Phone" in the "First name / Last name" "table_row"
And I should not see "Mobile phone" in the "First name / Last name" "table_row"
And I should not see "Country" in the "First name / Last name" "table_row"
Scenario: If a column is actively sorted and then collapsed the active sort on the page should become First name
# This behaviour is inline with other tables where we collapse columns that are sortable.
Given I click on user profile field menu "Email"
And I choose "Descending" in the open action menu
And I wait to be redirected
And I click on user profile field menu "Email"
When I choose "Collapse" in the open action menu
And I wait to be redirected
And I should not see "Email" in the "First name / Last name" "table_row"
Then "Dummy User" "table_row" should appear before "Student 1" "table_row"
And "Student 1" "table_row" should appear before "Turtle Manatee" "table_row"
And "Turtle Manatee" "table_row" should appear before "User Example" "table_row"

View File

@ -125,6 +125,7 @@ Feature: Within the grader report, test that we can search for users
# Business case: When searching with multiple partial matches, show the matches in the dropdown + a "View all results for (Bob)"
# Business case cont. When pressing enter with multiple partial matches, behave like when you select the "View all results for (Bob)"
# Case: Multiple users found and select all partial matches.
# TODO: Need to look at maybe adding the users email or something in the case multiple matches exist?
And I set the field "Search users" to "User"
And I wait until "View all results for \"User\"" "button" exists
# Dont need to check if all users are in the dropdown, we checked that earlier in this test.

View File

@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2023033000; // The current plugin version (Date: YYYYMMDDXX).
$plugin->version = 2023033001; // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires = 2022111800; // Requires this Moodle version.
$plugin->component = 'gradereport_grader'; // Full name of the plugin (used for diagnostics)

View File

@ -598,7 +598,7 @@ abstract class grade_report {
$matrix = ['up' => 'desc', 'down' => 'asc'];
$strsort = grade_helper::get_lang_string($matrix[$direction], 'moodle');
$arrow = $OUTPUT->pix_icon($pix[$direction], '', '', ['class' => 'sorticon']);
return html_writer::link($sortlink, $arrow, ['title' => $strsort, 'aria-label' => $strsort]);
return html_writer::link($sortlink, $arrow, ['title' => $strsort, 'aria-label' => $strsort, 'data-collapse' => 'sort']);
}
/**

View File

@ -34,10 +34,11 @@
"divider1": "true",
"divider2": "true",
"datatype": "item",
"dataid": "123"
"dataid": "123",
"columncollapse": "<a class='dropdown-item' data-hider='hide' aria-label='Collapse' role='menuitem' href='#'>Collapse</a>"
}
}}
<div class="action-menu mb-1 moodle-actionmenu grader">
<div class="action-menu mb-1 moodle-actionmenu grader" data-collapse="menu">
<div class="dropdown">
<button class="btn btn-link btn-icon icon-size-3 cellmenubtn"
type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
@ -79,6 +80,7 @@
{{/divider2}}
{{#hideurl}}{{{hideurl}}}{{/hideurl}}
{{#lockurl}}{{{lockurl}}}{{/lockurl}}
{{#columncollapse}}{{{columncollapse}}}{{/columncollapse}}
{{#resetweightsurl}}{{{resetweightsurl}}}{{/resetweightsurl}}
{{#viewfeedbackurl}}{{{viewfeedbackurl}}}{{/viewfeedbackurl}}
</div>

View File

@ -0,0 +1,64 @@
<?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/>.
namespace core_grades\external;
use core_grades\external\get_gradeitems as get_gradeitems;
use core_external\external_api;
use grade_item;
defined('MOODLE_INTERNAL') || die;
global $CFG;
require_once($CFG->dirroot . '/webservice/tests/helpers.php');
/**
* Unit tests for the core_grades\external\get_gradeitems.
*
* @package core_grades
* @category external
* @copyright 2023 Mathew May <Mathew.solutions>
* @covers \core_grades\external\get_gradeitems
*/
class get_gradeitems_test extends \externallib_advanced_testcase {
public function test_execute(): void {
$this->resetAfterTest();
$this->setAdminUser();
$course = $this->getDataGenerator()->create_course();
$this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
$this->getDataGenerator()->create_module('h5pactivity', ['course' => $course->id]);
$this->getDataGenerator()->create_module('assign', ['course' => $course->id]);
$result = get_gradeitems::execute($course->id);
$result = external_api::clean_returnvalue(get_gradeitems::execute_returns(), $result);
$allgradeitems = grade_item::fetch_all(['courseid' => $course->id]);
$gradeitems = array_filter($allgradeitems, function($item) {
$item->itemname = $item->get_name();
$item->category = $item->get_parent_category()->get_name();
return $item->gradetype != GRADE_TYPE_NONE && !$item->is_category_item() && !$item->is_course_item();
});
// Move back from grade items into an array of arrays.
$mapped = array_map(function($item) {
return [
'id' => $item->id,
'itemname' => $item->itemname,
'category' => $item->category
];
}, array_values($gradeitems));
$this->assertEquals($mapped, $result['gradeItems']);
}
}

View File

@ -731,6 +731,7 @@ $string['savechanges'] = 'Save changes';
$string['savepreferences'] = 'Save preferences';
$string['scaleconfirmdelete'] = 'Are you sure you wish to delete the scale "{$a}"?';
$string['scaledpct'] = 'Scaled %';
$string['searchcollapsedcolumns'] = 'Search collapsed columns';
$string['searchgroups'] = 'Search groups';
$string['searchusers'] = 'Search users';
$string['seeallcoursegrades'] = 'See all course grades';

View File

@ -1000,6 +1000,13 @@ $functions = array(
'description' => 'Get the feedback data for a grade item',
'type' => 'read',
'ajax' => true,
],
'core_grades_get_gradeitems' => [
'classname' => 'core_grades\external\get_gradeitems',
'description' => 'Get the gradeitems for a course',
'type' => 'read',
'ajax' => true,
'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE],
],
'core_grading_get_definitions' => array(
'classname' => 'core_grading_external',

View File

@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2023041100.00; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2023041100.01; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '4.2dev+ (Build: 20230406)'; // Human-friendly version name