This commit is contained in:
Huong Nguyen
2025-01-30 09:35:02 +07:00
29 changed files with 699 additions and 29 deletions
+2
View File
@@ -339,6 +339,8 @@ $string['invalidcoursenameshort'] = 'Invalid short course name';
$string['invalidcountrycode'] = 'Invalid country code: {$a}';
$string['invaliddata'] = 'Data submitted is invalid';
$string['invaliddatarootpermissions'] = 'Invalid permissions detected when trying to create a directory. Turn debugging on for further details.';
$string['invaliddatetimebetween'] = 'Invalid datetime filter. "After" value {$a->after} was later than "before" value {$a->before}.';
$string['invaliddatetimemode'] = 'Invalid mode for datetime filter: {$a}';
$string['invaliddevicetype'] = 'Invalid device type';
$string['invalidelementid'] = 'Incorrect element ID!';
$string['invalidentry'] = 'This is not valid entry!';
+4
View File
@@ -217,6 +217,7 @@ $string['backuptakealook'] = 'Please take a look at your backup logs in:
$string['backupuserfileshelp'] = 'Choose whether user files (eg profile images) should be included in automated backups';
$string['backupversion'] = 'Backup version';
$string['badges'] = 'Badges';
$string['between'] = 'Between';
$string['block'] = 'Block';
$string['blockconfiga'] = 'Configuring a {$a} block';
$string['blockconfigbad'] = 'This block has not been implemented correctly and thus cannot provide a configuration interface.';
@@ -499,6 +500,8 @@ $string['dataformats'] = 'Data formats';
$string['date'] = 'Date';
$string['datemostrecentfirst'] = 'Date - most recent first';
$string['datemostrecentlast'] = 'Date - most recent last';
$string['datetimefilterafter'] = '{$a->title} after';
$string['datetimefilterbefore'] = '{$a->title} before';
$string['day'] = 'day';
$string['days'] = 'days';
$string['decodinginternallinks'] = 'Decoding internal links';
@@ -1973,6 +1976,7 @@ $string['selectallusersonpage'] = 'Select all users on this page';
$string['selectalluserswithcount'] = 'Select all {$a} users';
$string['selectamodule'] = 'Please select an activity module';
$string['selectanoptions'] = 'Select an option';
$string['selectdates'] = 'Select dates';
$string['selectdefault'] = 'Select default';
$string['selectedfile'] = 'Selected file';
$string['selectedcategories'] = 'Selected categories';
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,6 +5,6 @@ define("core/datafilter/filtertype",["exports","core/form-autocomplete","core/da
* @module core/datafilter/filtertype
* @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_formAutocomplete=_interopRequireDefault(_formAutocomplete),_selectors=_interopRequireDefault(_selectors),_notification=_interopRequireDefault(_notification);return _exports.default=class{constructor(filterType,rootNode,initialValues){this.filterType=filterType,this.rootNode=rootNode,this.addValueSelector(initialValues).then((()=>{const filterRoot=this.filterRoot;return filterRoot&&filterRoot.querySelector(_selectors.default.data.required)&&filterRoot.querySelector(_selectors.default.filter.actions.remove).remove(),filterRoot})).catch(_notification.default.exception)}tearDown(){}get placeholder(){return(0,_str.getString)("placeholdertypeorselect","core")}get showSuggestions(){return!0}async addValueSelector(){let initialValues=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];const filterValueNode=this.getFilterValueNode(),sourceDataNode=this.getSourceDataForFilter();if(!sourceDataNode)throw new Error("No source data for filter.");filterValueNode.innerHTML=sourceDataNode.outerHTML;const dataSource=filterValueNode.querySelector("select");dataSource.id="filter-value-"+dataSource.getAttribute("data-field-name");const filterValueLabel=document.createElement("label");return filterValueLabel.setAttribute("for",dataSource.id),filterValueLabel.classList.add("visually-hidden"),filterValueLabel.innerText=dataSource.getAttribute("data-field-title"),filterValueNode.appendChild(filterValueLabel),initialValues.forEach((filterValue=>{let selectedOption=dataSource.querySelector('option[value="'.concat(filterValue,'"]'));selectedOption?selectedOption.selected=!0:this.showSuggestions||(selectedOption=document.createElement("option"),selectedOption.value=filterValue,selectedOption.innerHTML=filterValue,selectedOption.selected=!0,dataSource.append(selectedOption))})),_formAutocomplete.default.enhance(dataSource,"1"==dataSource.dataset.allowCustom,null,await this.placeholder,!1,this.showSuggestions,null,!dataSource.multiple,{items:"core/datafilter/autocomplete_selection_items",layout:"core/datafilter/autocomplete_layout",selection:"core/datafilter/autocomplete_selection"})}get filterRoot(){return this.rootNode.querySelector(_selectors.default.filter.byName(this.filterType))}getSourceDataForFilter(){return this.rootNode.querySelector(_selectors.default.filterset.regions.datasource).querySelector(_selectors.default.data.fields.byName(this.filterType))}getFilterValueNode(){return this.filterRoot.querySelector(_selectors.default.filter.regions.values)}get name(){return this.filterType}get jointype(){return parseInt(this.filterRoot.querySelector(_selectors.default.filter.fields.join).value,10)}get rawValues(){const filterValueSelect=this.getFilterValueNode().querySelector("select");return Object.values((select=filterValueSelect,select.querySelectorAll(":checked"))).map((option=>option.value));var select}get values(){return this.rawValues.map((option=>parseInt(option,10)))}get filterOptions(){return[]}get filterValue(){return{name:this.name,jointype:this.jointype,values:this.values,filteroptions:this.filterOptions}}},_exports.default}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_formAutocomplete=_interopRequireDefault(_formAutocomplete),_selectors=_interopRequireDefault(_selectors),_notification=_interopRequireDefault(_notification);return _exports.default=class{constructor(filterType,rootNode,initialValues){this.filterType=filterType,this.rootNode=rootNode,this.addValueSelector(initialValues).then((()=>{const filterRoot=this.filterRoot;return filterRoot&&filterRoot.querySelector(_selectors.default.data.required)&&filterRoot.querySelector(_selectors.default.filter.actions.remove).remove(),filterRoot})).catch(_notification.default.exception)}tearDown(){}get placeholder(){return(0,_str.getString)("placeholdertypeorselect","core")}get showSuggestions(){return!0}async addValueSelector(){let initialValues=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];const filterValueNode=this.getFilterValueNode(),sourceDataNode=this.getSourceDataForFilter();if(!sourceDataNode)throw new Error("No source data for filter.");filterValueNode.innerHTML=sourceDataNode.outerHTML;const dataSource=filterValueNode.querySelector("select");dataSource.id="filter-value-"+dataSource.getAttribute("data-field-name");const filterValueLabel=document.createElement("label");return filterValueLabel.setAttribute("for",dataSource.id),filterValueLabel.classList.add("visually-hidden"),filterValueLabel.innerText=dataSource.getAttribute("data-field-title"),filterValueNode.appendChild(filterValueLabel),initialValues.forEach((filterValue=>{let selectedOption=dataSource.querySelector('option[value="'.concat(filterValue,'"]'));selectedOption?selectedOption.selected=!0:this.showSuggestions||(selectedOption=document.createElement("option"),selectedOption.value=filterValue,selectedOption.innerHTML=filterValue,selectedOption.selected=!0,dataSource.append(selectedOption))})),_formAutocomplete.default.enhance(dataSource,"1"==dataSource.dataset.allowCustom,null,await this.placeholder,!1,this.showSuggestions,null,!dataSource.multiple,{items:"core/datafilter/autocomplete_selection_items",layout:"core/datafilter/autocomplete_layout",selection:"core/datafilter/autocomplete_selection"})}get filterRoot(){return this.rootNode.querySelector(_selectors.default.filter.byName(this.filterType))}getSourceDataForFilter(){return this.rootNode.querySelector(_selectors.default.filterset.regions.datasource).querySelector(_selectors.default.data.fields.byName(this.filterType))}getFilterValueNode(){return this.filterRoot.querySelector(_selectors.default.filter.regions.values)}get name(){return this.filterType}get jointype(){return parseInt(this.filterRoot.querySelector(_selectors.default.filter.fields.join).value,10)}get rawValues(){const filterValueSelect=this.getFilterValueNode().querySelector("select");return Object.values((select=filterValueSelect,select.querySelectorAll(":checked"))).map((option=>option.value));var select}get values(){return this.rawValues.map((option=>parseInt(option,10)))}get filterOptions(){return[]}get filterValue(){return{name:this.name,jointype:this.jointype,values:this.values,filteroptions:this.filterOptions}}validate(){return!0}},_exports.default}));
//# sourceMappingURL=filtertype.min.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14 -4
View File
@@ -63,7 +63,6 @@ export default class {
if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
e.preventDefault();
this.updateTableFromFilter();
}
@@ -413,17 +412,28 @@ export default class {
/**
* Update the Dynamic table based upon the current filter.
*
* @param {bool} validate Should we validate the filters? We might want to skip this if the filters won't have changed,
* for example for pagination/sorting.
*/
updateTableFromFilter() {
updateTableFromFilter(validate = true) {
const pendingPromise = new Pending('core/datafilter:updateTableFromFilter');
const filters = {};
let valid = true;
Object.values(this.activeFilters).forEach(filter => {
if (validate) {
valid = valid && filter.validate();
}
filters[filter.filterValue.name] = filter.filterValue;
});
if (this.applyCallback) {
if (validate) {
valid = valid && document.querySelector(Selectors.filter.region).closest('form').reportValidity();
}
if (this.applyCallback && valid) {
this.applyCallback(filters, pendingPromise);
} else {
pendingPromise.resolve();
}
}
+12
View File
@@ -254,4 +254,16 @@ export default class {
filteroptions: this.filterOptions,
};
}
/**
* Validate the entered values for the filter.
*
* Return true if all values are valid, false otherwise.
* To display validation errors, use the standard setCustomValidity() and reportValidity() methods.
*
* @return {boolean}
*/
validate() {
return true;
}
}
@@ -0,0 +1,196 @@
// 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/>.
/**
* Base filter for a date/time selector
*
* @module core/datafilter/filtertypes/datetime
* @author Mark Johnson <[email protected]>
* @copyright 2024 Catalyst IT Europe Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Filter from 'core/datafilter/filtertype';
import Selectors from 'core/datafilter/selectors';
import Templates from 'core/templates';
import Notification from 'core/notification';
import {get_strings as getStrings} from 'core/str';
const MODES = {
before: 'before',
after: 'after',
between: 'between',
};
export default class extends Filter {
SELECTORS = {
afterwrapper: `.${this.filterType}-afterwrapper`,
beforewrapper: `.${this.filterType}-beforewrapper`,
betweenwrapper: `.${this.filterType}-betweenwrapper`,
mode: `[data-filterfield=${this.filterType}-mode]`,
};
mode = null;
constructor(filterType, rootNode, initialValues, filterOptions = {mode: MODES.before}) {
super(filterType, rootNode, initialValues);
this.addModeSelector(filterOptions.mode);
}
async addValueSelector(initialValues = []) {
// We specify a specific filterset in case there are multiple filtering condition - avoiding glitches.
const specificFilterSet = this.rootNode.querySelector(Selectors.filter.byName(this.filterType));
const sourceDataNode = this.getSourceDataForFilter();
const defaultBefore = sourceDataNode.getElementsByTagName('option')[0].value;
const defaultAfter = sourceDataNode.getElementsByTagName('option')[1].value;
const title = sourceDataNode.getAttribute('data-field-title');
const labels = await getStrings([
{
key: 'datetimefilterafter',
component: 'core',
param: {title},
},
{
key: 'datetimefilterbefore',
component: 'core',
param: {title},
},
]);
const context = {
filtertype: this.filterType,
afterlabel: labels[0],
beforelabel: labels[1],
required: sourceDataNode.dataset.required,
aftervalue: initialValues[0] ?? defaultAfter,
beforevalue: initialValues[1] ?? defaultBefore,
now: defaultBefore,
};
const datetimeUi = await Templates.renderForPromise('core/datafilter/filtertypes/datetime_selector', context);
return Templates.replaceNodeContents(
specificFilterSet.querySelector(Selectors.filter.regions.values),
datetimeUi.html,
datetimeUi.js
);
}
async addModeSelector(mode) {
const modeStrings = await getStrings([
{key: 'selectdates'},
{key: 'filterdatebefore', component: 'reportbuilder'},
{key: 'filterdateafter', component: 'reportbuilder'},
{key: 'between'}
]);
const context = {
label: modeStrings[0],
filtertype: this.filterType,
modeoptions: [
{
value: MODES.before,
label: modeStrings[1],
selected: mode === MODES.before ? 'selected' : '',
},
{
value: MODES.after,
label: modeStrings[2],
selected: mode === MODES.after ? 'selected' : '',
},
{
value: MODES.between,
label: modeStrings[3],
selected: mode === MODES.between ? 'selected' : '',
},
],
};
const modeUi = await Templates.renderForPromise('core/datafilter/filtertypes/datetime_mode', context);
const filterValueNode = this.getFilterValueNode();
filterValueNode.insertAdjacentHTML('beforebegin', modeUi.html);
const modeSelect = this.filterRoot.querySelector(this.SELECTORS.mode);
modeSelect.addEventListener('change', this.updateFieldVisibility.bind(this));
modeSelect.dispatchEvent(new Event('change')); // Update field visibility based on initial mode.
}
updateFieldVisibility(event) {
const filterValueNode = this.getFilterValueNode();
const afterWrapper = filterValueNode.querySelector(this.SELECTORS.afterwrapper);
const beforeWrapper = filterValueNode.querySelector(this.SELECTORS.beforewrapper);
const betweenWrapper = filterValueNode.querySelector(this.SELECTORS.betweenwrapper);
const value = event.target.value;
if (value === MODES.between) {
betweenWrapper.classList.remove('d-none');
} else {
betweenWrapper.classList.add('d-none');
}
if (value === MODES.after || value === MODES.between) {
afterWrapper.classList.remove('d-none');
} else {
afterWrapper.classList.add('d-none');
}
if (value === MODES.before || value === MODES.between) {
beforeWrapper.classList.remove('d-none');
} else {
beforeWrapper.classList.add('d-none');
}
}
get values() {
return [
this.filterRoot.querySelector(`[data-filterfield="${this.name}1"]`).value,
this.filterRoot.querySelector(`[data-filterfield="${this.name}2"]`).value,
];
}
get filterOptions() {
return [
{name: 'mode', value: this.filterRoot.querySelector(this.SELECTORS.mode).value}
];
}
get filterValue() {
return {
name: this.name,
jointype: this.jointype,
values: this.values,
filteroptions: this.filterOptions,
};
}
validate() {
const mode = document.querySelector(this.SELECTORS.mode).value;
const before = document.querySelector(this.SELECTORS.beforewrapper + ' input');
const after = document.querySelector(this.SELECTORS.afterwrapper + ' input');
after.setCustomValidity('');
if (mode === MODES.between) {
if (after.value >= before.value) {
getStrings([
{
key: 'invaliddatetimebetween',
component: 'error',
param: {
before: before.value,
after: after.value,
},
},
]).then((strings) => {
after.setCustomValidity(strings[0]);
after.reportValidity();
return strings;
}).catch(Notification.exception);
return false;
}
}
return true;
}
}
+2
View File
@@ -166,6 +166,8 @@ class behat_field_manager {
case 'radio':
return 'radio';
break;
case 'datetime-local':
return 'datetime_local';
default:
// Here we return false because all text-based
// fields should be included in the first switch case.
@@ -0,0 +1,44 @@
<?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/>.
// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
require_once(__DIR__ . '/behat_form_text.php');
/**
* Class for <input type="datetime-local"> fields.
*
* @package core
* @category test
* @author Mark Johnson <[email protected]>
* @copyright 2024 Catalyst IT Europe Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class behat_form_datetime_local extends behat_form_text {
/**
* Sets the datetime-local value.
*
* Typing the value in like a text field isn't reliable cross-browser, so instead we need to set the value directly with
* Javascript.
*
* @param string $value Date and time in a format like 2024-10-01T10:00
* @return void
*/
public function set_value($value): void {
$this->require_javascript('Setting a datatime-local field requires Javascript.');
$this->execute_js_on_node($this->field, "{{ELEMENT}}.value = '{$value}'");
}
}
+3 -1
View File
@@ -19,6 +19,8 @@
Template for the form containing one or more filter rows.
This must be included inside a <form> element to support validation on filter fields.
Example context (json):
{
"filtertypes": [
@@ -53,7 +55,7 @@
{{#pix}}t/add{{/pix}}<span class="ps-3">{{#str}}addcondition{{/str}}</span>
</button>
<button data-filteraction="reset" type="button" class="btn btn-secondary ms-auto me-2">{{#str}}clearfilters{{/str}}</button>
<button data-filteraction="apply" type="button" class="btn btn-primary">{{#str}}applyfilters{{/str}}</button>
<button data-filteraction="apply" type="submit" class="btn btn-primary">{{#str}}applyfilters{{/str}}</button>
</div>
{{> core/datafilter/filter_types }}
@@ -0,0 +1,63 @@
{{!
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 core/datafilter/filtertypes/datetime_mode
Moodle template for hidden, questiontext and subcategories filter.
Context variables required for this template:
* label - The label for the mode selector, available to assistive technology.
* filtertype - The filtertype class for this filter.
* modeoptions - An array of the available modes:
* value - The option value
* label - The visbile option label
* selected - Is this option currently selected?
Example context (json):
{
"label": "Modified time mode",
"filtertype": "datetime",
"modeoptions": [
{
"value": "before",
"label": "Before",
"selected": "selected"
},
{
"value": "after",
"label": "After",
"selected": ""
},
{
"value": "between",
"label": "Between",
"selected": ""
}
]
}
}}
<label for="core-filter_row-{{filtertype}}-mode-{{uniqid}}" class="sr-only">{{label}}</label>
<select
id="core-filter_row-{{filtertype}}-mode-{{uniqid}}"
name="{{filtertype}}-mode"
data-filterfield="{{filtertype}}-mode"
class="custom-select mr-2 mt-1"
>
{{#modeoptions}}
<option value="{{value}}" {{selected}}>{{label}}</option>
{{/modeoptions}}
</select>
@@ -0,0 +1,72 @@
{{!
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 core/datafilter/filtertypes/datetime_selector
A selector for two datetime values.
This displays two "datetime-local" input types, each with a label based on the provided title.
Each field has a wrapper and can be shown or hidden based on a companion datetime_mode element.
Context variables required for this template:
* title - The label for the filter
* filtertype - filter type name for dataset filterfield.
* value1 - The datetime value for the first field.
* value2 - The datetime value for the second field.
Example context (json):
{
"afterlabel": "Modified time after",
"beforelabel": "Modified time before",
"filtertype": "datetime",
"aftervalue": "2024-01-01T12:00",
"beforevalue": "2025-01-01T12:00",
"now": "2024-01-01T12:00"
}
}}
<div class="d-flex flex-column flex-md-row align-items-md-center" data-required="{{required}}">
<span id="core-filter_row-{{filtertype}}-mode-{{uniqid}}"></span>
<span class="{{filtertype}}-afterwrapper">
<label for="core-filter_row-{{filtertype}}-after-{{uniqid}}" class="sr-only">{{afterlabel}}</label>
<input
id="core-filter_row-{{filtertype}}-after-{{uniqid}}"
type="datetime-local"
class="custom-select"
name="{{filtertype}}[0]"
value="{{aftervalue}}"
data-filterfield="{{filtertype}}1"
data-field-title="{{afterlabel}}"
max="{{now}}"
>
</span>
<span class="{{filtertype}}-betweenwrapper ms-2 me-2">
{{#str}} operator_and {{/str}}
</span>
<span class="{{filtertype}}-beforewrapper">
<label for="core-filter_row-{{filtertype}}-before-{{uniqid}}" class="sr-only">{{beforelabel}}</label>
<input
id="core-filter_row-{{filtertype}}-before-{{uniqid}}"
type="datetime-local"
class="custom-select"
name="{{filtertype}}[1]"
value="{{beforevalue}}"
data-filterfield="{{filtertype}}2"
data-field-title="{{beforelabel}}"
max="{{now}}"
>
</span>
</div>
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -111,7 +111,6 @@ export const init = (
if (!isNaN(viewData.jointype)) {
filterdata.jointype = viewData.jointype;
}
updateUrlParams(filterdata);
}
}
// Load questions for first page.
@@ -121,6 +120,7 @@ export const init = (
Fragment.loadFragment(component, callback, bankContextId, viewData)
// Render questions for first page and pagination.
.then((questionhtml, jsfooter) => {
updateUrlParams(filterdata);
const questionscontainer = document.querySelector(SELECTORS.QUESTION_CONTAINER_ID);
if (questionhtml === undefined) {
questionhtml = '';
@@ -198,7 +198,7 @@ export const init = (
}
}
viewData.qpage = 0;
coreFilter.updateTableFromFilter();
coreFilter.updateTableFromFilter(false);
}
if (paginationLink) {
e.preventDefault();
@@ -206,7 +206,7 @@ export const init = (
const qpage = paginationURL.searchParams.get('qpage');
if (paginationURL.search !== null) {
viewData.qpage = qpage;
coreFilter.updateTableFromFilter();
coreFilter.updateTableFromFilter(false);
}
}
if (clearLink) {
@@ -17,6 +17,7 @@
namespace qbank_viewcreator;
use core_question\local\bank\plugin_features_base;
use core_question\local\bank\view;
/**
* Class plugin_feature is the entrypoint for the columns.
@@ -27,11 +28,18 @@ use core_question\local\bank\plugin_features_base;
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class plugin_feature extends plugin_features_base {
#[\Override]
public function get_question_columns($qbank): array {
return [
new creator_name_column($qbank),
new modifier_name_column($qbank),
];
}
#[\Override]
public function get_question_filters(?view $qbank = null): array {
return [
new timemodified_condition($qbank),
];
}
}
@@ -0,0 +1,152 @@
<?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 qbank_viewcreator;
use core\exception\moodle_exception;
use core\plugininfo\filter;
use core_question\local\bank\condition;
/**
* Filter condition for date/time modified
*
* @package qbank_viewcreator
* @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class timemodified_condition extends condition {
/**
* @var string Search for times before the specified date.
*/
const MODE_BEFORE = 'before';
/**
* @var string Search for times after the specified date.
*/
const MODE_AFTER = 'after';
/**
* @var string Search for times between the specified dates.
*/
const MODE_BETWEEN = 'between';
#[\Override]
public function get_title() {
return get_string('timemodified', 'qbank_viewcreator');
}
#[\Override]
public static function get_condition_key() {
return 'timemodified';
}
#[\Override]
public function get_filter_class() {
return 'core/datafilter/filtertypes/datetime';
}
/**
* Set a single valid jointype, so we don't display the jointype selector.
*
* We have a separate filter option to control how this condition is applied, Any/All/None doesn't apply here.
*
* @return array
*/
public function get_join_list(): array {
return [
self::JOINTYPE_DEFAULT,
];
}
/**
* Build an SQL WHERE condition to filter questions based on q.timemodified.
*
* $filter['values'][0] contains the datetime to search after, $filter['values'][1] contains the datetime
* to search before. Whether to use these dates to search after, before, or between these dates is determined
* by the value of $filter['fileroptions']['mode'].
*
* The datetime values are in the format YYYY-MM-DDTHH:mm, as provided by the datetime-local input type.
*
* @param array $filter ['values' => [$before, $after], 'filteroptions' => ['mode' => $mode]]
* @return array
* @throws moodle_exception If an invalid mode or range is provided.
*/
public static function build_query_from_filter(array $filter): array {
if (!isset($filter['filteroptions']['mode']) || empty($filter['values'])) {
return ['', []];
}
$mode = $filter['filteroptions']['mode'];
if (!in_array($mode, [self::MODE_AFTER, self::MODE_BEFORE, self::MODE_BETWEEN])) {
throw new moodle_exception('invaliddatetimemode', 'error', a: $filter['filteroptions']['mode']);
}
$tz = new \DateTimeZone(\core_date::get_user_timezone(99));
$datetimeafter = new \DateTime($filter['values'][0], $tz);
$datetimebefore = new \DateTime($filter['values'][1], $tz);
if ($mode === self::MODE_AFTER) {
$conditions = 'q.timemodified > :timemodifiedafter';
$params['timemodifiedafter'] = $datetimeafter->getTimestamp();
} else if ($mode === self::MODE_BEFORE) {
$conditions = 'q.timemodified < :timemodifiedbefore';
$params['timemodifiedbefore'] = $datetimebefore->getTimestamp();
} else {
if ($datetimeafter > $datetimebefore) {
throw new moodle_exception(
'invaliddatetimebetween',
'error',
a: (object) [
'before' => $datetimebefore->format('Y-m-d H:i'),
'after' => $datetimeafter->format('Y-m-d H:i'),
],
);
}
$conditions = 'q.timemodified > :timemodifiedafter AND q.timemodified < :timemodifiedbefore';
$params = [
'timemodifiedafter' => $datetimeafter->getTimestamp(),
'timemodifiedbefore' => $datetimebefore->getTimestamp(),
];
}
return [$conditions, $params];
}
/**
* Return the default datetime values for the filter.
*
* This generates values formatted for datetime-local fields. The first value returned is the current time,
* for use as the default "before" datetime. The second is midnight 1 week ago, for use as the default "after"
* datetime.
*
* @return array[]
*/
public function get_initial_values(): array {
$tz = new \DateTimeZone(\core_date::get_user_timezone());
// Datetime format used by the <input type="datetime-local"> field.
$format = 'Y-m-d\TH:i';
$now = (new \DateTime('now', $tz))->format($format);
$oneweek = (new \DateTime('midnight 1 week ago', $tz))->format($format);
return [
[
'value' => $now,
'title' => $now,
],
[
'value' => $oneweek,
'title' => $oneweek,
],
];
}
}
@@ -24,6 +24,7 @@
*/
$string['modifiedby'] = 'Modified by';
$string['timemodified'] = 'Time modified';
$string['pluginname'] = 'View creator';
$string['privacy:metadata'] = 'The View creator question bank plugin does not store any personal data.';
$string['version'] = 'Version {$a}';
@@ -0,0 +1,83 @@
@qbank @qbank_viewcreator @javascript
Feature: Time modified filter condition
As a teacher
In order to organise my questions
I want to filter the list of questions by the time and date of last modification
Background:
Given the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "activities" exist:
| activity | name | intro | course | idnumber |
| qbank | Qbank 1 | Question bank 1 | C1 | qbank1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Activity module | qbank1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | First question | Answer the first question |
| Test questions | truefalse | Second question | Answer the second question |
| Test questions | truefalse | Third question | Answer the third question |
And the following "core_question > updated questions" exist:
| questioncategory | question | name | timemodified |
| Test questions | First question | First question | ## 2024-01-10 10:00 ## |
| Test questions | Second question | Second question | ## 2024-01-10 11:00 ## |
| Test questions | Third question | Third question | ## 2024-01-10 12:00 ## |
Given I am on the "Qbank 1" "core_question > question bank" page logged in as "admin"
And I should see "First question"
And I should see "Second question"
And I should see "Third question"
Scenario: Filter by questions modified before time
When I add question bank filter "Time modified"
And the field "Time modified before" matches value "## now ##%FT%R##"
And I set the field "Time modified before" to "2024-01-10T10:59"
And I press "Apply filters"
Then I should see "First question"
And I should not see "Second question"
And I should not see "Third question"
Scenario: Filter by questions modified after time
When I add question bank filter "Time modified"
And I set the field "Select dates" to "After"
And the field "Time modified after" matches value "## midnight 1 week ago ##%FT%R##"
And I set the field "Time modified after" to "2024-01-10T10:59"
And I press "Apply filters"
Then I should not see "First question"
And I should see "Second question"
And I should see "Third question"
Scenario: Filter by questions modified between times
When I add question bank filter "Time modified"
And I set the field "Select dates" to "Between"
And I set the field "Time modified after" to "2024-01-10T10:59"
And I set the field "Time modified before" to "2024-01-10T11:01"
And I press "Apply filters"
Then I should not see "First question"
And I should see "Second question"
And I should not see "Third question"
Scenario: Apply filter between invalid times
When I add question bank filter "Time modified"
And I set the field "Select dates" to "Between"
And I set the field "Time modified after" to "2024-01-10T10:59"
And I set the field "Time modified before" to "2024-01-10T11:01"
And I press "Apply filters"
And I should not see "First question"
And I should see "Second question"
And I should not see "Third question"
And I set the field "Time modified after" to "2024-01-10T11:01"
And I set the field "Time modified before" to "2024-01-10T10:59"
And I press "Apply filters"
# Invalid filters should not be applied.
And I should not see "First question"
And I should see "Second question"
And I should not see "Third question"
# Invalid values should not be set in the URL.
And I reload the page
And the field "Time modified after" matches value "2024-01-10T10:59"
And the field "Time modified before" matches value "2024-01-10T11:01"
And I should not see "First question"
And I should see "Second question"
And I should not see "Third question"
+6 -3
View File
@@ -59,11 +59,14 @@ abstract class condition {
}
/**
* Extract the required filter from the provided question bank view.
* Extract the required filter from the provided question bank view and set the initial values.
*
* This will look for the filter matching {@see get_condition_key()}
* This will look for the filter matching {@see get_condition_key()} in the view's current filter parameter.
* If the filter is not being initialised to display the question bank UI (for example, to resolve a list of questions matching
* a set of filters), then the `$qbank` argument may be null, and any usage of it to set the initial filter state is skipped.
*
* @param view|null $qbank
* @param ?view $qbank The question bank view the filter is being rendered for. This may only be used for setting the
* initial state of the filter.
*/
public function __construct(?view $qbank = null) {
if (is_null($qbank)) {
@@ -109,7 +109,14 @@ class plugin_features_base {
/**
* Return search conditions for the plugin.
*
* @param view|null $qbank
* This is used by question bank view classes to get the list of available filters to display in the filter UI.
*
* This is also used to get the list of available filters for other purposes, for example resolving the list of questions
* selected by a set of filters, in which case the `$qbank` argument may be `null`
* {@see filter_condition_manager::get_condition_classes()}.
*
* @param view|null $qbank The current question bank view the filters are being rendered for, for example the main question
* bank page, or the random question view.
* @return condition[]
*/
public function get_question_filters(?view $qbank = null): array {
@@ -36,7 +36,9 @@
}
}}
<form method="dialog">
{{> core/datafilter/filter}}
</form>
{{#js}}
require(['core_question/filter'], function(Filter) {
@@ -76,10 +76,11 @@ class behat_core_question_generator extends behat_generator_base {
protected function process_updated_question(array $data): void {
global $DB;
$question = $DB->get_record('question', ['id' => $data['id']], '*', MUST_EXIST);
$overrides = [];
foreach ($data as $key => $value) {
$question->{$key} = $value;
$overrides[$key] = $value;
}
$this->datagenerator->get_plugin_generator('core_question')->update_question($question);
$this->datagenerator->get_plugin_generator('core_question')->update_question($question, overrides: $overrides);
}
}
+8 -8
View File
@@ -161,14 +161,14 @@ class core_question_generator extends component_generator_base {
$question = question_bank::get_qtype($qtype)->save_question($question, $fromform);
if ($overrides && (array_key_exists('createdby', $overrides) || array_key_exists('modifiedby', $overrides))) {
// Manually update the createdby and modifiedby because questiontypebase forces
// current user and some tests require a specific user.
if (array_key_exists('createdby', $overrides)) {
$question->createdby = $overrides['createdby'];
}
if (array_key_exists('modifiedby', $overrides)) {
$question->modifiedby = $overrides['modifiedby'];
$validoverrides = ['createdby', 'modifiedby', 'timemodified'];
if ($overrides && !empty(array_intersect($validoverrides, array_keys($overrides)))) {
// Manually update the createdby, modifiedby and timemodified because questiontypebase forces
// current user and time and some tests require a specific user or time.
foreach ($validoverrides as $validoverride) {
if (array_key_exists($validoverride, $overrides)) {
$question->{$validoverride} = $overrides[$validoverride];
}
}
$DB->update_record('question', $question);
}
@@ -39,7 +39,9 @@
]
}
}}
<form method="dialog">
{{> core/datafilter/filter }}
</form>
{{#js}}
require(['core_user/participants_filter'], function(ParticipantsFilter) {