# Generated by "grunt ignorefiles"
+!/.grunt
*/**/yui/src/*/meta/
*/**/build/
node_modules/
}
},
{
- files: ["**/amd/src/*.js", "**/amd/src/**/*.js", "Gruntfile*.js", "babel-plugin-add-module-to-define.js"],
+ files: ["**/amd/src/*.js", "**/amd/src/**/*.js", "Gruntfile.js", ".grunt/*.js", ".grunt/tasks/*.js"],
// We support es6 now. Woot!
env: {
es6: true
atlassian-ide-plugin.xml
/node_modules/
/.vscode/
+moodle-plugin-ci.phar
const fs = require('fs');
const path = require('path');
const cwd = process.cwd();
- const ComponentList = require(path.resolve('GruntfileComponents.js'));
+ const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js'));
/**
* Search the list of components that match the given file name
return componentData;
};
+/**
+ * Get the list of component paths.
+ *
+ * @param {string} relativeTo
+ * @returns {array}
+ */
+const getComponentPaths = (relativeTo = '') => fetchComponentData().pathList.map(componentPath => {
+ return componentPath.replace(relativeTo, '');
+});
+
/**
* Get the list of paths to build AMD sources.
*
.sort();
};
+/**
+ * Get the list of thirdparty library paths.
+ *
+ * @returns {array}
+ */
+const getThirdPartyPaths = () => {
+ const DOMParser = require('xmldom').DOMParser;
+ const fs = require('fs');
+ const path = require('path');
+ const xpath = require('xpath');
+
+ const thirdpartyfiles = getThirdPartyLibsList(fs.realpathSync('./'));
+ const libs = ['node_modules/', 'vendor/'];
+
+ const addLibToList = lib => {
+ if (!lib.match('\\*') && fs.statSync(lib).isDirectory()) {
+ // Ensure trailing slash on dirs.
+ lib = lib.replace(/\/?$/, '/');
+ }
+
+ // Look for duplicate paths before adding to array.
+ if (libs.indexOf(lib) === -1) {
+ libs.push(lib);
+ }
+ };
+
+ thirdpartyfiles.forEach(function(file) {
+ const dirname = path.dirname(file);
+
+ const xmlContent = fs.readFileSync(file, 'utf8');
+ const doc = new DOMParser().parseFromString(xmlContent);
+ const nodes = xpath.select("/libraries/library/location/text()", doc);
+
+ nodes.forEach(function(node) {
+ let lib = path.posix.join(dirname, node.toString());
+ addLibToList(lib);
+ });
+ });
+
+ return libs;
+
+};
+
/**
* Find the name of the component matching the specified path.
*
module.exports = {
getAmdSrcGlobList,
getComponentFromPath,
+ getComponentPaths,
getOwningComponentDirectory,
getYuiSrcGlobList,
getThirdPartyLibsList,
+ getThirdPartyPaths,
};
--- /dev/null
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright 2021 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+ const files = grunt.moodleEnv.files;
+
+ // Project configuration.
+ grunt.config.merge({
+ eslint: {
+ // Even though warnings dont stop the build we don't display warnings by default because
+ // at this moment we've got too many core warnings.
+ // To display warnings call: grunt eslint --show-lint-warnings
+ // To fail on warnings call: grunt eslint --max-lint-warnings=0
+ // Also --max-lint-warnings=-1 can be used to display warnings but not fail.
+ options: {
+ quiet: (!grunt.option('show-lint-warnings')) && (typeof grunt.option('max-lint-warnings') === 'undefined'),
+ maxWarnings: ((typeof grunt.option('max-lint-warnings') !== 'undefined') ? grunt.option('max-lint-warnings') : -1)
+ },
+
+ // Check AMD src files.
+ amd: {src: files ? files : grunt.moodleEnv.amdSrc},
+
+ // Check YUI module source files.
+ yui: {src: files ? files : grunt.moodleEnv.yuiSrc},
+ },
+ });
+
+ grunt.loadNpmTasks('grunt-eslint');
+
+ // On watch, we dynamically modify config to build only affected files. This
+ // method is slightly complicated to deal with multiple changed files at once (copied
+ // from the grunt-contrib-watch readme).
+ let changedFiles = Object.create(null);
+ const onChange = grunt.util._.debounce(function() {
+ const files = Object.keys(changedFiles);
+ grunt.config('eslint.amd.src', files);
+ grunt.config('eslint.yui.src', files);
+ changedFiles = Object.create(null);
+ }, 200);
+
+ grunt.event.on('watch', (action, filepath) => {
+ changedFiles[filepath] = action;
+ onChange();
+ });
+};
--- /dev/null
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright 2021 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+ /**
+ * Get the list of feature files to pass to the gherkin linter.
+ *
+ * @returns {Array}
+ */
+ const getGherkinLintTargets = () => {
+ if (grunt.moodleEnv.files) {
+ // Specific files were requested. Only check these.
+ return grunt.moodleEnv.files;
+ }
+
+ if (grunt.moodleEnv.inComponent) {
+ return [`${grunt.moodleEnv.runDir}/tests/behat/*.feature`];
+ }
+
+ return ['**/tests/behat/*.feature'];
+ };
+
+ const handler = function() {
+ const done = this.async();
+ const options = grunt.config('gherkinlint.options');
+
+ // Grab the gherkin-lint linter and required scaffolding.
+ const linter = require('gherkin-lint/dist/linter.js');
+ const featureFinder = require('gherkin-lint/dist/feature-finder.js');
+ const configParser = require('gherkin-lint/dist/config-parser.js');
+ const formatter = require('gherkin-lint/dist/formatters/stylish.js');
+
+ // Run the linter.
+ return linter.lint(
+ featureFinder.getFeatureFiles(grunt.file.expand(options.files)),
+ configParser.getConfiguration(configParser.defaultConfigFileName)
+ )
+ .then(results => {
+ // Print the results out uncondtionally.
+ formatter.printResults(results);
+
+ return results;
+ })
+ .then(results => {
+ // Report on the results.
+ // The done function takes a bool whereby a falsey statement causes the task to fail.
+ return results.every(result => result.errors.length === 0);
+ })
+ .then(done); // eslint-disable-line promise/no-callback-in-promise
+ };
+
+ grunt.registerTask('gherkinlint', 'Run gherkinlint against the current directory', handler);
+
+ grunt.config.set('gherkinlint', {
+ options: {
+ files: getGherkinLintTargets(),
+ }
+ });
+
+ grunt.config.merge({
+ watch: {
+ gherkinlint: {
+ files: [grunt.moodleEnv.inComponent ? 'tests/behat/*.feature' : '**/tests/behat/*.feature'],
+ tasks: ['gherkinlint'],
+ },
+ },
+ });
+
+ return handler;
+};
--- /dev/null
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright 2021 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+ /**
+ * Generate ignore files (utilising thirdpartylibs.xml data)
+ */
+ const handler = function() {
+ const path = require('path');
+ const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js'));
+
+ // An array of paths to third party directories.
+ const thirdPartyPaths = ComponentList.getThirdPartyPaths();
+
+ // Generate .eslintignore.
+ const eslintIgnores = [
+ '# Generated by "grunt ignorefiles"',
+ // Do not ignore the .grunt directory.
+ '!/.grunt',
+
+ // Ignore all yui/src meta directories and build directories.
+ '*/**/yui/src/*/meta/',
+ '*/**/build/',
+ ].concat(thirdPartyPaths);
+ grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
+
+ // Generate .stylelintignore.
+ const stylelintIgnores = [
+ '# Generated by "grunt ignorefiles"',
+ '**/yui/build/*',
+ 'theme/boost/style/moodle.css',
+ 'theme/classic/style/moodle.css',
+ ].concat(thirdPartyPaths);
+ grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
+ };
+
+ grunt.registerTask('ignorefiles', 'Generate ignore files for linters', handler);
+
+ return handler;
+};
--- /dev/null
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright 2021 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Function to generate the destination for the uglify task
+ * (e.g. build/file.min.js). This function will be passed to
+ * the rename property of files array when building dynamically:
+ * http://gruntjs.com/configuring-tasks#building-the-files-object-dynamically
+ *
+ * @param {String} destPath the current destination
+ * @param {String} srcPath the matched src path
+ * @return {String} The rewritten destination path.
+ */
+const babelRename = function(destPath, srcPath) {
+ destPath = srcPath.replace('src', 'build');
+ destPath = destPath.replace('.js', '.min.js');
+ return destPath;
+};
+
+module.exports = grunt => {
+ // Load the Shifter tasks.
+ require('./shifter')(grunt);
+
+ // Load ESLint.
+ require('./eslint')(grunt);
+
+ const path = require('path');
+
+ // Register JS tasks.
+ grunt.registerTask('yui', ['eslint:yui', 'shifter']);
+ grunt.registerTask('amd', ['eslint:amd', 'babel']);
+ grunt.registerTask('js', ['amd', 'yui']);
+
+ // Register NPM tasks.
+ grunt.loadNpmTasks('grunt-contrib-uglify');
+ grunt.loadNpmTasks('grunt-contrib-watch');
+
+ // Load the Babel tasks and config.
+ grunt.loadNpmTasks('grunt-babel');
+ grunt.config.merge({
+ babel: {
+ options: {
+ sourceMaps: true,
+ comments: false,
+ plugins: [
+ 'transform-es2015-modules-amd-lazy',
+ 'system-import-transformer',
+ // This plugin modifies the Babel transpiling for "export default"
+ // so that if it's used then only the exported value is returned
+ // by the generated AMD module.
+ //
+ // It also adds the Moodle plugin name to the AMD module definition
+ // so that it can be imported as expected in other modules.
+ path.resolve('.grunt/babel-plugin-add-module-to-define.js'),
+ '@babel/plugin-syntax-dynamic-import',
+ '@babel/plugin-syntax-import-meta',
+ ['@babel/plugin-proposal-class-properties', {'loose': false}],
+ '@babel/plugin-proposal-json-strings'
+ ],
+ presets: [
+ ['minify', {
+ // This minification plugin needs to be disabled because it breaks the
+ // source map generation and causes invalid source maps to be output.
+ simplify: false,
+ builtIns: false
+ }],
+ ['@babel/preset-env', {
+ targets: {
+ browsers: [
+ ">0.25%",
+ "last 2 versions",
+ "not ie <= 10",
+ "not op_mini all",
+ "not Opera > 0",
+ "not dead"
+ ]
+ },
+ modules: false,
+ useBuiltIns: false
+ }]
+ ]
+ },
+ dist: {
+ files: [{
+ expand: true,
+ src: grunt.moodleEnv.files ? grunt.moodleEnv.files : grunt.moodleEnv.amdSrc,
+ rename: babelRename
+ }]
+ }
+ },
+ });
+
+ grunt.config.merge({
+ watch: {
+ amd: {
+ files: grunt.moodleEnv.inComponent
+ ? ['amd/src/*.js', 'amd/src/**/*.js']
+ : ['**/amd/src/**/*.js'],
+ tasks: ['amd']
+ },
+ },
+ });
+
+ // On watch, we dynamically modify config to build only affected files. This
+ // method is slightly complicated to deal with multiple changed files at once (copied
+ // from the grunt-contrib-watch readme).
+ let changedFiles = Object.create(null);
+ const onChange = grunt.util._.debounce(function() {
+ const files = Object.keys(changedFiles);
+ grunt.config('babel.dist.files', [{expand: true, src: files, rename: babelRename}]);
+ changedFiles = Object.create(null);
+ }, 200);
+
+ grunt.event.on('watch', function(action, filepath) {
+ changedFiles[filepath] = action;
+ onChange();
+ });
+
+ return {
+ babelRename,
+ };
+};
--- /dev/null
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright 2021 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+ grunt.loadNpmTasks('grunt-sass');
+
+ grunt.config.merge({
+ sass: {
+ dist: {
+ files: {
+ "theme/boost/style/moodle.css": "theme/boost/scss/preset/default.scss",
+ "theme/classic/style/moodle.css": "theme/classic/scss/classicgrunt.scss"
+ }
+ },
+ options: {
+ implementation: require('node-sass'),
+ includePaths: ["theme/boost/scss/", "theme/classic/scss/"]
+ }
+ },
+ });
+};
--- /dev/null
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright 2021 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/* eslint-env node */
+
+module.exports = grunt => {
+ /**
+ * Shifter task. Is configured with a path to a specific file or a directory,
+ * in the case of a specific file it will work out the right module to be built.
+ *
+ * Note that this task runs the invidiaul shifter jobs async (becase it spawns
+ * so be careful to to call done().
+ */
+ const handler = function() {
+ const done = this.async();
+ const options = grunt.config('shifter.options');
+ const async = require('async');
+ const path = require('path');
+
+ // Run the shifter processes one at a time to avoid confusing output.
+ async.eachSeries(options.paths, function(src, filedone) {
+ var args = [];
+ args.push(path.normalize(process.cwd() + '/node_modules/shifter/bin/shifter'));
+
+ // Always ignore the node_modules directory.
+ args.push('--excludes', 'node_modules');
+
+ // Determine the most appropriate options to run with based upon the current location.
+ if (grunt.file.isMatch('**/yui/**/*.js', src)) {
+ // When passed a JS file, build our containing module (this happen with
+ // watch).
+ grunt.log.debug('Shifter passed a specific JS file');
+ src = path.dirname(path.dirname(src));
+ options.recursive = false;
+ } else if (grunt.file.isMatch('**/yui/src', src)) {
+ // When in a src directory --walk all modules.
+ grunt.log.debug('In a src directory');
+ args.push('--walk');
+ options.recursive = false;
+ } else if (grunt.file.isMatch('**/yui/src/*', src)) {
+ // When in module, only build our module.
+ grunt.log.debug('In a module directory');
+ options.recursive = false;
+ } else if (grunt.file.isMatch('**/yui/src/*/js', src)) {
+ // When in module src, only build our module.
+ grunt.log.debug('In a source directory');
+ src = path.dirname(src);
+ options.recursive = false;
+ }
+
+ if (grunt.option('watch')) {
+ grunt.fail.fatal('The --watch option has been removed, please use `grunt watch` instead');
+ }
+
+ // Add the stderr option if appropriate
+ if (grunt.option('verbose')) {
+ args.push('--lint-stderr');
+ }
+
+ if (grunt.option('no-color')) {
+ args.push('--color=false');
+ }
+
+ var execShifter = function() {
+
+ grunt.log.ok("Running shifter on " + src);
+ grunt.util.spawn({
+ cmd: "node",
+ args: args,
+ opts: {cwd: src, stdio: 'inherit', env: process.env}
+ }, function(error, result, code) {
+ if (code) {
+ grunt.fail.fatal('Shifter failed with code: ' + code);
+ } else {
+ grunt.log.ok('Shifter build complete.');
+ filedone();
+ }
+ });
+ };
+
+ // Actually run shifter.
+ if (!options.recursive) {
+ execShifter();
+ } else {
+ // Check that there are yui modules otherwise shifter ends with exit code 1.
+ if (grunt.file.expand({cwd: src}, '**/yui/src/**/*.js').length > 0) {
+ args.push('--recursive');
+ execShifter();
+ } else {
+ grunt.log.ok('No YUI modules to build.');
+ filedone();
+ }
+ }
+ }, done);
+ };
+
+ // Register the shifter task.
+ grunt.registerTask('shifter', 'Run Shifter against the current directory', handler);
+
+ // Configure it.
+ grunt.config.set('shifter', {
+ options: {
+ recursive: true,
+ // Shifter takes a relative path.
+ paths: grunt.moodleEnv.files ? grunt.moodleEnv.files : [grunt.moodleEnv.runDir]
+ }
+ });
+
+ grunt.config.merge({
+ watch: {
+ yui: {
+ files: grunt.moodleEnv.inComponent
+ ? ['yui/src/*.json', 'yui/src/**/*.js']
+ : ['**/yui/src/**/*.js'],
+ tasks: ['yui']
+ },
+ },
+ });
+
+ // On watch, we dynamically modify config to build only affected files. This
+ // method is slightly complicated to deal with multiple changed files at once (copied
+ // from the grunt-contrib-watch readme).
+ let changedFiles = Object.create(null);
+ const onChange = grunt.util._.debounce(function() {
+ const files = Object.keys(changedFiles);
+ grunt.config('shifter.options.paths', files);
+ changedFiles = Object.create(null);
+ }, 200);
+
+ grunt.event.on('watch', (action, filepath) => {
+ changedFiles[filepath] = action;
+ onChange();
+ });
+
+ return handler;
+};
--- /dev/null
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright 2021 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+ /**
+ * Generate ignore files (utilising thirdpartylibs.xml data)
+ */
+ const handler = function() {
+ const path = require('path');
+
+ // Are we in a YUI directory?
+ if (path.basename(path.resolve(grunt.moodleEnv.cwd, '../../')) == 'yui') {
+ grunt.task.run('yui');
+ // Are we in an AMD directory?
+ } else if (grunt.moodleEnv.inAMD) {
+ grunt.task.run('amd');
+ } else {
+ // Run them all!.
+ grunt.task.run('css');
+ grunt.task.run('js');
+ grunt.task.run('gherkinlint');
+ }
+ };
+
+ // Register the startup task.
+ grunt.registerTask('startup', 'Run the correct tasks for the current directory', handler);
+
+ return handler;
+};
--- /dev/null
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright 2021 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+ // Load the Style Lint tasks.
+ require('./stylelint')(grunt);
+
+ // Load the SASS tasks.
+ require('./sass')(grunt);
+};
--- /dev/null
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright 2021 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+
+ const getCssConfigForFiles = files => {
+ return {
+ stylelint: {
+ css: {
+ // Use a fully-qualified path.
+ src: files,
+ options: {
+ configOverrides: {
+ rules: {
+ // These rules have to be disabled in .stylelintrc for scss compat.
+ "at-rule-no-unknown": true,
+ }
+ }
+ }
+ },
+ },
+ };
+ };
+
+ const getScssConfigForFiles = files => {
+ return {
+ stylelint: {
+ scss: {
+ options: {syntax: 'scss'},
+ src: files,
+ },
+ },
+ };
+ };
+
+ /**
+ * Register any stylelint tasks.
+ *
+ * @param {Object} grunt
+ * @param {Array} files
+ * @param {String} fullRunDir
+ */
+ const registerStyleLintTasks = () => {
+ const glob = require('glob');
+
+ // The stylelinters do not handle the case where a configuration was provided but no files were included.
+ // Keep track of whether any files were found.
+ let hasCss = false;
+ let hasScss = false;
+
+ // The stylelint processors do not take a path argument. They always check all provided values.
+ // As a result we must check through each glob and determine if any files match the current directory.
+ const scssFiles = [];
+ const cssFiles = [];
+
+ const requestedFiles = grunt.moodleEnv.files;
+ if (requestedFiles) {
+ // Grunt was called with a files argument.
+ // Check whether each of the requested files matches either the CSS or SCSS source file list.
+
+ requestedFiles.forEach(changedFilePath => {
+ let matchesGlob;
+
+ // Check whether this watched path matches any watched SCSS file.
+ matchesGlob = grunt.moodleEnv.scssSrc.some(watchedPathGlob => {
+ return glob.sync(watchedPathGlob).indexOf(changedFilePath) !== -1;
+ });
+ if (matchesGlob) {
+ scssFiles.push(changedFilePath);
+ hasScss = true;
+ }
+
+ // Check whether this watched path matches any watched CSS file.
+ matchesGlob = grunt.moodleEnv.cssSrc.some(watchedPathGlob => {
+ return glob.sync(watchedPathGlob).indexOf(changedFilePath) !== -1;
+ });
+ if (matchesGlob) {
+ cssFiles.push(changedFilePath);
+ hasCss = true;
+ }
+ });
+ } else {
+ // Grunt was called without a list of files.
+ // The start directory (runDir) may be a child dir of the project.
+ // Check each scssSrc file to see if it's in the start directory.
+ // This means that we can lint just mod/*/styles.css if started in the mod directory.
+
+ grunt.moodleEnv.scssSrc.forEach(path => {
+ if (path.startsWith(grunt.moodleEnv.runDir)) {
+ scssFiles.push(path);
+ hasScss = true;
+ }
+ });
+
+ grunt.moodleEnv.cssSrc.forEach(path => {
+ if (path.startsWith(grunt.moodleEnv.runDir)) {
+ cssFiles.push(path);
+ hasCss = true;
+ }
+ });
+ }
+
+ // Register the tasks.
+ const scssTasks = ['sass'];
+ if (hasScss) {
+ grunt.config.merge(getScssConfigForFiles(scssFiles));
+ scssTasks.unshift('stylelint:scss');
+ }
+
+ const cssTasks = [];
+ if (hasCss) {
+ grunt.config.merge(getCssConfigForFiles(cssFiles));
+ cssTasks.push('stylelint:css');
+ }
+
+ // The tasks must be registered, even if empty to ensure a consistent command list.
+ // They jsut won't run anything.
+ grunt.registerTask('scss', scssTasks);
+ grunt.registerTask('rawcss', cssTasks);
+ };
+
+ // Register CSS tasks.
+ grunt.loadNpmTasks('grunt-stylelint');
+
+ // Register the style lint tasks.
+ registerStyleLintTasks();
+ grunt.registerTask('css', ['scss', 'rawcss']);
+
+ const getCoreThemeMatches = () => {
+ const scssMatch = 'scss/**/*.scss';
+
+ if (grunt.moodleEnv.inTheme) {
+ return [scssMatch];
+ }
+
+ if (grunt.moodleEnv.runDir.startsWith('theme')) {
+ return [`*/${scssMatch}`];
+ }
+
+ return [`theme/*/${scssMatch}`];
+ };
+
+ // Add the watch configuration for rawcss, and scss.
+ grunt.config.merge({
+ watch: {
+ rawcss: {
+ files: [
+ '**/*.css',
+ ],
+ excludes: [
+ '**/moodle.css',
+ '**/editor.css',
+ ],
+ tasks: ['rawcss']
+ },
+ scss: {
+ files: getCoreThemeMatches(),
+ tasks: ['scss']
+ },
+ },
+ });
+};
--- /dev/null
+/**
+ * This is a wrapper task to handle the grunt watch command. It attempts to use
+ * Watchman to monitor for file changes, if it's installed, because it's much faster.
+ *
+ * If Watchman isn't installed then it falls back to the grunt-contrib-watch file
+ * watcher for backwards compatibility.
+ */
+
+/* eslint-env node */
+
+module.exports = grunt => {
+ /**
+ * This is a wrapper task to handle the grunt watch command. It attempts to use
+ * Watchman to monitor for file changes, if it's installed, because it's much faster.
+ *
+ * If Watchman isn't installed then it falls back to the grunt-contrib-watch file
+ * watcher for backwards compatibility.
+ */
+ const watchHandler = function() {
+ const async = require('async');
+ const watchTaskDone = this.async();
+ let watchInitialised = false;
+ let watchTaskQueue = {};
+ let processingQueue = false;
+
+ const watchman = require('fb-watchman');
+ const watchmanClient = new watchman.Client();
+
+ // Grab the tasks and files that have been queued up and execute them.
+ var processWatchTaskQueue = function() {
+ if (!Object.keys(watchTaskQueue).length || processingQueue) {
+ // If there is nothing in the queue or we're already processing then wait.
+ return;
+ }
+
+ processingQueue = true;
+
+ // Grab all tasks currently in the queue.
+ var queueToProcess = watchTaskQueue;
+ // Reset the queue.
+ watchTaskQueue = {};
+
+ async.forEachSeries(
+ Object.keys(queueToProcess),
+ function(task, next) {
+ var files = queueToProcess[task];
+ var filesOption = '--files=' + files.join(',');
+ grunt.log.ok('Running task ' + task + ' for files ' + filesOption);
+
+ // Spawn the task in a child process so that it doesn't kill this one
+ // if it failed.
+ grunt.util.spawn(
+ {
+ // Spawn with the grunt bin.
+ grunt: true,
+ // Run from current working dir and inherit stdio from process.
+ opts: {
+ cwd: grunt.moodleEnv.fullRunDir,
+ stdio: 'inherit'
+ },
+ args: [task, filesOption]
+ },
+ function(err, res, code) {
+ if (code !== 0) {
+ // The grunt task failed.
+ grunt.log.error(err);
+ }
+
+ // Move on to the next task.
+ next();
+ }
+ );
+ },
+ function() {
+ // No longer processing.
+ processingQueue = false;
+ // Once all of the tasks are done then recurse just in case more tasks
+ // were queued while we were processing.
+ processWatchTaskQueue();
+ }
+ );
+ };
+
+ const originalWatchConfig = grunt.config.get(['watch']);
+ const watchConfig = Object.keys(originalWatchConfig).reduce(function(carry, key) {
+ if (key == 'options') {
+ return carry;
+ }
+
+ const value = originalWatchConfig[key];
+
+ const taskNames = value.tasks;
+ const files = value.files;
+ let excludes = [];
+ if (value.excludes) {
+ excludes = value.excludes;
+ }
+
+ taskNames.forEach(function(taskName) {
+ carry[taskName] = {
+ files,
+ excludes,
+ };
+ });
+
+ return carry;
+ }, {});
+
+ watchmanClient.on('error', function(error) {
+ // We have to add an error handler here and parse the error string because the
+ // example way from the docs to check if Watchman is installed doesn't actually work!!
+ // See: https://github.com/facebook/watchman/issues/509
+ if (error.message.match('Watchman was not found')) {
+ // If watchman isn't installed then we should fallback to the other watch task.
+ grunt.log.ok('It is recommended that you install Watchman for better performance using the "watch" command.');
+
+ // Fallback to the old grunt-contrib-watch task.
+ grunt.renameTask('watch-grunt', 'watch');
+ grunt.task.run(['watch']);
+ // This task is finished.
+ watchTaskDone(0);
+ } else {
+ grunt.log.error(error);
+ // Fatal error.
+ watchTaskDone(1);
+ }
+ });
+
+ watchmanClient.on('subscription', function(resp) {
+ if (resp.subscription !== 'grunt-watch') {
+ return;
+ }
+
+ resp.files.forEach(function(file) {
+ grunt.log.ok('File changed: ' + file.name);
+
+ var fullPath = grunt.moodleEnv.fullRunDir + '/' + file.name;
+ Object.keys(watchConfig).forEach(function(task) {
+
+ const fileGlobs = watchConfig[task].files;
+ var match = fileGlobs.some(function(fileGlob) {
+ return grunt.file.isMatch(`**/${fileGlob}`, fullPath);
+ });
+
+ if (match) {
+ // If we are watching a subdirectory then the file.name will be relative
+ // to that directory. However the grunt tasks expect the file paths to be
+ // relative to the Gruntfile.js location so let's normalise them before
+ // adding them to the queue.
+ var relativePath = fullPath.replace(grunt.moodleEnv.gruntFilePath + '/', '');
+ if (task in watchTaskQueue) {
+ if (!watchTaskQueue[task].includes(relativePath)) {
+ watchTaskQueue[task] = watchTaskQueue[task].concat(relativePath);
+ }
+ } else {
+ watchTaskQueue[task] = [relativePath];
+ }
+ }
+ });
+ });
+
+ processWatchTaskQueue();
+ });
+
+ process.on('SIGINT', function() {
+ // Let the user know that they may need to manually stop the Watchman daemon if they
+ // no longer want it running.
+ if (watchInitialised) {
+ grunt.log.ok('The Watchman daemon may still be running and may need to be stopped manually.');
+ }
+
+ process.exit();
+ });
+
+ // Initiate the watch on the current directory.
+ watchmanClient.command(['watch-project', grunt.moodleEnv.fullRunDir], function(watchError, watchResponse) {
+ if (watchError) {
+ grunt.log.error('Error initiating watch:', watchError);
+ watchTaskDone(1);
+ return;
+ }
+
+ if ('warning' in watchResponse) {
+ grunt.log.error('warning: ', watchResponse.warning);
+ }
+
+ var watch = watchResponse.watch;
+ var relativePath = watchResponse.relative_path;
+ watchInitialised = true;
+
+ watchmanClient.command(['clock', watch], function(clockError, clockResponse) {
+ if (clockError) {
+ grunt.log.error('Failed to query clock:', clockError);
+ watchTaskDone(1);
+ return;
+ }
+
+ // Generate the expression query used by watchman.
+ // Documentation is limited, but see https://facebook.github.io/watchman/docs/expr/allof.html for examples.
+ // We generate an expression to match any value in the files list of all of our tasks, but excluding
+ // all value in the excludes list of that task.
+ //
+ // [anyof, [
+ // [allof, [
+ // [anyof, [
+ // ['match', validPath, 'wholename'],
+ // ['match', validPath, 'wholename'],
+ // ],
+ // [not,
+ // [anyof, [
+ // ['match', invalidPath, 'wholename'],
+ // ['match', invalidPath, 'wholename'],
+ // ],
+ // ],
+ // ],
+ var matchWholeName = fileGlob => ['match', fileGlob, 'wholename'];
+ var matches = Object.keys(watchConfig).map(function(task) {
+ const matchAll = [];
+ matchAll.push(['anyof'].concat(watchConfig[task].files.map(matchWholeName)));
+
+ if (watchConfig[task].excludes.length) {
+ matchAll.push(['not', ['anyof'].concat(watchConfig[task].excludes.map(matchWholeName))]);
+ }
+
+ return ['allof'].concat(matchAll);
+ });
+
+ matches = ['anyof'].concat(matches);
+
+ var sub = {
+ expression: matches,
+ // Which fields we're interested in.
+ fields: ["name", "size", "type"],
+ // Add our time constraint.
+ since: clockResponse.clock
+ };
+
+ if (relativePath) {
+ /* eslint-disable camelcase */
+ sub.relative_root = relativePath;
+ }
+
+ watchmanClient.command(['subscribe', watch, 'grunt-watch', sub], function(subscribeError) {
+ if (subscribeError) {
+ // Probably an error in the subscription criteria.
+ grunt.log.error('failed to subscribe: ', subscribeError);
+ watchTaskDone(1);
+ return;
+ }
+
+ grunt.log.ok('Listening for changes to files in ' + grunt.moodleEnv.fullRunDir);
+ });
+ });
+ });
+ };
+
+ // Rename the grunt-contrib-watch "watch" task because we're going to wrap it.
+ grunt.renameTask('watch', 'watch-grunt');
+
+ // Register the new watch handler.
+ grunt.registerTask('watch', 'Run tasks on file changes', watchHandler);
+
+ grunt.config.merge({
+ watch: {
+ options: {
+ nospawn: true // We need not to spawn so config can be changed dynamically.
+ },
+ },
+ });
+
+ return watchHandler;
+};
/* eslint-env node */
/**
+ * Grunt configuration for Moodle.
+ *
* @copyright 2014 Andrew Nicols
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-/* eslint-env node */
-
/**
- * Calculate the cwd, taking into consideration the `root` option (for Windows).
+ * Setup the Grunt Moodle environment.
*
- * @param {Object} grunt
- * @returns {String} The current directory as best we can determine
+ * @param {Grunt} grunt
+ * @returns {Object}
*/
-const getCwd = grunt => {
+const setupMoodleEnvironment = grunt => {
const fs = require('fs');
const path = require('path');
+ const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js'));
- let cwd = fs.realpathSync(process.env.PWD || process.cwd());
+ const getAmdConfiguration = () => {
+ // If the cwd is the amd directory in the current component then it will be empty.
+ // If the cwd is a child of the component's AMD directory, the relative directory will not start with ..
+ let inAMD = !path.relative(`${componentDirectory}/amd`, cwd).startsWith('..');
- // Windows users can't run grunt in a subdirectory, so allow them to set
- // the root by passing --root=path/to/dir.
- if (grunt.option('root')) {
- const root = grunt.option('root');
- if (grunt.file.exists(__dirname, root)) {
- cwd = fs.realpathSync(path.join(__dirname, root));
- grunt.log.ok('Setting root to ' + cwd);
+ // Globbing pattern for matching all AMD JS source files.
+ let amdSrc = [];
+ if (inComponent) {
+ amdSrc.push(
+ componentDirectory + "/amd/src/*.js",
+ componentDirectory + "/amd/src/**/*.js"
+ );
} else {
- grunt.fail.fatal('Setting root to ' + root + ' failed - path does not exist');
+ amdSrc = ComponentList.getAmdSrcGlobList();
}
- }
- return cwd;
-};
-
-/**
- * Register any stylelint tasks.
- *
- * @param {Object} grunt
- * @param {Array} files
- * @param {String} fullRunDir
- */
-const registerStyleLintTasks = (grunt, files, fullRunDir) => {
- const getCssConfigForFiles = files => {
return {
- stylelint: {
- css: {
- // Use a fully-qualified path.
- src: files,
- options: {
- configOverrides: {
- rules: {
- // These rules have to be disabled in .stylelintrc for scss compat.
- "at-rule-no-unknown": true,
- }
- }
- }
- },
- },
+ inAMD,
+ amdSrc,
};
};
- const getScssConfigForFiles = files => {
+ const getYuiConfiguration = () => {
+ let yuiSrc = [];
+ if (inComponent) {
+ yuiSrc.push(componentDirectory + "/yui/src/**/*.js");
+ } else {
+ yuiSrc = ComponentList.getYuiSrcGlobList(gruntFilePath + '/');
+ }
+
return {
- stylelint: {
- scss: {
- options: {syntax: 'scss'},
- src: files,
- },
- },
+ yuiSrc,
};
};
- let hasCss = true;
- let hasScss = true;
-
- if (files) {
- // Specific files were passed. Just set them up.
- grunt.config.merge(getCssConfigForFiles(files));
- grunt.config.merge(getScssConfigForFiles(files));
- } else {
- // The stylelint system does not handle the case where there was no file to lint.
- // Check whether there are any files to lint in the current directory.
- const glob = require('glob');
-
- const scssSrc = [];
- glob.sync(`${fullRunDir}/**/*.scss`).forEach(path => scssSrc.push(path));
+ const getStyleConfiguration = () => {
+ const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js'));
+ // Build the cssSrc and scssSrc.
+ // Valid paths are:
+ // [component]/styles.css; and either
+ // [theme/[themename]]/scss/**/*.scss; or
+ // [theme/[themename]]/style/*.css.
+ //
+ // If a theme has scss, then it is assumed that the style directory contains generated content.
+ let cssSrc = [];
+ let scssSrc = [];
+
+ const checkComponentDirectory = componentDirectory => {
+ const isTheme = componentDirectory.startsWith('theme/');
+ if (isTheme) {
+ const scssDirectory = `${componentDirectory}/scss`;
+
+ if (fs.existsSync(scssDirectory)) {
+ // This theme has an SCSS directory.
+ // Include all scss files within it recursively, but do not check for css files.
+ scssSrc.push(`${scssDirectory}/*.scss`);
+ scssSrc.push(`${scssDirectory}/**/*.scss`);
+ } else {
+ // This theme has no SCSS directory.
+ // Only hte CSS files in the top-level directory are checked.
+ cssSrc.push(`${componentDirectory}/style/*.css`);
+ }
+ } else {
+ // This is not a theme.
+ // All other plugin types are restricted to a single styles.css in their top level.
+ cssSrc.push(`${componentDirectory}/styles.css`);
+ }
+ };
- if (scssSrc.length) {
- grunt.config.merge(getScssConfigForFiles(scssSrc));
+ if (inComponent) {
+ checkComponentDirectory(componentDirectory);
} else {
- hasScss = false;
+ ComponentList.getComponentPaths(`${gruntFilePath}/`).forEach(componentPath => {
+ checkComponentDirectory(componentPath);
+ });
}
- const cssSrc = [];
- glob.sync(`${fullRunDir}/**/*.css`).forEach(path => cssSrc.push(path));
+ return {
+ cssSrc,
+ scssSrc,
+ };
+ };
- if (cssSrc.length) {
- grunt.config.merge(getCssConfigForFiles(cssSrc));
- } else {
- hasCss = false;
+ /**
+ * Calculate the cwd, taking into consideration the `root` option (for Windows).
+ *
+ * @param {Object} grunt
+ * @returns {String} The current directory as best we can determine
+ */
+ const getCwd = grunt => {
+ let cwd = fs.realpathSync(process.env.PWD || process.cwd());
+
+ // Windows users can't run grunt in a subdirectory, so allow them to set
+ // the root by passing --root=path/to/dir.
+ if (grunt.option('root')) {
+ const root = grunt.option('root');
+ if (grunt.file.exists(__dirname, root)) {
+ cwd = fs.realpathSync(path.join(__dirname, root));
+ grunt.log.ok('Setting root to ' + cwd);
+ } else {
+ grunt.fail.fatal('Setting root to ' + root + ' failed - path does not exist');
+ }
}
- }
-
- const scssTasks = ['sass'];
- if (hasScss) {
- scssTasks.unshift('stylelint:scss');
- }
- grunt.registerTask('scss', scssTasks);
-
- const cssTasks = [];
- if (hasCss) {
- cssTasks.push('stylelint:css');
- }
- grunt.registerTask('rawcss', cssTasks);
-
- grunt.registerTask('css', ['scss', 'rawcss']);
-};
-
-/**
- * Grunt configuration.
- *
- * @param {Object} grunt
- */
-module.exports = function(grunt) {
- const path = require('path');
- const tasks = {};
- const async = require('async');
- const DOMParser = require('xmldom').DOMParser;
- const xpath = require('xpath');
- const semver = require('semver');
- const watchman = require('fb-watchman');
- const watchmanClient = new watchman.Client();
- const fs = require('fs');
- const ComponentList = require(path.resolve('GruntfileComponents.js'));
- const sass = require('node-sass');
- // Verify the node version is new enough.
- var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
- var actual = semver.valid(process.version);
- if (!semver.satisfies(actual, expected)) {
- grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual);
- }
+ return cwd;
+ };
// Detect directories:
// * gruntFilePath The real path on disk to this Gruntfile.js
const relativeCwd = path.relative(gruntFilePath, cwd);
const componentDirectory = ComponentList.getOwningComponentDirectory(relativeCwd);
const inComponent = !!componentDirectory;
+ const inTheme = !!componentDirectory && componentDirectory.startsWith('theme/');
const runDir = inComponent ? componentDirectory : relativeCwd;
const fullRunDir = fs.realpathSync(gruntFilePath + path.sep + runDir);
+ const {inAMD, amdSrc} = getAmdConfiguration();
+ const {yuiSrc} = getYuiConfiguration();
+ const {cssSrc, scssSrc} = getStyleConfiguration();
+
+ let files = null;
+ if (grunt.option('files')) {
+ // Accept a comma separated list of files to process.
+ files = grunt.option('files').split(',');
+ }
+
grunt.log.debug('============================================================================');
grunt.log.debug(`= Node version: ${process.versions.node}`);
grunt.log.debug(`= grunt version: ${grunt.package.version}`);
grunt.log.ok(`Running tasks for component directory ${componentDirectory}`);
}
- let files = null;
- if (grunt.option('files')) {
- // Accept a comma separated list of files to process.
- files = grunt.option('files').split(',');
- }
-
- // If the cwd is the amd directory in the current component then it will be empty.
- // If the cwd is a child of the component's AMD directory, the relative directory will not start with ..
- const inAMD = !path.relative(`${componentDirectory}/amd`, cwd).startsWith('..');
-
- // Globbing pattern for matching all AMD JS source files.
- let amdSrc = [];
- if (inComponent) {
- amdSrc.push(componentDirectory + "/amd/src/*.js");
- amdSrc.push(componentDirectory + "/amd/src/**/*.js");
- } else {
- amdSrc = ComponentList.getAmdSrcGlobList();
- }
-
- let yuiSrc = [];
- if (inComponent) {
- yuiSrc.push(componentDirectory + "/yui/src/**/*.js");
- } else {
- yuiSrc = ComponentList.getYuiSrcGlobList(gruntFilePath + '/');
- }
-
- /**
- * Function to generate the destination for the uglify task
- * (e.g. build/file.min.js). This function will be passed to
- * the rename property of files array when building dynamically:
- * http://gruntjs.com/configuring-tasks#building-the-files-object-dynamically
- *
- * @param {String} destPath the current destination
- * @param {String} srcPath the matched src path
- * @return {String} The rewritten destination path.
- */
- var babelRename = function(destPath, srcPath) {
- destPath = srcPath.replace('src', 'build');
- destPath = destPath.replace('.js', '.min.js');
- return destPath;
+ return {
+ amdSrc,
+ componentDirectory,
+ cwd,
+ cssSrc,
+ files,
+ fullRunDir,
+ gruntFilePath,
+ inAMD,
+ inComponent,
+ inTheme,
+ relativeCwd,
+ runDir,
+ scssSrc,
+ yuiSrc,
};
+};
- /**
- * Find thirdpartylibs.xml and generate an array of paths contained within
- * them (used to generate ignore files and so on).
- *
- * @return {array} The list of thirdparty paths.
- */
- var getThirdPartyPathsFromXML = function() {
- const thirdpartyfiles = ComponentList.getThirdPartyLibsList(gruntFilePath + '/');
- const libs = ['node_modules/', 'vendor/'];
-
- thirdpartyfiles.forEach(function(file) {
- const dirname = path.dirname(file);
-
- const doc = new DOMParser().parseFromString(grunt.file.read(file));
- const nodes = xpath.select("/libraries/library/location/text()", doc);
-
- nodes.forEach(function(node) {
- let lib = path.posix.join(dirname, node.toString());
- if (grunt.file.isDir(lib)) {
- // Ensure trailing slash on dirs.
- lib = lib.replace(/\/?$/, '/');
- }
-
- // Look for duplicate paths before adding to array.
- if (libs.indexOf(lib) === -1) {
- libs.push(lib);
- }
- });
- });
-
- return libs;
- };
-
- /**
- * Get the list of feature files to pass to the gherkin linter.
- *
- * @returns {Array}
- */
- const getGherkinLintTargets = () => {
- if (files) {
- // Specific files were requested. Only check these.
- return files;
- }
-
- if (inComponent) {
- return [`${runDir}/tests/behat/*.feature`];
- }
+/**
+ * Verify tha tthe current NodeJS version matches the required version in package.json.
+ *
+ * @param {Grunt} grunt
+ */
+const verifyNodeVersion = grunt => {
+ const semver = require('semver');
- return ['**/tests/behat/*.feature'];
- };
+ // Verify the node version is new enough.
+ var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
+ var actual = semver.valid(process.version);
+ if (!semver.satisfies(actual, expected)) {
+ grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual);
+ }
+};
- // Project configuration.
- grunt.initConfig({
- eslint: {
- // Even though warnings dont stop the build we don't display warnings by default because
- // at this moment we've got too many core warnings.
- // To display warnings call: grunt eslint --show-lint-warnings
- // To fail on warnings call: grunt eslint --max-lint-warnings=0
- // Also --max-lint-warnings=-1 can be used to display warnings but not fail.
- options: {
- quiet: (!grunt.option('show-lint-warnings')) && (typeof grunt.option('max-lint-warnings') === 'undefined'),
- maxWarnings: ((typeof grunt.option('max-lint-warnings') !== 'undefined') ? grunt.option('max-lint-warnings') : -1)
- },
- amd: {src: files ? files : amdSrc},
- // Check YUI module source files.
- yui: {src: files ? files : yuiSrc},
- },
- babel: {
- options: {
- sourceMaps: true,
- comments: false,
- plugins: [
- 'transform-es2015-modules-amd-lazy',
- 'system-import-transformer',
- // This plugin modifies the Babel transpiling for "export default"
- // so that if it's used then only the exported value is returned
- // by the generated AMD module.
- //
- // It also adds the Moodle plugin name to the AMD module definition
- // so that it can be imported as expected in other modules.
- path.resolve('babel-plugin-add-module-to-define.js'),
- '@babel/plugin-syntax-dynamic-import',
- '@babel/plugin-syntax-import-meta',
- ['@babel/plugin-proposal-class-properties', {'loose': false}],
- '@babel/plugin-proposal-json-strings'
- ],
- presets: [
- ['minify', {
- // This minification plugin needs to be disabled because it breaks the
- // source map generation and causes invalid source maps to be output.
- simplify: false,
- builtIns: false
- }],
- ['@babel/preset-env', {
- targets: {
- browsers: [
- ">0.25%",
- "last 2 versions",
- "not ie <= 10",
- "not op_mini all",
- "not Opera > 0",
- "not dead"
- ]
- },
- modules: false,
- useBuiltIns: false
- }]
- ]
- },
- dist: {
- files: [{
- expand: true,
- src: files ? files : amdSrc,
- rename: babelRename
- }]
- }
- },
- sass: {
- dist: {
- files: {
- "theme/boost/style/moodle.css": "theme/boost/scss/preset/default.scss",
- "theme/classic/style/moodle.css": "theme/classic/scss/classicgrunt.scss"
- }
- },
- options: {
- implementation: sass,
- includePaths: ["theme/boost/scss/", "theme/classic/scss/"]
- }
- },
- watch: {
- options: {
- nospawn: true // We need not to spawn so config can be changed dynamically.
- },
- amd: {
- files: inComponent
- ? ['amd/src/*.js', 'amd/src/**/*.js']
- : ['**/amd/src/**/*.js'],
- tasks: ['amd']
- },
- boost: {
- files: [inComponent ? 'scss/**/*.scss' : 'theme/boost/scss/**/*.scss'],
- tasks: ['scss']
- },
- rawcss: {
- files: [
- '**/*.css',
- ],
- excludes: [
- '**/moodle.css',
- '**/editor.css',
- ],
- tasks: ['rawcss']
- },
- yui: {
- files: inComponent
- ? ['yui/src/*.json', 'yui/src/**/*.js']
- : ['**/yui/src/**/*.js'],
- tasks: ['yui']
- },
- gherkinlint: {
- files: [inComponent ? 'tests/behat/*.feature' : '**/tests/behat/*.feature'],
- tasks: ['gherkinlint']
- }
- },
- shifter: {
- options: {
- recursive: true,
- // Shifter takes a relative path.
- paths: files ? files : [runDir]
- }
- },
- gherkinlint: {
- options: {
- files: getGherkinLintTargets(),
- }
- },
- });
+/**
+ * Grunt configuration.
+ *
+ * @param {Grunt} grunt
+ */
+module.exports = function(grunt) {
+ // Verify that the Node version meets our requirements.
+ verifyNodeVersion(grunt);
- /**
- * Generate ignore files (utilising thirdpartylibs.xml data)
- */
- tasks.ignorefiles = function() {
- // An array of paths to third party directories.
- const thirdPartyPaths = getThirdPartyPathsFromXML();
- // Generate .eslintignore.
- const eslintIgnores = [
- '# Generated by "grunt ignorefiles"',
- '*/**/yui/src/*/meta/',
- '*/**/build/',
- ].concat(thirdPartyPaths);
- grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
-
- // Generate .stylelintignore.
- const stylelintIgnores = [
- '# Generated by "grunt ignorefiles"',
- '**/yui/build/*',
- 'theme/boost/style/moodle.css',
- 'theme/classic/style/moodle.css',
- ].concat(thirdPartyPaths);
- grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
- };
+ // Setup the Moodle environemnt within the Grunt object.
+ grunt.moodleEnv = setupMoodleEnvironment(grunt);
/**
- * Shifter task. Is configured with a path to a specific file or a directory,
- * in the case of a specific file it will work out the right module to be built.
+ * Add the named task.
*
- * Note that this task runs the invidiaul shifter jobs async (becase it spawns
- * so be careful to to call done().
+ * @param {string} name
+ * @param {Grunt} grunt
*/
- tasks.shifter = function() {
- var done = this.async(),
- options = grunt.config('shifter.options');
-
- // Run the shifter processes one at a time to avoid confusing output.
- async.eachSeries(options.paths, function(src, filedone) {
- var args = [];
- args.push(path.normalize(__dirname + '/node_modules/shifter/bin/shifter'));
-
- // Always ignore the node_modules directory.
- args.push('--excludes', 'node_modules');
-
- // Determine the most appropriate options to run with based upon the current location.
- if (grunt.file.isMatch('**/yui/**/*.js', src)) {
- // When passed a JS file, build our containing module (this happen with
- // watch).
- grunt.log.debug('Shifter passed a specific JS file');
- src = path.dirname(path.dirname(src));
- options.recursive = false;
- } else if (grunt.file.isMatch('**/yui/src', src)) {
- // When in a src directory --walk all modules.
- grunt.log.debug('In a src directory');
- args.push('--walk');
- options.recursive = false;
- } else if (grunt.file.isMatch('**/yui/src/*', src)) {
- // When in module, only build our module.
- grunt.log.debug('In a module directory');
- options.recursive = false;
- } else if (grunt.file.isMatch('**/yui/src/*/js', src)) {
- // When in module src, only build our module.
- grunt.log.debug('In a source directory');
- src = path.dirname(src);
- options.recursive = false;
- }
+ const addTask = (name, grunt) => {
+ const path = require('path');
+ const taskPath = path.resolve(`./.grunt/tasks/${name}.js`);
- if (grunt.option('watch')) {
- grunt.fail.fatal('The --watch option has been removed, please use `grunt watch` instead');
- }
-
- // Add the stderr option if appropriate
- if (grunt.option('verbose')) {
- args.push('--lint-stderr');
- }
+ grunt.log.debug(`Including tasks for ${name} from ${taskPath}`);
- if (grunt.option('no-color')) {
- args.push('--color=false');
- }
-
- var execShifter = function() {
-
- grunt.log.ok("Running shifter on " + src);
- grunt.util.spawn({
- cmd: "node",
- args: args,
- opts: {cwd: src, stdio: 'inherit', env: process.env}
- }, function(error, result, code) {
- if (code) {
- grunt.fail.fatal('Shifter failed with code: ' + code);
- } else {
- grunt.log.ok('Shifter build complete.');
- filedone();
- }
- });
- };
-
- // Actually run shifter.
- if (!options.recursive) {
- execShifter();
- } else {
- // Check that there are yui modules otherwise shifter ends with exit code 1.
- if (grunt.file.expand({cwd: src}, '**/yui/src/**/*.js').length > 0) {
- args.push('--recursive');
- execShifter();
- } else {
- grunt.log.ok('No YUI modules to build.');
- filedone();
- }
- }
- }, done);
- };
-
- tasks.gherkinlint = function() {
- const done = this.async();
- const options = grunt.config('gherkinlint.options');
-
- // Grab the gherkin-lint linter and required scaffolding.
- const linter = require('gherkin-lint/dist/linter.js');
- const featureFinder = require('gherkin-lint/dist/feature-finder.js');
- const configParser = require('gherkin-lint/dist/config-parser.js');
- const formatter = require('gherkin-lint/dist/formatters/stylish.js');
-
- // Run the linter.
- return linter.lint(
- featureFinder.getFeatureFiles(grunt.file.expand(options.files)),
- configParser.getConfiguration(configParser.defaultConfigFileName)
- )
- .then(results => {
- // Print the results out uncondtionally.
- formatter.printResults(results);
-
- return results;
- })
- .then(results => {
- // Report on the results.
- // The done function takes a bool whereby a falsey statement causes the task to fail.
- return results.every(result => result.errors.length === 0);
- })
- .then(done); // eslint-disable-line promise/no-callback-in-promise
+ require(path.resolve(`./.grunt/tasks/${name}.js`))(grunt);
};
- tasks.startup = function() {
- // Are we in a YUI directory?
- if (path.basename(path.resolve(cwd, '../../')) == 'yui') {
- grunt.task.run('yui');
- // Are we in an AMD directory?
- } else if (inAMD) {
- grunt.task.run('amd');
- } else {
- // Run them all!.
- grunt.task.run('css');
- grunt.task.run('js');
- grunt.task.run('gherkinlint');
- }
- };
-
- /**
- * This is a wrapper task to handle the grunt watch command. It attempts to use
- * Watchman to monitor for file changes, if it's installed, because it's much faster.
- *
- * If Watchman isn't installed then it falls back to the grunt-contrib-watch file
- * watcher for backwards compatibility.
- */
- tasks.watch = function() {
- var watchTaskDone = this.async();
- var watchInitialised = false;
- var watchTaskQueue = {};
- var processingQueue = false;
-
- // Grab the tasks and files that have been queued up and execute them.
- var processWatchTaskQueue = function() {
- if (!Object.keys(watchTaskQueue).length || processingQueue) {
- // If there is nothing in the queue or we're already processing then wait.
- return;
- }
-
- processingQueue = true;
-
- // Grab all tasks currently in the queue.
- var queueToProcess = watchTaskQueue;
- // Reset the queue.
- watchTaskQueue = {};
-
- async.forEachSeries(
- Object.keys(queueToProcess),
- function(task, next) {
- var files = queueToProcess[task];
- var filesOption = '--files=' + files.join(',');
- grunt.log.ok('Running task ' + task + ' for files ' + filesOption);
-
- // Spawn the task in a child process so that it doesn't kill this one
- // if it failed.
- grunt.util.spawn(
- {
- // Spawn with the grunt bin.
- grunt: true,
- // Run from current working dir and inherit stdio from process.
- opts: {
- cwd: fullRunDir,
- stdio: 'inherit'
- },
- args: [task, filesOption]
- },
- function(err, res, code) {
- if (code !== 0) {
- // The grunt task failed.
- grunt.log.error(err);
- }
-
- // Move on to the next task.
- next();
- }
- );
- },
- function() {
- // No longer processing.
- processingQueue = false;
- // Once all of the tasks are done then recurse just in case more tasks
- // were queued while we were processing.
- processWatchTaskQueue();
- }
- );
- };
-
- const originalWatchConfig = grunt.config.get(['watch']);
- const watchConfig = Object.keys(originalWatchConfig).reduce(function(carry, key) {
- if (key == 'options') {
- return carry;
- }
-
- const value = originalWatchConfig[key];
-
- const taskNames = value.tasks;
- const files = value.files;
- let excludes = [];
- if (value.excludes) {
- excludes = value.excludes;
- }
-
- taskNames.forEach(function(taskName) {
- carry[taskName] = {
- files,
- excludes,
- };
- });
-
- return carry;
- }, {});
-
- watchmanClient.on('error', function(error) {
- // We have to add an error handler here and parse the error string because the
- // example way from the docs to check if Watchman is installed doesn't actually work!!
- // See: https://github.com/facebook/watchman/issues/509
- if (error.message.match('Watchman was not found')) {
- // If watchman isn't installed then we should fallback to the other watch task.
- grunt.log.ok('It is recommended that you install Watchman for better performance using the "watch" command.');
-
- // Fallback to the old grunt-contrib-watch task.
- grunt.renameTask('watch-grunt', 'watch');
- grunt.task.run(['watch']);
- // This task is finished.
- watchTaskDone(0);
- } else {
- grunt.log.error(error);
- // Fatal error.
- watchTaskDone(1);
- }
- });
-
- watchmanClient.on('subscription', function(resp) {
- if (resp.subscription !== 'grunt-watch') {
- return;
- }
-
- resp.files.forEach(function(file) {
- grunt.log.ok('File changed: ' + file.name);
-
- var fullPath = fullRunDir + '/' + file.name;
- Object.keys(watchConfig).forEach(function(task) {
-
- const fileGlobs = watchConfig[task].files;
- var match = fileGlobs.some(function(fileGlob) {
- return grunt.file.isMatch(`**/${fileGlob}`, fullPath);
- });
-
- if (match) {
- // If we are watching a subdirectory then the file.name will be relative
- // to that directory. However the grunt tasks expect the file paths to be
- // relative to the Gruntfile.js location so let's normalise them before
- // adding them to the queue.
- var relativePath = fullPath.replace(gruntFilePath + '/', '');
- if (task in watchTaskQueue) {
- if (!watchTaskQueue[task].includes(relativePath)) {
- watchTaskQueue[task] = watchTaskQueue[task].concat(relativePath);
- }
- } else {
- watchTaskQueue[task] = [relativePath];
- }
- }
- });
- });
-
- processWatchTaskQueue();
- });
-
- process.on('SIGINT', function() {
- // Let the user know that they may need to manually stop the Watchman daemon if they
- // no longer want it running.
- if (watchInitialised) {
- grunt.log.ok('The Watchman daemon may still be running and may need to be stopped manually.');
- }
-
- process.exit();
- });
- // Initiate the watch on the current directory.
- watchmanClient.command(['watch-project', fullRunDir], function(watchError, watchResponse) {
- if (watchError) {
- grunt.log.error('Error initiating watch:', watchError);
- watchTaskDone(1);
- return;
- }
-
- if ('warning' in watchResponse) {
- grunt.log.error('warning: ', watchResponse.warning);
- }
-
- var watch = watchResponse.watch;
- var relativePath = watchResponse.relative_path;
- watchInitialised = true;
-
- watchmanClient.command(['clock', watch], function(clockError, clockResponse) {
- if (clockError) {
- grunt.log.error('Failed to query clock:', clockError);
- watchTaskDone(1);
- return;
- }
+ // Add Moodle task configuration.
+ addTask('gherkinlint', grunt);
+ addTask('ignorefiles', grunt);
- // Generate the expression query used by watchman.
- // Documentation is limited, but see https://facebook.github.io/watchman/docs/expr/allof.html for examples.
- // We generate an expression to match any value in the files list of all of our tasks, but excluding
- // all value in the excludes list of that task.
- //
- // [anyof, [
- // [allof, [
- // [anyof, [
- // ['match', validPath, 'wholename'],
- // ['match', validPath, 'wholename'],
- // ],
- // [not,
- // [anyof, [
- // ['match', invalidPath, 'wholename'],
- // ['match', invalidPath, 'wholename'],
- // ],
- // ],
- // ],
- var matchWholeName = fileGlob => ['match', fileGlob, 'wholename'];
- var matches = Object.keys(watchConfig).map(function(task) {
- const matchAll = [];
- matchAll.push(['anyof'].concat(watchConfig[task].files.map(matchWholeName)));
-
- if (watchConfig[task].excludes.length) {
- matchAll.push(['not', ['anyof'].concat(watchConfig[task].excludes.map(matchWholeName))]);
- }
-
- return ['allof'].concat(matchAll);
- });
-
- matches = ['anyof'].concat(matches);
-
- var sub = {
- expression: matches,
- // Which fields we're interested in.
- fields: ["name", "size", "type"],
- // Add our time constraint.
- since: clockResponse.clock
- };
-
- if (relativePath) {
- /* eslint-disable camelcase */
- sub.relative_root = relativePath;
- }
-
- watchmanClient.command(['subscribe', watch, 'grunt-watch', sub], function(subscribeError) {
- if (subscribeError) {
- // Probably an error in the subscription criteria.
- grunt.log.error('failed to subscribe: ', subscribeError);
- watchTaskDone(1);
- return;
- }
-
- grunt.log.ok('Listening for changes to files in ' + fullRunDir);
- });
- });
- });
- };
+ addTask('javascript', grunt);
+ addTask('style', grunt);
- // On watch, we dynamically modify config to build only affected files. This
- // method is slightly complicated to deal with multiple changed files at once (copied
- // from the grunt-contrib-watch readme).
- var changedFiles = Object.create(null);
- var onChange = grunt.util._.debounce(function() {
- var files = Object.keys(changedFiles);
- grunt.config('eslint.amd.src', files);
- grunt.config('eslint.yui.src', files);
- grunt.config('shifter.options.paths', files);
- grunt.config('gherkinlint.options.files', files);
- grunt.config('babel.dist.files', [{expand: true, src: files, rename: babelRename}]);
- changedFiles = Object.create(null);
- }, 200);
-
- grunt.event.on('watch', function(action, filepath) {
- changedFiles[filepath] = action;
- onChange();
- });
-
- // Register NPM tasks.
- grunt.loadNpmTasks('grunt-contrib-uglify');
- grunt.loadNpmTasks('grunt-contrib-watch');
- grunt.loadNpmTasks('grunt-sass');
- grunt.loadNpmTasks('grunt-eslint');
- grunt.loadNpmTasks('grunt-stylelint');
- grunt.loadNpmTasks('grunt-babel');
-
- // Rename the grunt-contrib-watch "watch" task because we're going to wrap it.
- grunt.renameTask('watch', 'watch-grunt');
-
- // Register JS tasks.
- grunt.registerTask('shifter', 'Run Shifter against the current directory', tasks.shifter);
- grunt.registerTask('gherkinlint', 'Run gherkinlint against the current directory', tasks.gherkinlint);
- grunt.registerTask('ignorefiles', 'Generate ignore files for linters', tasks.ignorefiles);
- grunt.registerTask('watch', 'Run tasks on file changes', tasks.watch);
- grunt.registerTask('yui', ['eslint:yui', 'shifter']);
- grunt.registerTask('amd', ['eslint:amd', 'babel']);
- grunt.registerTask('js', ['amd', 'yui']);
-
- // Register CSS tasks.
- registerStyleLintTasks(grunt, files, fullRunDir);
-
- // Register the startup task.
- grunt.registerTask('startup', 'Run the correct tasks for the current directory', tasks.startup);
+ addTask('watch', grunt);
+ addTask('startup', grunt);
// Register the default task.
grunt.registerTask('default', ['startup']);
}
// TODO Does not support custom user profile fields (MDL-70456).
- $userfieldsapi = \core\user_fields::for_identity(\context_system::instance(), false)->with_userpic();
+ $userfieldsapi = \core_user\fields::for_identity(\context_system::instance(), false)->with_userpic();
$userfields = $userfieldsapi->get_sql('u', false, 'user', 'userid2', false)->selects;
$where = '';
foreach ($assignableroles as $roleid => $notused) {
$roleusers = '';
if (0 < $assigncounts[$roleid] && $assigncounts[$roleid] <= MAX_USERS_TO_LIST_PER_ROLE) {
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$userfields = 'u.id, u.username' . $userfieldsapi->get_sql('u')->selects;
$roleusers = get_role_users($roleid, $context, false, $userfields);
if (!empty($roleusers)) {
new lang_string('coursehelpshowgrades'), 1, array(0 => new lang_string('no'), 1 => new lang_string('yes'))));
$temp->add(new admin_setting_configselect('moodlecourse/showreports', new lang_string('showreports'), '', 0,
array(0 => new lang_string('no'), 1 => new lang_string('yes'))));
+ $temp->add(new admin_setting_configselect('moodlecourse/showactivitydates',
+ new lang_string('showactivitydates'),
+ new lang_string('showactivitydates_help'), 1, [
+ 0 => new lang_string('no'),
+ 1 => new lang_string('yes')
+ ]
+ ));
// Files and uploads.
$temp->add(new admin_setting_heading('filesanduploadshdr', new lang_string('filesanduploads'), ''));
$temp->add(new admin_setting_configselect('moodlecourse/enablecompletion', new lang_string('completion', 'completion'),
new lang_string('enablecompletion_help', 'completion'), 1, array(0 => new lang_string('no'), 1 => new lang_string('yes'))));
+ // Display completion conditions.
+ $temp->add(new admin_setting_configselect('moodlecourse/showcompletionconditions',
+ new lang_string('showcompletionconditions', 'completion'),
+ new lang_string('showcompletionconditions_help', 'completion'), 1, [
+ 0 => new lang_string('no'),
+ 1 => new lang_string('yes')
+ ]
+ ));
+
// Groups.
$temp->add(new admin_setting_heading('groups', new lang_string('groups', 'group'), ''));
$choices = array();
--- /dev/null
+@core @core_admin
+Feature: An administrator can browse user accounts
+ In order to find the user accounts I am looking for
+ As an admin
+ I can browse users and see their basic information
+
+ Background:
+ Given the following "custom profile fields" exist:
+ | datatype | shortname | name |
+ | text | frog | Favourite frog |
+ And the following "users" exist:
+ | username | firstname | lastname | email | department | profile_field_frog | firstnamephonetic |
+ | user1 | User | One | one@example.com | Attack | Kermit | Yewzer |
+ | user2 | User | Two | two@example.com | Defence | Tree | Yoozare |
+ And I log in as "admin"
+
+ Scenario: User accounts display default fields
+ When I navigate to "Users > Accounts > Browse list of users" in site administration
+ # Name field always present, email field is default for showidentity.
+ Then the following should exist in the "users" table:
+ | First name / Surname | Email address |
+ | User One | one@example.com |
+ | User Two | two@example.com |
+ # Should not see other identity fields or non-default name fields.
+ And I should not see "Department" in the "table" "css_element"
+ And I should not see "Attack"
+ And I should not see "Favourite frog" in the "table" "css_element"
+ And I should not see "Kermit"
+ And I should not see "First name - phonetic" in the "table" "css_element"
+ And I should not see "Yoozare"
+
+ Scenario: User accounts with extra name fields
+ Given the following config values are set as admin:
+ | alternativefullnameformat | firstnamephonetic lastname |
+ When I navigate to "Users > Accounts > Browse list of users" in site administration
+ Then the following should exist in the "users" table:
+ | First name - phonetic / Surname | Email address |
+ | Yewzer One | one@example.com |
+ | Yoozare Two | two@example.com |
+
+ Scenario: User accounts with specified identity fields
+ Given the following config values are set as admin:
+ | showuseridentity | department,profile_field_frog |
+ When I navigate to "Users > Accounts > Browse list of users" in site administration
+ Then the following should exist in the "users" table:
+ | First name / Surname | Favourite frog | Department |
+ | User One | Kermit | Attack |
+ | User Two | Tree | Defence |
+ And I should not see "Email address" in the "table" "css_element"
+ And I should not see "one@example.com"
--- /dev/null
+@core @core_admin
+Feature: Web service user settings
+ In order to configure authorised users for a web service
+ As an admin
+ I need to use the page that lets you do that
+
+ Background:
+ # Include a custom profile field so we can check it gets displayed
+ Given the following "custom profile fields" exist:
+ | datatype | shortname | name | param2 |
+ | text | frog | Favourite frog | 100 |
+ And the following config values are set as admin:
+ | showuseridentity | email,profile_field_frog |
+ And the following "users" exist:
+ | username | firstname | lastname | email | profile_field_frog |
+ | user1 | User | One | 1@example.org | Kermit |
+ And the following "core_webservice > Service" exists:
+ | name | Silly service |
+ | shortname | silly |
+ | restrictedusers | 1 |
+ | enabled | 1 |
+
+ Scenario: Add a user to a web service
+ When I log in as "admin"
+ And I navigate to "Server > Web services > External services" in site administration
+ And I click on "Authorised users" "link" in the "Silly service" "table_row"
+ And I set the field "Not authorised users" to "User One"
+ And I press "Add"
+ Then I should see "User One" in the ".alloweduserlist" "css_element"
+ And I should see "1@example.org" in the ".alloweduserlist" "css_element"
+ And I should see "Kermit" in the ".alloweduserlist" "css_element"
*/
protected function define_table_columns() {
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = \core\user_fields::get_identity_fields($this->context, false);
+ $extrafields = \core_user\fields::get_identity_fields($this->context, false);
// Define headers and columns.
$cols = array(
// Add extra user fields that we need for the graded user.
// TODO Does not support custom user profile fields (MDL-70456).
- $userfieldsapi = \core\user_fields::for_identity($this->context, false)->with_name();
+ $userfieldsapi = \core_user\fields::for_identity($this->context, false)->with_name();
$fields .= $userfieldsapi->get_sql('u')->selects;
if ($count) {
$dpos = [];
$context = context_system::instance();
foreach ($dporoles as $roleid) {
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$allnames = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$fields = 'u.id, u.confirmed, u.username, '. $allnames . ', ' .
'u.maildisplay, u.mailformat, u.maildigest, u.email, u.emailstop, u.city, '.
self::validate_context($context);
require_capability('tool/dataprivacy:managedatarequests', $context);
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$allusernames = $userfieldsapi->get_sql('', false, '', '', false)->selects;
// Exclude admins and guest user.
$excludedusers = array_keys(get_admins()) + [guest_user()->id];
$fields = 'id,' . $allusernames;
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = \core\user_fields::get_identity_fields($context, false);
+ $extrafields = \core_user\fields::get_identity_fields($context, false);
if (!empty($extrafields)) {
$fields .= ',' . implode(',', $extrafields);
}
global $DB;
// Get users that the user has role assignments to.
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$allusernames = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$sql = "SELECT u.id, $allusernames
FROM {role_assignments} ra, {context} c, {user} u
'valuehtmlcallback' => function($value) {
global $OUTPUT;
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$allusernames = $userfieldsapi->get_sql('', false, '', '', false)->selects;
$fields = 'id, email, ' . $allusernames;
$user = \core_user::get_user($value, $fields);
$this->resetAfterTest();
$this->expectOutputRegex($ouputregex);
- $finder = new tool_httpreplace_url_finder_test();
+ $finder = new tool_httpreplace_url_finder_mock();
$generator = $this->getDataGenerator();
$course = $generator->create_course((object) [
public function test_http_link_stats($content, $domain, $expectedcount) {
$this->resetAfterTest();
- $finder = new tool_httpreplace_url_finder_test();
+ $finder = new tool_httpreplace_url_finder_mock();
$generator = $this->getDataGenerator();
$course = $generator->create_course((object) [
$this->resetAfterTest();
$this->expectOutputRegex('/^$/');
- $finder = new tool_httpreplace_url_finder_test();
+ $finder = new tool_httpreplace_url_finder_mock();
$generator = $this->getDataGenerator();
$course = $generator->create_course((object) [
$CFG->wwwroot = preg_replace('/^https:/', 'http:', $CFG->wwwroot);
$this->expectOutputRegex('/^$/');
- $finder = new tool_httpreplace_url_finder_test();
+ $finder = new tool_httpreplace_url_finder_mock();
$generator = $this->getDataGenerator();
$course = $generator->create_course((object) [
set_config('test_upgrade_http_links', '<img src="http://somesite/someimage.png" />');
- $finder = new tool_httpreplace_url_finder_test();
+ $finder = new tool_httpreplace_url_finder_mock();
ob_start();
$results = $finder->upgrade_http_links();
$output = ob_get_contents();
set_config('renames', json_encode($renames), 'tool_httpsreplace');
- $finder = new tool_httpreplace_url_finder_test();
+ $finder = new tool_httpreplace_url_finder_mock();
$generator = $this->getDataGenerator();
$course = $generator->create_course((object) [
$original2 .= '<img src="http://example.com/image' . ($i + 15 ) . '.png">';
$expected2 .= '<img src="https://example.com/image' . ($i + 15) . '.png">';
}
- $finder = new tool_httpreplace_url_finder_test();
+ $finder = new tool_httpreplace_url_finder_mock();
$generator = $this->getDataGenerator();
$course1 = $generator->create_course((object) ['summary' => $original1]);
$columnamequoted = $dbman->generator->getEncQuoted('where');
$DB->execute("INSERT INTO {reserved_words_temp} ($columnamequoted) VALUES (?)", [$content]);
- $finder = new tool_httpreplace_url_finder_test();
+ $finder = new tool_httpreplace_url_finder_mock();
$finder->upgrade_http_links();
$record = $DB->get_record('reserved_words_temp', []);
}
/**
- * Class tool_httpreplace_url_finder_test for testing replace tool without calling curl
+ * Class tool_httpreplace_url_finder_mock for testing replace tool without calling curl
*
* @package tool_httpsreplace
* @copyright 2017 Marina Glancy
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class tool_httpreplace_url_finder_test extends \tool_httpsreplace\url_finder {
+class tool_httpreplace_url_finder_mock extends \tool_httpsreplace\url_finder {
/**
* Check if url is available (check hardcoded for unittests)
*
$USER->id, SQL_PARAMS_NAMED);
// TODO Does not support custom user profile fields (MDL-70456).
- $userfieldsapi = \core\user_fields::for_identity($context, false)->with_userpic();
+ $userfieldsapi = \core_user\fields::for_identity($context, false)->with_userpic();
$fields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
- $extrasearchfields = $userfieldsapi->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]);
+ $extrasearchfields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]);
list($wheresql, $whereparams) = users_search_sql($query, 'u', true, $extrasearchfields);
list($sortsql, $sortparams) = users_order_by_sql('u', $query, $context);
*/
protected function define_table_columns() {
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = \core\user_fields::get_identity_fields($this->context, false);
+ $extrafields = \core_user\fields::get_identity_fields($this->context, false);
// Define headers and columns.
$cols = array(
// Add extra user fields that we need for the graded user.
// TODO Does not support custom user profile fields (MDL-70456).
- $userfieldsapi = \core\user_fields::for_identity($this->context, false)->with_name();
+ $userfieldsapi = \core_user\fields::for_identity($this->context, false)->with_name();
$fields .= $userfieldsapi->get_sql('u')->selects;
if ($count) {
}
// TODO Does not support custom user profile fields (MDL-70456).
- $userfieldsapi = \core\user_fields::for_identity(\context_system::instance(), false)->with_userpic();
+ $userfieldsapi = \core_user\fields::for_identity(\context_system::instance(), false)->with_userpic();
$userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
- $extrafields = $userfieldsapi->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]);
+ $extrafields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]);
$this->set_sql("$userfields",
"{user} u",
}
$this->add_column_header('fullname', get_string('fullnameuser', 'core'));
foreach ($extrafields as $field) {
- $this->add_column_header($field, \core\user_fields::get_display_name($field));
+ $this->add_column_header($field, \core_user\fields::get_display_name($field));
}
if (!$this->is_downloading() && !has_capability('tool/policy:acceptbehalf', \context_system::instance())) {
* Helper configuration method.
*/
protected function configure_for_single_version() {
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$userfieldsmod = $userfieldsapi->get_sql('m', false, 'mod', '', false)->selects;
$v = key($this->versionids);
$this->sql->fields .= ", $userfieldsmod, a{$v}.status AS status{$v}, a{$v}.note, ".
global $DB;
$ctxfields = context_helper::get_preload_record_columns_sql('c');
- $userfieldsapi = \core\user_fields::for_name()->with_userpic()->including(...($extrafields ?? []));
+ $userfieldsapi = \core_user\fields::for_name()->with_userpic()->including(...($extrafields ?? []));
$userfields = $userfieldsapi->get_sql('u')->selects;
$sql = "SELECT $ctxfields $userfields
$vsql = ' AND a.policyversionid ' . $vsql;
}
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$userfieldsmod = $userfieldsapi->get_sql('m', false, 'mod', '', false)->selects;
$sql = "SELECT u.id AS mainuserid, a.policyversionid, a.status, a.lang, a.timemodified, a.usermodified, a.note,
u.policyagreed, $userfieldsmod
$usernames = [];
list($sql, $params) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
$params['usercontextlevel'] = CONTEXT_USER;
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$users = $DB->get_records_sql("SELECT u.id" . $userfieldsapi->get_sql('u')->selects . ", " .
\context_helper::get_preload_record_columns_sql('ctx') .
" FROM {user} u JOIN {context} ctx ON ctx.contextlevel=:usercontextlevel AND ctx.instanceid = u.id
'interests',
);
// Include all name fields.
- $this->standardfields = array_merge($this->standardfields, \core\user_fields::get_name_fields());
+ $this->standardfields = array_merge($this->standardfields, \core_user\fields::get_name_fields());
}
/**
// These columns are always shown in the users list.
$requiredcolumns = array('city', 'country', 'lastaccess');
// Extra columns containing the extra user fields, excluding the required columns (city and country, to be specific).
- $userfields = \core\user_fields::for_identity($context, true)->with_name()->excluding(...$requiredcolumns);
+ $userfields = \core_user\fields::for_identity($context, true)->excluding(...$requiredcolumns);
$extracolumns = $userfields->get_required_fields();
- // Get all user name fields as an array.
- $columns = array_merge($extracolumns, $requiredcolumns);
+ // Get all user name fields as an array, but with firstname and lastname first.
+ $allusernamefields = \core_user\fields::get_name_fields(true);
+ $columns = array_merge($allusernamefields, $extracolumns, $requiredcolumns);
foreach ($columns as $column) {
- $string[$column] = \core\user_fields::get_display_name($column);
+ $string[$column] = \core_user\fields::get_display_name($column);
if ($sort != $column) {
$columnicon = "";
if ($column == "lastaccess") {
}
// Order in string will ensure that the name columns are in the correct order.
- $usernames = order_in_string($extracolumns, $fullnamesetting);
+ $usernames = order_in_string($allusernamefields, $fullnamesetting);
$fullnamedisplay = array();
foreach ($usernames as $name) {
// Use the link from $$column for sorting on the user's name.
}
$countries = get_string_manager()->get_list_of_countries(true);
-$userfieldsapi = \core\user_fields::for_name();
+$userfieldsapi = \core_user\fields::for_name();
$namefields = $userfieldsapi->get_sql('', false, '', '', false)->selects;
foreach ($users as $key => $id) {
$user = $DB->get_record('user', array('id' => $id), 'id, ' . $namefields . ', username,
$countries = get_string_manager()->get_list_of_countries(true);
-$userfieldsapi = \core\user_fields::for_name();
+$userfieldsapi = \core_user\fields::for_name();
$namefields = $userfieldsapi->get_sql('', false, '', '', false)->selects;
foreach ($users as $key => $id) {
$user = $DB->get_record('user', array('id'=>$id), 'id, ' . $namefields . ', username, email, country, lastaccess, city');
*/
protected function warn_about_invalid_availability(\coding_exception $e) {
$name = $this->get_thing_name();
- // If it occurs while building modinfo based on somebody calling $cm->name,
- // we can't get $cm->name, and this line will cause a warning.
- $htmlname = @$this->format_info($name, $this->course);
+ $htmlname = $this->format_info($name, $this->course);
+ // Because we call format_info here, likely in the middle of building dynamic data for the
+ // activity, there could be a chance that the name might not be available.
if ($htmlname === '') {
// So instead use the numbers (cmid) from the tag.
$htmlname = preg_replace('~[^0-9]~', '', $name);
$info = preg_replace_callback('~<AVAILABILITY_CMNAME_([0-9]+)/>~',
function($matches) use($modinfo, $context) {
$cm = $modinfo->get_cm($matches[1]);
- if ($cm->has_view() and $cm->uservisible) {
+ if ($cm->has_view() and $cm->get_user_visible()) {
// Help student by providing a link to the module which is preventing availability.
- return \html_writer::link($cm->url, format_string($cm->name, true, array('context' => $context)));
+ return \html_writer::link($cm->get_url(), format_string($cm->get_name(), true, ['context' => $context]));
} else {
- return format_string($cm->name, true, array('context' => $context));
+ return format_string($cm->get_name(), true, ['context' => $context]);
}
}, $info);
$this->customfield);
}
} else {
- $translatedfieldname = \core\user_fields::get_display_name($this->standardfield);
+ $translatedfieldname = \core_user\fields::get_display_name($this->standardfield);
}
$context = \context_course::instance($course->id);
$a = new \stdClass();
\section_info $section = null) {
// Standard user fields.
$standardfields = array(
- 'firstname' => \core\user_fields::get_display_name('firstname'),
- 'lastname' => \core\user_fields::get_display_name('lastname'),
- 'email' => \core\user_fields::get_display_name('email'),
- 'city' => \core\user_fields::get_display_name('city'),
- 'country' => \core\user_fields::get_display_name('country'),
- 'url' => \core\user_fields::get_display_name('url'),
- 'icq' => \core\user_fields::get_display_name('icq'),
- 'skype' => \core\user_fields::get_display_name('skype'),
- 'aim' => \core\user_fields::get_display_name('aim'),
- 'yahoo' => \core\user_fields::get_display_name('yahoo'),
- 'msn' => \core\user_fields::get_display_name('msn'),
- 'idnumber' => \core\user_fields::get_display_name('idnumber'),
- 'institution' => \core\user_fields::get_display_name('institution'),
- 'department' => \core\user_fields::get_display_name('department'),
- 'phone1' => \core\user_fields::get_display_name('phone1'),
- 'phone2' => \core\user_fields::get_display_name('phone2'),
- 'address' => \core\user_fields::get_display_name('address')
+ 'firstname' => \core_user\fields::get_display_name('firstname'),
+ 'lastname' => \core_user\fields::get_display_name('lastname'),
+ 'email' => \core_user\fields::get_display_name('email'),
+ 'city' => \core_user\fields::get_display_name('city'),
+ 'country' => \core_user\fields::get_display_name('country'),
+ 'url' => \core_user\fields::get_display_name('url'),
+ 'icq' => \core_user\fields::get_display_name('icq'),
+ 'skype' => \core_user\fields::get_display_name('skype'),
+ 'aim' => \core_user\fields::get_display_name('aim'),
+ 'yahoo' => \core_user\fields::get_display_name('yahoo'),
+ 'msn' => \core_user\fields::get_display_name('msn'),
+ 'idnumber' => \core_user\fields::get_display_name('idnumber'),
+ 'institution' => \core_user\fields::get_display_name('institution'),
+ 'department' => \core_user\fields::get_display_name('department'),
+ 'phone1' => \core_user\fields::get_display_name('phone1'),
+ 'phone2' => \core_user\fields::get_display_name('phone2'),
+ 'address' => \core_user\fields::get_display_name('address')
);
\core_collator::asort($standardfields);
sort($result);
$this->assertEquals($expected, $result);
}
+
+ /**
+ * Tests the info_module class when involved in a recursive call to $cm->name.
+ */
+ public function test_info_recursive_name_call() {
+ global $DB;
+
+ $this->resetAfterTest();
+
+ // Create a course and page.
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $page1 = $generator->create_module('page', ['course' => $course->id, 'name' => 'Page1']);
+
+ // Set invalid availability.
+ $DB->set_field('course_modules', 'availability', 'not valid', ['id' => $page1->cmid]);
+
+ // Get the cm_info object.
+ $this->setAdminUser();
+ $modinfo = get_fast_modinfo($course);
+ $cm1 = $modinfo->get_cm($page1->cmid);
+
+ // At this point we will generate dynamic data for $cm1, which will cause the debugging
+ // call below.
+ $this->assertEquals('Page1', $cm1->name);
+
+ $this->assertDebuggingCalled('Error processing availability data for ' .
+ '‘Page1’: Invalid availability text');
+ }
}
'phone2', 'institution', 'department', 'address',
'city', 'country', 'lastip', 'picture',
'url', 'description', 'descriptionformat', 'imagealt', 'auth');
- $anonfields = array_merge($anonfields, \core\user_fields::get_name_fields());
+ $anonfields = array_merge($anonfields, \core_user\fields::get_name_fields());
// Add anonymized fields to $userfields with custom final element
foreach ($anonfields as $field) {
} else { // Revoked badge.
header("HTTP/1.0 410 Gone");
$assertion = array();
- if ($obversion == OPEN_BADGES_V2) {
+ if ($obversion >= OPEN_BADGES_V2) {
$assertionurl = new moodle_url('/badges/assertion.php', array('b' => $hash));
$assertion['id'] = $assertionurl->out();
}
$email = empty($this->_data->backpackemail) ? $this->_data->email : $this->_data->backpackemail;
$assertionurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'obversion' => $this->_obversion));
- if ($this->_obversion == OPEN_BADGES_V2) {
+ if ($this->_obversion >= OPEN_BADGES_V2) {
$classurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id()));
} else {
$classurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'action' => 1));
*/
protected function embed_data_badge_version2 (&$json, $type = OPEN_BADGES_V2_TYPE_ASSERTION) {
// Specification Version 2.0.
- if ($this->_obversion == OPEN_BADGES_V2) {
+ if ($this->_obversion >= OPEN_BADGES_V2) {
$badge = new badge($this->_data->id);
if (empty($this->_data->courseid)) {
$context = context_system::instance();
global $DB;
// At this point a user has connected a backpack. So, we are going to get
// their backpack email rather than their account email.
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$namefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$user = $DB->get_record_sql("SELECT {$namefields}, b.email
FROM {user} u INNER JOIN {badge_backpack} b ON u.id = b.userid
array('hash' => $hash), IGNORE_MISSING);
if ($rec) {
// Get a recipient from database.
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$namefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$user = $DB->get_record_sql("SELECT u.id, $namefields, u.deleted, u.email
FROM {user} u WHERE u.id = :userid", array('userid' => $rec->userid));
$checked = true;
}
$this->config_options($mform, array('id' => $field, 'checked' => $checked,
- 'name' => \core\user_fields::get_display_name($field), 'error' => false));
+ 'name' => \core_user\fields::get_display_name($field), 'error' => false));
$none = false;
}
}
if (is_numeric($p['field'])) {
$str = $DB->get_field('user_info_field', 'name', array('id' => $p['field']));
} else {
- $str = \core\user_fields::get_display_name($p['field']);
+ $str = \core_user\fields::get_display_name($p['field']);
}
if (!$str) {
$output[] = $OUTPUT->error_text(get_string('error:nosuchfield', 'badges'));
echo $OUTPUT->box($OUTPUT->single_button($url, get_string('award', 'badges')), 'clearfix mdl-align');
}
-$userfieldsapi = \core\user_fields::for_name();
+$userfieldsapi = \core_user\fields::for_name();
$namefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$sql = "SELECT b.userid, b.dateissued, b.uniquehash, $namefields
FROM {badge_issued} b INNER JOIN {user} u
use core_badges\helper;
-class core_badges_badgeslib_testcase extends advanced_testcase {
+class badgeslib_test extends advanced_testcase {
protected $badgeid;
protected $course;
protected $user;
$this->assertStringMatchesFormat($testassertion2->badge, json_encode($assertion2->get_badge_assertion()));
$this->assertStringMatchesFormat($testassertion2->class, json_encode($assertion2->get_badge_class()));
$this->assertStringMatchesFormat($testassertion2->issuer, json_encode($assertion2->get_issuer()));
+
+ // Test Openbadge specification version 2.1. It has the same format as OBv2.0.
+ // Get assertion version 2.1.
+ $award = reset($awards);
+ $assertion2 = new core_badges_assertion($award->uniquehash, OPEN_BADGES_V2P1);
+
+ // Make sure JSON strings have the same structure.
+ $this->assertStringMatchesFormat($testassertion2->badge, json_encode($assertion2->get_badge_assertion()));
+ $this->assertStringMatchesFormat($testassertion2->class, json_encode($assertion2->get_badge_class()));
+ $this->assertStringMatchesFormat($testassertion2->issuer, json_encode($assertion2->get_issuer()));
}
/**
* @copyright 2015 onwards Simey Lameze <simey@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class core_badges_events_testcase extends core_badges_badgeslib_testcase {
+class events_test extends badgeslib_test {
/**
* Test badge awarded event.
// Now grab all the users from the database.
$userids = array_merge(array_keys($best), array_keys($worst));
- $fields = array_merge(array('id', 'idnumber'), \core\user_fields::get_name_fields());
+ $fields = array_merge(array('id', 'idnumber'), \core_user\fields::get_name_fields());
$fields = implode(',', $fields);
$users = $DB->get_records_list('user', 'id', $userids, '', $fields);
// If configured to view user idnumber, ensure current user can see it.
- $extrafields = \core\user_fields::for_identity($this->context)->get_required_fields();
+ $extrafields = \core_user\fields::for_identity($this->context)->get_required_fields();
$canviewidnumber = (array_search('idnumber', $extrafields) !== false);
// Ready for output!
$this->content = new stdClass();
// get all the mentees, i.e. users you have a direct assignment to
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$allusernames = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
if ($usercontexts = $DB->get_records_sql("SELECT c.instanceid, c.instanceid, $allusernames
FROM {role_assignments} ra, {context} c, {user} u
}
$params = array();
- $userfieldsapi = \core\user_fields::for_userpic()->including('username', 'deleted');
+ $userfieldsapi = \core_user\fields::for_userpic()->including('username', 'deleted');
$userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
// Add this to the SQL to show only group users.
if (!$userid) {
$userid = $USER->id;
}
- $userfieldsapi = \core\user_fields::for_userpic();
+ $userfieldsapi = \core_user\fields::for_userpic();
$allnamefields = $userfieldsapi->get_sql('u', false, '', 'useridalias', false)->selects;
// The query used to locate blog entries is complicated. It will be built from the following components:
$requiredfields = "p.*, $allnamefields"; // The SELECT clause.
switch ($type) {
case 'user':
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$info = fullname($DB->get_record('user', array('id' => $id),
$userfieldsapi->get_sql('', false, '', '', false)->selects));
break;
$params = array();
$perpage = (!empty($CFG->commentsperpage))?$CFG->commentsperpage:15;
$start = $page * $perpage;
- $userfieldsapi = \core\user_fields::for_userpic();
+ $userfieldsapi = \core_user\fields::for_userpic();
$ufields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
list($componentwhere, $component) = $this->get_component_select_sql('c');
}
$comments = array();
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$usernamefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$sql = "SELECT c.id, c.contextid, c.itemid, c.component, c.commentarea, c.userid, c.content, $usernamefields, c.timecreated
FROM {comments} c
$item->time = userdate($item->timecreated);
$item->content = format_text($item->content, FORMAT_MOODLE, $formatoptions);
// Unset fields not related to the comment
- foreach (\core\user_fields::get_name_fields() as $namefield) {
+ foreach (\core_user\fields::get_name_fields() as $namefield) {
unset($item->$namefield);
}
unset($item->timecreated);
--- /dev/null
+<?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/>.
+
+/**
+ * Contains the class for building the user's activity completion details.
+ *
+ * @package core_completion
+ * @copyright Jun Pataleta <jun@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace core_completion;
+
+use cm_info;
+use completion_info;
+
+/**
+ * Class for building the user's activity completion details.
+ *
+ * @package core_completion
+ * @copyright Jun Pataleta <jun@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cm_completion_details {
+ /** @var completion_info The completion info instance for this cm's course. */
+ protected $completioninfo = null;
+
+ /** @var cm_info The course module information. */
+ protected $cminfo = null;
+
+ /** @var int The user ID. */
+ protected $userid = 0;
+
+ /** @var bool Whether to return automatic completion details. */
+ protected $returndetails = true;
+
+ /**
+ * Constructor.
+ *
+ * @param completion_info $completioninfo The completion info instance for this cm's course.
+ * @param cm_info $cminfo The course module information.
+ * @param int $userid The user ID.
+ * @param bool $returndetails Whether to return completion details or not.
+ */
+ public function __construct(completion_info $completioninfo, cm_info $cminfo, int $userid, bool $returndetails = true) {
+ $this->completioninfo = $completioninfo;
+ $this->cminfo = $cminfo;
+ $this->userid = $userid;
+ $this->returndetails = $returndetails;
+ }
+
+ /**
+ * Fetches the completion details for a user.
+ *
+ * @return array An array of completion details for a user containing the completion requirement's description and status.
+ */
+ public function get_details(): array {
+ if (!$this->is_automatic()) {
+ // No details need to be returned for modules that don't have automatic completion tracking enabled.
+ return [];
+ }
+
+ if (!$this->returndetails) {
+ // We don't need to return completion details.
+ return [];
+ }
+
+ $completiondata = $this->completioninfo->get_data($this->cminfo, false, $this->userid);
+ $hasoverride = !empty($this->overridden_by());
+
+ $details = [];
+
+ // Completion rule: Student must view this activity.
+ if (!empty($this->cminfo->completionview)) {
+ if (!$hasoverride) {
+ $status = COMPLETION_INCOMPLETE;
+ if ($completiondata->viewed == COMPLETION_VIEWED) {
+ $status = COMPLETION_COMPLETE;
+ }
+ } else {
+ $status = $completiondata->completionstate;
+ }
+
+ $details['completionview'] = (object)[
+ 'status' => $status,
+ 'description' => get_string('detail_desc:view', 'completion'),
+ ];
+ }
+
+ // Completion rule: Student must receive a grade.
+ if (!is_null($this->cminfo->completiongradeitemnumber)) {
+ if (!$hasoverride) {
+ $status = $completiondata->completiongrade ?? COMPLETION_INCOMPLETE;
+ } else {
+ $status = $completiondata->completionstate;
+ }
+
+ $details['completionusegrade'] = (object)[
+ 'status' => $status,
+ 'description' => get_string('detail_desc:receivegrade', 'completion'),
+ ];
+ }
+
+ // Custom completion rules.
+ $cmcompletionclass = activity_custom_completion::get_cm_completion_class($this->cminfo->modname);
+ if (!isset($completiondata->customcompletion) || !$cmcompletionclass) {
+ // Return early if there are no custom rules to process or the cm completion class implementation is not available.
+ return $details;
+ }
+
+ /** @var activity_custom_completion $cmcompletion */
+ $cmcompletion = new $cmcompletionclass($this->cminfo, $this->userid);
+ foreach ($completiondata->customcompletion as $rule => $status) {
+ $details[$rule] = (object)[
+ 'status' => !$hasoverride ? $status : $completiondata->completionstate,
+ 'description' => $cmcompletion->get_custom_rule_description($rule),
+ ];
+ }
+
+ return $details;
+ }
+
+ /**
+ * Fetches the overall completion state of this course module.
+ *
+ * @return int The overall completion state for this course module.
+ */
+ public function get_overall_completion(): int {
+ $completiondata = $this->completioninfo->get_data($this->cminfo, false, $this->userid);
+ return (int)$completiondata->completionstate;
+ }
+
+ /**
+ * Whether this activity module has completion enabled.
+ *
+ * @return bool
+ */
+ public function has_completion(): bool {
+ return $this->completioninfo->is_enabled($this->cminfo) != COMPLETION_DISABLED;
+ }
+
+ /**
+ * Whether this activity module instance tracks completion automatically.
+ *
+ * @return bool
+ */
+ public function is_automatic(): bool {
+ return $this->cminfo->completion == COMPLETION_TRACKING_AUTOMATIC;
+ }
+
+ /**
+ * Fetches the user ID that has overridden the completion state of this activity for the user.
+ *
+ * @return int|null
+ */
+ public function overridden_by(): ?int {
+ $completiondata = $this->completioninfo->get_data($this->cminfo);
+ return isset($completiondata->overrideby) ? (int)$completiondata->overrideby : null;
+ }
+
+ /**
+ * Checks whether completion is being tracked for this user.
+ *
+ * @return bool
+ */
+ public function is_tracked_user(): bool {
+ return $this->completioninfo->is_tracked_user($this->userid);
+ }
+
+ /**
+ * Generates an instance of this class.
+ *
+ * @param cm_info $cminfo The course module info instance.
+ * @param int $userid The user ID that we're fetching completion details for.
+ * @param bool $returndetails Whether to return completion details or not.
+ * @return cm_completion_details
+ */
+ public static function get_instance(cm_info $cminfo, int $userid, bool $returndetails = true): cm_completion_details {
+ $course = $cminfo->get_course();
+ $completioninfo = new completion_info($course);
+ return new self($completioninfo, $cminfo, $userid, $returndetails);
+ }
+}
--- /dev/null
+@core @core_completion
+Feature: Allow teachers to edit the visibility of completion conditions in a course
+ In order to show students the course completion conditions in a course
+ As a teacher
+ I need to be able to edit completion conditions settings
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | enablecompletion | showcompletionconditions |
+ | Course 1 | C1 | 1 | 1 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And the following "activities" exist:
+ | activity | course | idnumber | name | completion | completionsubmit |
+ | choice | C1 | c1m | Test choice manual| 1 | 0 |
+ | choice | C1 | c1a | Test choice auto | 2 | 1 |
+
+ Scenario: Completion condition displaying for manual and auto completion
+ Given I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I follow "Test choice manual"
+ And I should see "Mark as done"
+ And I am on "Course 1" course homepage
+ When I follow "Test choice auto"
+ Then I should see "Make a choice" in the "[data-region=completionrequirements]" "css_element"
+ # TODO MDL-70821: Check completion conditions display on course homepage.
+
+ Scenario: Completion condition displaying setting can be disabled at course level
+ Given I log in as "teacher1"
+ And I am on "Course 1" course homepage with editing mode on
+ And I navigate to "Edit settings" in current page administration
+ When I set the following fields to these values:
+ | Show completion conditions | No |
+ And I click on "Save and display" "button"
+ And I follow "Test choice auto"
+ # Completion conditions are always shown in the module's view page.
+ Then I should see "Make a choice" in the "[data-region=completionrequirements]" "css_element"
+ And I am on "Course 1" course homepage
+ And I follow "Test choice manual"
+ And I should see "Mark as done"
+
+ Scenario: Default show completion conditions value in course form when default show completion conditions admin setting is set to No
+ Given I log in as "admin"
+ And I navigate to "Courses > Course default settings" in site administration
+ When I set the following fields to these values:
+ | Show completion conditions | No |
+ And I click on "Save changes" "button"
+ And I navigate to "Courses > Add a new course" in site administration
+ Then the field "showcompletionconditions" matches value "No"
+
+ Scenario: Default show completion conditions value in course form when default show completion conditions admin setting is set to Yes
+ Given I log in as "admin"
+ And I navigate to "Courses > Course default settings" in site administration
+ When I set the following fields to these values:
+ | Show completion conditions | Yes |
+ And I click on "Save changes" "button"
+ And I navigate to "Courses > Add a new course" in site administration
+ Then the field "showcompletionconditions" matches value "Yes"
--- /dev/null
+<?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/>.
+
+/**
+ * Contains unit tests for core_completion/cm_completion_details.
+ *
+ * @package core_completion
+ * @copyright Jun Pataleta <jun@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace core_completion;
+
+use advanced_testcase;
+use cm_info;
+use completion_info;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/completionlib.php');
+
+/**
+ * Class for unit testing core_completion/cm_completion_details.
+ *
+ * @package core_completion
+ * @copyright Jun Pataleta <jun@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cm_completion_details_test extends advanced_testcase {
+
+ /** @var completion_info A completion object. */
+ protected $completioninfo = null;
+
+ /**
+ * Fetches a mocked cm_completion_details instance.
+ *
+ * @param int|null $completion The completion tracking mode for the module.
+ * @param array $completionoptions Completion options (e.g. completionview, completionusegrade, etc.)
+ * @return cm_completion_details
+ */
+ protected function setup_data(?int $completion, array $completionoptions = []): cm_completion_details {
+ if (is_null($completion)) {
+ $completion = COMPLETION_TRACKING_AUTOMATIC;
+ }
+
+ // Mock a completion_info instance so we can simply mock the returns of completion_info::get_data() later.
+ $this->completioninfo = $this->getMockBuilder(completion_info::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ // Mock return of completion_info's is_enabled() method to match the expected completion tracking for the module.
+ $this->completioninfo->expects($this->any())
+ ->method('is_enabled')
+ ->willReturn($completion);
+
+ // Build a mock cm_info instance.
+ $mockcminfo = $this->getMockBuilder(cm_info::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['__get'])
+ ->getMock();
+
+ // Mock the return of the magic getter method when fetching the cm_info object's customdata and instance values.
+ $mockcminfo->expects($this->any())
+ ->method('__get')
+ ->will($this->returnValueMap([
+ ['completion', $completion],
+ ['instance', 1],
+ ['modname', 'somenonexistentmod'],
+ ['completionview', $completionoptions['completionview'] ?? COMPLETION_VIEW_NOT_REQUIRED],
+ ['completiongradeitemnumber', $completionoptions['completionusegrade'] ?? null],
+ ]));
+
+ return new cm_completion_details($this->completioninfo, $mockcminfo, 2);
+ }
+
+ /**
+ * Provides data for test_has_completion().
+ *
+ * @return array[]
+ */
+ public function has_completion_provider(): array {
+ return [
+ 'Automatic' => [
+ COMPLETION_TRACKING_AUTOMATIC, true
+ ],
+ 'Manual' => [
+ COMPLETION_TRACKING_MANUAL, true
+ ],
+ 'None' => [
+ COMPLETION_TRACKING_NONE, false
+ ],
+ ];
+ }
+
+ /**
+ * Test for has_completion().
+ *
+ * @dataProvider has_completion_provider
+ * @param int $completion The completion tracking mode.
+ * @param bool $expectedresult Expected result.
+ */
+ public function test_has_completion(int $completion, bool $expectedresult) {
+ $cmcompletion = $this->setup_data($completion);
+
+ $this->assertEquals($expectedresult, $cmcompletion->has_completion());
+ }
+
+ /**
+ * Provides data for test_is_automatic().
+ *
+ * @return array[]
+ */
+ public function is_automatic_provider(): array {
+ return [
+ 'Automatic' => [
+ COMPLETION_TRACKING_AUTOMATIC, true
+ ],
+ 'Manual' => [
+ COMPLETION_TRACKING_MANUAL, false
+ ],
+ 'None' => [
+ COMPLETION_TRACKING_NONE, false
+ ],
+ ];
+ }
+
+ /**
+ * Test for is_available().
+ *
+ * @dataProvider is_automatic_provider
+ * @param int $completion The completion tracking mode.
+ * @param bool $expectedresult Expected result.
+ */
+ public function test_is_automatic(int $completion, bool $expectedresult) {
+ $cmcompletion = $this->setup_data($completion);
+
+ $this->assertEquals($expectedresult, $cmcompletion->is_automatic());
+ }
+
+ /**
+ * Data provider for test_get_overall_completion().
+ * @return array[]
+ */
+ public function overall_completion_provider(): array {
+ return [
+ 'Complete' => [COMPLETION_COMPLETE],
+ 'Incomplete' => [COMPLETION_INCOMPLETE],
+ ];
+ }
+
+ /**
+ * Test for get_overall_completion().
+ *
+ * @dataProvider overall_completion_provider
+ * @param int $state
+ */
+ public function test_get_overall_completion(int $state) {
+ $cmcompletion = $this->setup_data(COMPLETION_TRACKING_AUTOMATIC);
+
+ $this->completioninfo->expects($this->once())
+ ->method('get_data')
+ ->willReturn((object)['completionstate' => $state]);
+
+ $this->assertEquals($state, $cmcompletion->get_overall_completion());
+ }
+
+ /**
+ * Data provider for test_get_details().
+ * @return array[]
+ */
+ public function get_details_provider() {
+ return [
+ 'No completion tracking' => [
+ COMPLETION_TRACKING_NONE, null, null, []
+ ],
+ 'Manual completion tracking' => [
+ COMPLETION_TRACKING_MANUAL, null, null, []
+ ],
+ 'Automatic, require view, not viewed' => [
+ COMPLETION_TRACKING_AUTOMATIC, COMPLETION_INCOMPLETE, null, [
+ 'completionview' => (object)[
+ 'status' => COMPLETION_INCOMPLETE,
+ 'description' => get_string('detail_desc:view', 'completion'),
+ ]
+ ]
+ ],
+ 'Automatic, require view, viewed' => [
+ COMPLETION_TRACKING_AUTOMATIC, COMPLETION_COMPLETE, null, [
+ 'completionview' => (object)[
+ 'status' => COMPLETION_COMPLETE,
+ 'description' => get_string('detail_desc:view', 'completion'),
+ ]
+ ]
+ ],
+ 'Automatic, require grade, incomplete' => [
+ COMPLETION_TRACKING_AUTOMATIC, null, COMPLETION_INCOMPLETE, [
+ 'completionusegrade' => (object)[
+ 'status' => COMPLETION_INCOMPLETE,
+ 'description' => get_string('detail_desc:receivegrade', 'completion'),
+ ]
+ ]
+ ],
+ 'Automatic, require grade, complete' => [
+ COMPLETION_TRACKING_AUTOMATIC, null, COMPLETION_COMPLETE, [
+ 'completionusegrade' => (object)[
+ 'status' => COMPLETION_COMPLETE,
+ 'description' => get_string('detail_desc:receivegrade', 'completion'),
+ ]
+ ]
+ ],
+ 'Automatic, require view (complete) and grade (incomplete)' => [
+ COMPLETION_TRACKING_AUTOMATIC, COMPLETION_COMPLETE, COMPLETION_INCOMPLETE, [
+ 'completionview' => (object)[
+ 'status' => COMPLETION_COMPLETE,
+ 'description' => get_string('detail_desc:view', 'completion'),
+ ],
+ 'completionusegrade' => (object)[
+ 'status' => COMPLETION_INCOMPLETE,
+ 'description' => get_string('detail_desc:receivegrade', 'completion'),
+ ]
+ ]
+ ],
+ 'Automatic, require view (incomplete) and grade (complete)' => [
+ COMPLETION_TRACKING_AUTOMATIC, COMPLETION_INCOMPLETE, COMPLETION_COMPLETE, [
+ 'completionview' => (object)[
+ 'status' => COMPLETION_INCOMPLETE,
+ 'description' => get_string('detail_desc:view', 'completion'),
+ ],
+ 'completionusegrade' => (object)[
+ 'status' => COMPLETION_COMPLETE,
+ 'description' => get_string('detail_desc:receivegrade', 'completion'),
+ ]
+ ]
+ ],
+ ];
+ }
+
+ /**
+ * Test for \core_completion\cm_completion_details::get_details().
+ *
+ * @dataProvider get_details_provider
+ * @param int $completion The completion tracking mode.
+ * @param int|null $completionview Completion status of the "view" completion condition.
+ * @param int|null $completiongrade Completion status of the "must receive grade" completion condition.
+ * @param array $expecteddetails Expected completion details returned by get_details().
+ */
+ public function test_get_details(int $completion, ?int $completionview, ?int $completiongrade, array $expecteddetails) {
+ $options = [];
+ $getdatareturn = (object)[
+ 'viewed' => $completionview,
+ 'completiongrade' => $completiongrade,
+ ];
+
+ if (!is_null($completionview)) {
+ $options['completionview'] = true;
+ }
+ if (!is_null($completiongrade)) {
+ $options['completionusegrade'] = true;
+ }
+
+ $cmcompletion = $this->setup_data($completion, $options);
+
+ $this->completioninfo->expects($this->any())
+ ->method('get_data')
+ ->willReturn($getdatareturn);
+
+ $this->assertEquals($expecteddetails, $cmcompletion->get_details());
+ }
+}
--- /dev/null
+// 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/>.
+
+/**
+ * Provides the functionality for toggling the manual completion state of a course module through
+ * the manual completion button.
+ *
+ * @module core_course/manual_completion_toggle
+ * @package core_course
+ * @copyright 2021 Jun Pataleta <jun@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Templates from 'core/templates';
+import Notification from 'core/notification';
+import {toggleManualCompletion} from 'core_course/repository';
+
+/**
+ * Selectors in the manual completion template.
+ *
+ * @type {{MANUAL_TOGGLE: string}}
+ */
+const SELECTORS = {
+ MANUAL_TOGGLE: 'button[data-action=toggle-manual-completion]',
+};
+
+/**
+ * Toggle type values for the data-toggletype attribute in the core_course/completion_manual template.
+ *
+ * @type {{TOGGLE_UNDO: string, TOGGLE_MARK_DONE: string}}
+ */
+const TOGGLE_TYPES = {
+ TOGGLE_MARK_DONE: 'manual:mark-done',
+ TOGGLE_UNDO: 'manual:undo',
+};
+
+/**
+ * Whether the event listener has already been registered for this module.
+ *
+ * @type {boolean}
+ */
+let registered = false;
+
+/**
+ * Registers the click event listener for the manual completion toggle button.
+ */
+export const init = () => {
+ if (registered) {
+ return;
+ }
+ document.addEventListener('click', (e) => {
+ const toggleButton = e.target.closest(SELECTORS.MANUAL_TOGGLE);
+ if (toggleButton) {
+ e.preventDefault();
+ toggleManualCompletionState(toggleButton).catch(Notification.exception);
+ }
+ });
+ registered = true;
+};
+
+/**
+ * Toggles the manual completion state of the module for the given user.
+ *
+ * @param {HTMLElement} toggleButton
+ * @returns {Promise<void>}
+ */
+const toggleManualCompletionState = async(toggleButton) => {
+ // Make a copy of the original content of the button.
+ const originalInnerHtml = toggleButton.innerHTML;
+
+ // Disable the button to prevent double clicks.
+ toggleButton.setAttribute('disabled', 'disabled');
+
+ // Get button data.
+ const toggleType = toggleButton.getAttribute('data-toggletype');
+ const cmid = toggleButton.getAttribute('data-cmid');
+ const activityname = toggleButton.getAttribute('data-activityname');
+ // Get the target completion state.
+ const completed = toggleType === TOGGLE_TYPES.TOGGLE_MARK_DONE;
+
+ // Replace the button contents with the loading icon.
+ const loadingHtml = await Templates.render('core/loading', {});
+ await Templates.replaceNodeContents(toggleButton, loadingHtml, '');
+
+ try {
+ // Call the webservice to update the manual completion status.
+ await toggleManualCompletion(cmid, completed);
+
+ // All good so far. Refresh the manual completion button to reflect its new state by re-rendering the template.
+ const templateContext = {
+ cmid: cmid,
+ activityname: activityname,
+ overallcomplete: completed,
+ overallincomplete: !completed,
+ istrackeduser: true, // We know that we're tracking completion for this user given the presence of this button.
+ };
+ const renderObject = await Templates.renderForPromise('core_course/completion_manual', templateContext);
+
+ // Replace the toggle button with the newly loaded template.
+ await Templates.replaceNode(toggleButton, renderObject.html, renderObject.js);
+
+ } catch (exception) {
+ // In case of an error, revert the original state and appearance of the button.
+ toggleButton.removeAttribute('disabled');
+ toggleButton.innerHTML = originalInnerHtml;
+
+ // Show the exception.
+ Notification.exception(exception);
+ }
+};
* @copyright 2018 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-define(['jquery', 'core/ajax'], function($, Ajax) {
-
- /**
- * Get the list of courses that the logged in user is enrolled in for a given
- * timeline classification.
- *
- * @param {string} classification past, inprogress, or future
- * @param {int} limit Only return this many results
- * @param {int} offset Skip this many results from the start of the result set
- * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'
- * @return {object} jQuery promise resolved with courses.
- */
- var getEnrolledCoursesByTimelineClassification = function(classification, limit, offset, sort) {
- var args = {
- classification: classification
- };
-
- if (typeof limit !== 'undefined') {
- args.limit = limit;
- }
- if (typeof offset !== 'undefined') {
- args.offset = offset;
- }
+import Ajax from 'core/ajax';
- if (typeof sort !== 'undefined') {
- args.sort = sort;
- }
+/**
+ * Get the list of courses that the logged in user is enrolled in for a given
+ * timeline classification.
+ *
+ * @param {string} classification past, inprogress, or future
+ * @param {int} limit Only return this many results
+ * @param {int} offset Skip this many results from the start of the result set
+ * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'
+ * @return {object} jQuery promise resolved with courses.
+ */
+const getEnrolledCoursesByTimelineClassification = (classification, limit, offset, sort) => {
+ const args = {
+ classification: classification
+ };
- var request = {
- methodname: 'core_course_get_enrolled_courses_by_timeline_classification',
- args: args
- };
+ if (typeof limit !== 'undefined') {
+ args.limit = limit;
+ }
- return Ajax.call([request])[0];
+ if (typeof offset !== 'undefined') {
+ args.offset = offset;
+ }
+
+ if (typeof sort !== 'undefined') {
+ args.sort = sort;
+ }
+
+ const request = {
+ methodname: 'core_course_get_enrolled_courses_by_timeline_classification',
+ args: args
};
- /**
- * Get the list of courses that the user has most recently accessed.
- *
- * @method getLastAccessedCourses
- * @param {int} userid User from which the courses will be obtained
- * @param {int} limit Only return this many results
- * @param {int} offset Skip this many results from the start of the result set
- * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'
- * @return {promise} Resolved with an array of courses
- */
- var getLastAccessedCourses = function(userid, limit, offset, sort) {
- var args = {};
-
- if (typeof userid !== 'undefined') {
- args.userid = userid;
- }
+ return Ajax.call([request])[0];
+};
- if (typeof limit !== 'undefined') {
- args.limit = limit;
- }
+/**
+ * Get the list of courses that the user has most recently accessed.
+ *
+ * @method getLastAccessedCourses
+ * @param {int} userid User from which the courses will be obtained
+ * @param {int} limit Only return this many results
+ * @param {int} offset Skip this many results from the start of the result set
+ * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'
+ * @return {promise} Resolved with an array of courses
+ */
+const getLastAccessedCourses = (userid, limit, offset, sort) => {
+ const args = {};
- if (typeof offset !== 'undefined') {
- args.offset = offset;
- }
+ if (typeof userid !== 'undefined') {
+ args.userid = userid;
+ }
- if (typeof sort !== 'undefined') {
- args.sort = sort;
- }
+ if (typeof limit !== 'undefined') {
+ args.limit = limit;
+ }
+
+ if (typeof offset !== 'undefined') {
+ args.offset = offset;
+ }
- var request = {
- methodname: 'core_course_get_recent_courses',
- args: args
- };
+ if (typeof sort !== 'undefined') {
+ args.sort = sort;
+ }
- return Ajax.call([request])[0];
+ const request = {
+ methodname: 'core_course_get_recent_courses',
+ args: args
};
- /**
- * Get the list of users enrolled in this cmid.
- *
- * @param {Number} cmid Course Module from which the users will be obtained
- * @param {Number} groupID Group ID from which the users will be obtained
- * @returns {Promise} Promise containing a list of users
- */
- var getEnrolledUsersFromCourseModuleID = function(cmid, groupID) {
- var request = {
- methodname: 'core_course_get_enrolled_users_by_cmid',
- args: {
- cmid: cmid,
- groupid: groupID,
- },
- };
-
- return Ajax.call([request])[0];
+ return Ajax.call([request])[0];
+};
+
+/**
+ * Get the list of users enrolled in this cmid.
+ *
+ * @param {Number} cmid Course Module from which the users will be obtained
+ * @param {Number} groupID Group ID from which the users will be obtained
+ * @returns {Promise} Promise containing a list of users
+ */
+const getEnrolledUsersFromCourseModuleID = (cmid, groupID) => {
+ var request = {
+ methodname: 'core_course_get_enrolled_users_by_cmid',
+ args: {
+ cmid: cmid,
+ groupid: groupID,
+ },
};
- return {
- getEnrolledCoursesByTimelineClassification: getEnrolledCoursesByTimelineClassification,
- getLastAccessedCourses: getLastAccessedCourses,
- getUsersFromCourseModuleID: getEnrolledUsersFromCourseModuleID,
+ return Ajax.call([request])[0];
+};
+
+/**
+ * Toggle the completion state of an activity with manual completion.
+ *
+ * @param {Number} cmid The course module ID.
+ * @param {Boolean} completed Whether to set as complete or not.
+ * @returns {object} jQuery promise
+ */
+const toggleManualCompletion = (cmid, completed) => {
+ const request = {
+ methodname: 'core_completion_update_activity_completion_status_manually',
+ args: {
+ cmid,
+ completed,
+ }
};
-});
+ return Ajax.call([request])[0];
+};
+
+export default {
+ getEnrolledCoursesByTimelineClassification,
+ getLastAccessedCourses,
+ getUsersFromCourseModuleID: getEnrolledUsersFromCourseModuleID,
+ toggleManualCompletion,
+};
list($sql2, $params2) = $DB->get_in_or_equal($managerroles, SQL_PARAMS_NAMED, 'rid');
list($sort, $sortparams) = users_order_by_sql('u');
$notdeleted = array('notdeleted' => 0);
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$allnames = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$sql = "SELECT ra.contextid, ra.id AS raid,
r.id AS roleid, r.name AS rolename, r.shortname AS roleshortname,
--- /dev/null
+<?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/>.
+
+/**
+ * File containing the class activity information renderable.
+ *
+ * @package core_course
+ * @copyright 2021 Jun Pataleta <jun@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use cm_info;
+use completion_info;
+use context;
+use core\activity_dates;
+use core_completion\cm_completion_details;
+use core_user;
+use core_user\fields;
+use renderable;
+use renderer_base;
+use stdClass;
+use templatable;
+
+/**
+ * The activity information renderable class.
+ *
+ * @package core_course
+ * @copyright 2021 Jun Pataleta <jun@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class activity_information implements renderable, templatable {
+
+ /** @var cm_info The course module information. */
+ protected $cminfo = null;
+
+ /** @var array The array of relevant dates for this activity. */
+ protected $activitydates = [];
+
+ /** @var cm_completion_details The user's completion details for this activity. */
+ protected $cmcompletion = null;
+
+ /**
+ * Constructor.
+ *
+ * @param cm_info $cminfo The course module information.
+ * @param cm_completion_details $cmcompletion The course module information.
+ * @param array $activitydates The activity dates.
+ */
+ public function __construct(cm_info $cminfo, cm_completion_details $cmcompletion, array $activitydates) {
+ $this->cminfo = $cminfo;
+ $this->cmcompletion = $cmcompletion;
+ $this->activitydates = $activitydates;
+ }
+
+ /**
+ * Export this data so it can be used as the context for a mustache template.
+ *
+ * @param renderer_base $output Renderer base.
+ * @return stdClass
+ */
+ public function export_for_template(renderer_base $output): stdClass {
+ $data = $this->build_completion_data();
+
+ $data->cmid = $this->cminfo->id;
+ $data->activityname = $this->cminfo->name;
+ $data->activitydates = $this->activitydates;
+
+ return $data;
+ }
+
+ /**
+ * Builds the completion data for export.
+ *
+ * @return stdClass
+ */
+ protected function build_completion_data(): stdClass {
+ $data = new stdClass();
+
+ $data->hascompletion = $this->cmcompletion->has_completion();
+ $data->isautomatic = $this->cmcompletion->is_automatic();
+
+ // Get the name of the user overriding the completion condition, if available.
+ $data->overrideby = null;
+ $overrideby = $this->cmcompletion->overridden_by();
+ $overridebyname = null;
+ if (!empty($overrideby)) {
+ $userfields = fields::for_name();
+ $overridebyrecord = core_user::get_user($overrideby, 'id ' . $userfields->get_sql()->selects, MUST_EXIST);
+ $data->overrideby = fullname($overridebyrecord);
+ }
+
+ // We'll show only the completion conditions and not the completion status if we're not tracking completion for this user
+ // (e.g. a teacher, admin).
+ $data->istrackeduser = $this->cmcompletion->is_tracked_user();
+
+ // Overall completion states.
+ $overallcompletion = $this->cmcompletion->get_overall_completion();
+ $data->overallcomplete = $overallcompletion == COMPLETION_COMPLETE;
+ $data->overallincomplete = $overallcompletion == COMPLETION_INCOMPLETE;
+
+ // Set an accessible description for manual completions with overridden completion state.
+ if (!$data->isautomatic && $data->overrideby) {
+ $setbydata = (object)[
+ 'activityname' => $this->cminfo->name,
+ 'setby' => $data->overrideby,
+ ];
+ $setbylangkey = $data->overallcomplete ? 'completion_setby:manual:done' : 'completion_setby:manual:markdone';
+ $data->accessibledescription = get_string($setbylangkey, 'course', $setbydata);
+ }
+
+ // Build automatic completion details.
+ $details = [];
+ foreach ($this->cmcompletion->get_details() as $key => $detail) {
+ // Set additional attributes for the template.
+ $detail->key = $key;
+ $detail->statuscomplete = in_array($detail->status, [COMPLETION_COMPLETE, COMPLETION_COMPLETE_PASS]);
+ $detail->statuscompletefail = $detail->status == COMPLETION_COMPLETE_FAIL;
+ $detail->statusincomplete = $detail->status == COMPLETION_INCOMPLETE;
+
+ // Add an accessible description to be used for title and aria-label attributes for overridden completion details.
+ if ($data->overrideby) {
+ $setbydata = (object)[
+ 'condition' => $detail->description,
+ 'setby' => $data->overrideby,
+ ];
+ $detail->accessibledescription = get_string('completion_setby:auto', 'course', $setbydata);
+ }
+
+ // We don't need the status in the template.
+ unset($detail->status);
+
+ $details[] = $detail;
+ }
+ $data->completiondetails = $details;
+
+ return $data;
+ }
+}
$mform->addHelpButton('showreports', 'showreports');
$mform->setDefault('showreports', $courseconfig->showreports);
+ // Show activity dates.
+ $mform->addElement('selectyesno', 'showactivitydates', get_string('showactivitydates'));
+ $mform->addHelpButton('showactivitydates', 'showactivitydates');
+ $mform->setDefault('showactivitydates', $courseconfig->showactivitydates);
+
// Files and uploads.
$mform->addElement('header', 'filehdr', get_string('filesanduploads'));
$mform->addElement('selectyesno', 'enablecompletion', get_string('enablecompletion', 'completion'));
$mform->setDefault('enablecompletion', $courseconfig->enablecompletion);
$mform->addHelpButton('enablecompletion', 'enablecompletion', 'completion');
+
+ $showcompletionconditions = $courseconfig->showcompletionconditions ?? COMPLETION_SHOW_CONDITIONS;
+ $mform->addElement('selectyesno', 'showcompletionconditions', get_string('showcompletionconditions', 'completion'));
+ $mform->addHelpButton('showcompletionconditions', 'showcompletionconditions', 'completion');
+ $mform->setDefault('showcompletionconditions', $showcompletionconditions);
+ $mform->hideIf('showcompletionconditions', 'enablecompletion', 'eq', COMPLETION_HIDE_CONDITIONS);
} else {
$mform->addElement('hidden', 'enablecompletion');
$mform->setType('enablecompletion', PARAM_INT);
$options[0] = get_string('allparticipants');
$options[$CFG->siteguest] = get_string('guestuser');
- $userfieldsapi = \core\user_fields::for_userpic();
+ $userfieldsapi = \core_user\fields::for_userpic();
$ufields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
if (isset($groupoptions[0])) {
return $this->coursecat_tree($chelper, $tree);
}
+ /**
+ * Renders the activity information.
+ *
+ * Defer to template.
+ *
+ * @param \core_course\output\activity_information $page
+ * @return string html for the page
+ */
+ public function render_activity_information(\core_course\output\activity_information $page) {
+ $data = $page->export_for_template($this->output);
+ return $this->output->render_from_template('core_course/activity_info', $data);
+ }
+
/**
* Renders the activity navigation.
*
--- /dev/null
+{{!
+ 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_course/activity_date
+
+ Template for displaying the activity's dates.
+
+ Example context (json):
+ {
+ "label": "Opens:",
+ "timestamp": 1293876000
+ }
+}}
+<div>
+ <strong>{{label}}</strong> {{#userdate}} {{timestamp}}, {{#str}} strftimedatetime, core_langconfig {{/str}} {{/userdate}}
+</div>
--- /dev/null
+{{!
+ 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_course/activity_info
+
+ Container to display activity information such as:
+ - Activity dates
+ - Activity completion requirements (automatic completion)
+ - Manual completion button
+
+ Example context (json):
+ {
+ "hascompletion": true,
+ "isautomatic": true,
+ "istrackeduser": true,
+ "activitydates": [
+ {
+ "label": "Opens:",
+ "timestamp": 1293876000
+ }
+ ],
+ "completiondetails": [
+ {
+ "statuscomplete": 1,
+ "description": "Viewed"
+ },
+ {
+ "statusincomplete": 1,
+ "description": "Receive a grade"
+ }
+ ]
+ }
+}}
+<div data-region="activity-information" class="activity-information">
+ <div class="mb-1">
+ {{#activitydates}}
+ {{>core_course/activity_date}}
+ {{/activitydates}}
+ </div>
+ <div>
+ {{#hascompletion}}
+ {{#isautomatic}}
+ <div class="automatic-completion-conditions" data-region ="completionrequirements" role="list" aria-label="{{#str}}completionrequirements, core_course{{/str}}">
+ {{#completiondetails}}
+ {{> core_course/completion_automatic }}
+ {{/completiondetails}}
+ </div>
+ {{/isautomatic}}
+ {{^isautomatic}}
+ {{> core_course/completion_manual }}
+ {{/isautomatic}}
+ {{/hascompletion}}
+ </div>
+</div>
--- /dev/null
+{{!
+ 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_course/completion_automatic
+
+ Template for displaying an automatic completion rule and its status.
+
+ Example context (json):
+ {
+ "statuscomplete": 1,
+ "description": "View",
+ "istrackeduser": true,
+ "accessibledescription": "Done: View (Set by Admin User)"
+ }
+}}
+{{#istrackeduser}}
+ {{#statuscomplete}}
+ <span class="badge badge-success rounded mb-1" role="listitem" {{!
+ }}{{#accessibledescription}}{{!
+ }}title="{{.}}" {{!
+ }}aria-label="{{.}}" {{!
+ }}{{/accessibledescription}}>
+ <strong>{{#str}}completion_automatic:done, core_course{{/str}}</strong> <span class="font-weight-normal">{{description}}</span>
+ </span>
+ {{/statuscomplete}}
+ {{#statuscompletefail}}
+ <span class="badge badge-danger rounded mb-1" role="listitem" {{!
+ }}{{#accessibledescription}}{{!
+ }}title="{{.}}" {{!
+ }}aria-label="{{.}}" {{!
+ }}{{/accessibledescription}}>
+ <strong>{{#str}}completion_automatic:failed, core_course{{/str}}</strong> <span class="font-weight-normal">{{description}}</span>
+ </span>
+ {{/statuscompletefail}}
+ {{#statusincomplete}}
+ <span class="badge badge-secondary rounded mb-1" role="listitem" {{!
+ }}{{#accessibledescription}}{{!
+ }}title="{{.}}" {{!
+ }}aria-label="{{.}}" {{!
+ }}{{/accessibledescription}}>
+ <strong>{{#str}}completion_automatic:todo, core_course{{/str}}</strong> <span class="font-weight-normal">{{description}}</span>
+ </span>
+ {{/statusincomplete}}
+{{/istrackeduser}}
+{{^istrackeduser}}
+ <span class="badge badge-secondary rounded mb-1" role="listitem">
+ <span class="font-weight-normal">{{description}}</span>
+ </span>
+{{/istrackeduser}}
--- /dev/null
+{{!
+ 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_course/completion_manual
+
+ Template for displaying the manual completion button.
+
+ Example context (json):
+ {
+ "cmid": 0,
+ "overallcomplete": true,
+ "overallincomplete": false
+ }
+}}
+{{#istrackeduser}}
+ {{#overallcomplete}}
+ <button class="btn btn-outline-success" data-action="toggle-manual-completion" data-toggletype="manual:undo" data-cmid="{{cmid}}" data-activityname="{{activityname}}" {{!
+ }}{{#accessibledescription}}{{!
+ }}title="{{.}}" {{!
+ }}aria-label="{{.}}" {{!
+ }}{{/accessibledescription}}{{!
+ }}{{^accessibledescription}}{{!
+ }}title="{{#str}}completion_manual:aria:done, course, {{activityname}} {{/str}}" {{!
+ }}aria-label="{{#str}}completion_manual:aria:done, course, {{activityname}} {{/str}}" {{!
+ }}{{/accessibledescription}}>
+ <i class="fa fa-check" aria-hidden="true"></i> {{#str}} completion_manual:done, core_course {{/str}}
+ </button>
+ {{/overallcomplete}}
+ {{#overallincomplete}}
+ <button class="btn btn-outline-secondary" data-action="toggle-manual-completion" data-toggletype="manual:mark-done" data-cmid="{{cmid}}" data-activityname="{{activityname}}" {{!
+ }}{{#accessibledescription}}{{!
+ }}title="{{.}}" {{!
+ }}aria-label="{{.}}" {{!
+ }}{{/accessibledescription}}{{!
+ }}{{^accessibledescription}}{{!
+ }}title="{{#str}}completion_manual:aria:markdone, course, {{activityname}} {{/str}}" {{!
+ }}aria-label="{{#str}}completion_manual:aria:markdone, course, {{activityname}} {{/str}}" {{!
+ }}{{/accessibledescription}}>
+ {{#str}} completion_manual:markdone, core_course {{/str}}
+ </button>
+ {{/overallincomplete}}
+{{/istrackeduser}}
+{{^istrackeduser}}
+ <button class="btn btn-outline-secondary" disabled>
+ {{#str}} completion_manual:markdone, core_course {{/str}}
+ </button>
+{{/istrackeduser}}
+
+{{#js}}
+require(['core_course/manual_completion_toggle'], toggle => {
+ toggle.init()
+});
+{{/js}}
--- /dev/null
+@core @core_course
+Feature: Allow teachers to edit the visibility of activity dates in a course
+ In order to show students the activity dates in a course
+ As a teacher
+ I need to be able to edit activity dates settings
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category |
+ | Course 1 | C1 | 0 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And the following "activities" exist:
+ | activity | course | idnumber | name | intro | timeopen | timeclose |
+ | choice | C1 | choice1 | Test choice 1 | Test choice description | ##yesterday## | ##tomorrow## |
+
+ Scenario: Activity dates setting can be enabled to display activity dates in a course
+ Given I log in as "teacher1"
+ And I am on "Course 1" course homepage with editing mode on
+ And I navigate to "Edit settings" in current page administration
+ When I set the following fields to these values:
+ | Show activity dates | Yes |
+ And I click on "Save and display" "button"
+ And I follow "Test choice"
+ Then I should see "Opened:" in the "[data-region=activity-information]" "css_element"
+ And I should see "Closes:" in the "[data-region=activity-information]" "css_element"
+ # TODO MDL-70821: Check activity dates display on course homepage.
+
+ Scenario: Activity dates setting can be disabled to hidden activity dates in a course
+ Given I log in as "teacher1"
+ And I am on "Course 1" course homepage with editing mode on
+ And I navigate to "Edit settings" in current page administration
+ When I set the following fields to these values:
+ | Show activity dates | No |
+ And I click on "Save and display" "button"
+ And I follow "Test choice"
+ # Activity dates are always shown in the module's view page.
+ Then I should see "Opened:"
+ And I should see "Closes:"
+ And I am on "Course 1" course homepage
+ And I should not see "Opened:"
+ And I should not see "Closes:"
+
+ Scenario: Default activity dates setting default value can changed to No
+ Given I log in as "admin"
+ And I navigate to "Courses > Course default settings" in site administration
+ When I set the following fields to these values:
+ | Show activity dates | No |
+ And I click on "Save changes" "button"
+ And I navigate to "Courses > Add a new course" in site administration
+ Then the field "showactivitydates" matches value "No"
+
+ Scenario: Default activity dates setting default value can changed to Yes
+ Given I log in as "admin"
+ And I navigate to "Courses > Course default settings" in site administration
+ When I set the following fields to these values:
+ | Show activity dates | Yes |
+ And I click on "Save changes" "button"
+ And I navigate to "Courses > Add a new course" in site administration
+ Then the field "showactivitydates" matches value "Yes"
$page = optional_param('page', 0, PARAM_INT);
$outcome->response = $manager->search_other_users($search, $searchanywhere, $page);
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = \core\user_fields::get_identity_fields($context, false);
+ $extrafields = \core_user\fields::get_identity_fields($context, false);
$useroptions = array();
// User is not enrolled, either link to site profile or do not link at all.
if (has_capability('moodle/user:viewdetails', context_system::instance())) {
$results = array();
// Add also extra user fields.
- $identityfields = \core\user_fields::get_identity_fields($context, true);
+ $identityfields = \core_user\fields::get_identity_fields($context, true);
$customprofilefields = [];
foreach ($identityfields as $key => $value) {
- if ($fieldname = \core\user_fields::match_custom_field($value)) {
+ if ($fieldname = \core_user\fields::match_custom_field($value)) {
unset($identityfields[$key]);
$customprofilefields[$fieldname] = true;
}
$requiredfields = array_merge(
['id', 'fullname', 'profileimageurl', 'profileimageurlsmall'],
// TODO Does not support custom user profile fields (MDL-70456).
- \core\user_fields::get_identity_fields($context, false)
+ \core_user\fields::get_identity_fields($context, false)
);
foreach ($users['users'] as $user) {
if ($userdetails = user_get_user_details($user, $course, $requiredfields)) {
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-use core\user_fields;
+use core_user\fields;
defined('MOODLE_INTERNAL') || die();
list($instancessql, $params, $filter) = $this->get_instance_sql();
list($filtersql, $moreparams) = $this->get_filter_sql();
$params += $moreparams;
- $userfields = user_fields::for_identity($this->get_context())->with_userpic()->excluding('lastaccess');
+ $userfields = fields::for_identity($this->get_context())->with_userpic()->excluding('lastaccess');
['selects' => $fieldselect, 'joins' => $fieldjoin, 'params' => $fieldjoinparams] =
(array)$userfields->get_sql('u', true, '', '', false);
$params += $fieldjoinparams;
// Search condition.
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = user_fields::get_identity_fields($this->get_context(), false);
+ $extrafields = fields::get_identity_fields($this->get_context(), false);
list($sql, $params) = users_search_sql($this->searchfilter, 'u', true, $extrafields);
// Role condition.
list($ctxcondition, $params) = $DB->get_in_or_equal($this->context->get_parent_context_ids(true), SQL_PARAMS_NAMED, 'ctx');
$params['courseid'] = $this->course->id;
$params['cid'] = $this->course->id;
- $userfields = user_fields::for_identity($this->get_context())->with_userpic();
+ $userfields = fields::for_identity($this->get_context())->with_userpic();
['selects' => $fieldselect, 'joins' => $fieldjoin, 'params' => $fieldjoinparams] =
(array)$userfields->get_sql('u', true);
$params += $fieldjoinparams;
// Get custom user field SQL used for querying all the fields we need (identity, name, and
// user picture).
- $userfields = user_fields::for_identity($this->context)->with_name()->with_userpic()
+ $userfields = fields::for_identity($this->context)->with_name()->with_userpic()
->excluding('username', 'lastaccess', 'maildisplay');
['selects' => $fieldselects, 'joins' => $fieldjoins, 'params' => $params, 'mappings' => $mappings] =
(array)$userfields->get_sql('u', true, '', '', false);
// Searchable fields are only the identity and name ones (not userpic).
$searchable = array_fill_keys($userfields->get_required_fields(
- [user_fields::PURPOSE_IDENTITY, user_fields::PURPOSE_NAME]), true);
+ [fields::PURPOSE_IDENTITY, fields::PURPOSE_NAME]), true);
// Add some additional sensible conditions
$tests = array("u.id <> :guestid", 'u.deleted = 0', 'u.confirmed = 1');
$context = $this->get_context();
$now = time();
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = user_fields::get_identity_fields($context, false);
+ $extrafields = fields::get_identity_fields($context, false);
$users = array();
foreach ($userroles as $userrole) {
$url = new moodle_url($pageurl, $this->get_url_params());
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = user_fields::get_identity_fields($context, false);
+ $extrafields = fields::get_identity_fields($context, false);
$enabledplugins = $this->get_enrolment_plugins(true);
* Please note that this function does not check capability for moodle/coures:viewhiddenuserfields
*
* @param object $user The user record
- * @param array $extrafields The list of fields as returned from get_extra_user_fields used to determine which
+ * @param array $extrafields The list of fields as returned from \core_user\fields::get_identity_fields used to determine which
* additional fields may be displayed
* @param int $now The time used for lastaccess calculation
* @return array The fields to be displayed including userid, courseid, picture, firstname, lastcourseaccess, lastaccess and any
list($instancesql, $instanceparams) = $DB->get_in_or_equal(array_keys($instances), SQL_PARAMS_NAMED, 'instanceid0000');
}
- $userfieldsapi = \core\user_fields::for_userpic();
+ $userfieldsapi = \core_user\fields::for_userpic();
$userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
list($idsql, $idparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED, 'userid0000');
'courseid' => $course->id,
'enrolid' => $instance->id,
'perpage' => $CFG->maxusersperpage,
- 'userfields' => implode(',', \core\user_fields::get_identity_fields($context, true))
+ 'userfields' => implode(',', \core_user\fields::get_identity_fields($context, true))
);
$mform->addElement('autocomplete', 'userlist', get_string('selectusers', 'enrol_manual'), array(), $options);
'lastname' => get_string('lastname'),
);
// TODO Does not support custom user profile fields (MDL-70456).
-$extrafields = \core\user_fields::get_identity_fields($context, false);
+$extrafields = \core_user\fields::get_identity_fields($context, false);
foreach ($extrafields as $field) {
- $userdetails[$field] = \core\user_fields::get_display_name($field);
+ $userdetails[$field] = \core_user\fields::get_display_name($field);
}
$fields = array(
// We only use the first user.
$i = 0;
do {
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$allnames = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$rusers = get_role_users($croles[$i], $context, true, 'u.id, u.confirmed, u.username, '. $allnames . ',
u.email, r.sortorder, ra.id', 'r.sortorder, ra.id ASC, ' . $sort, null, '', '', '', '', $sortparams);
$mform->addElement('password', 'enrolpassword', get_string('password', 'enrol_self'),
array('id' => 'enrolpassword_'.$instance->id));
$context = context_course::instance($this->instance->courseid);
- $userfieldsapi = \core\user_fields::for_userpic();
+ $userfieldsapi = \core_user\fields::for_userpic();
$ufields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$keyholders = get_users_by_capability($context, 'enrol/self:holdkey', $ufields);
$keyholdercount = 0;
* @copyright 2017 Andrew nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class core_files_converter_testcase extends advanced_testcase {
+class converter_test extends advanced_testcase {
/**
* Get a testable mock of the abstract files_converter class.
* Test the get_document_converter_classes function when the returned classes do not meet requirements.
*/
public function test_get_document_converter_classes_plugin_class_requirements_not_met() {
- $plugin = $this->getMockBuilder(\core_file_converter_requirements_not_met_test::class)
+ $plugin = $this->getMockBuilder(\core_file_converter_requirements_not_met::class)
->onlyMethods([])
->getMock();
* Test the get_document_converter_classes function when the returned classes do not meet requirements.
*/
public function test_get_document_converter_classes_plugin_class_met_not_supported() {
- $plugin = $this->getMockBuilder(\core_file_converter_type_not_supported_test::class)
+ $plugin = $this->getMockBuilder(\core_file_converter_type_not_supported::class)
->onlyMethods([])
->getMock();
* Test the get_document_converter_classes function when the returned classes do not meet requirements.
*/
public function test_get_document_converter_classes_plugin_class_met_and_supported() {
- $plugin = $this->getMockBuilder(\core_file_converter_type_supported_test::class)
+ $plugin = $this->getMockBuilder(\core_file_converter_type_supported::class)
->onlyMethods([])
->getMock();
$classname = get_class($plugin);
}
}
-class core_file_converter_requirements_test_base implements \core_files\converter_interface {
+class core_file_converter_requirements_base implements \core_files\converter_interface {
/**
* Whether the plugin is configured and requirements are met.
* @copyright 2017 Andrew nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class core_file_converter_requirements_not_met_test extends core_file_converter_requirements_test_base {
+class core_file_converter_requirements_not_met extends core_file_converter_requirements_base {
}
/**
* @copyright 2017 Andrew nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class core_file_converter_type_not_supported_test extends core_file_converter_requirements_test_base {
+class core_file_converter_type_not_supported extends core_file_converter_requirements_base {
/**
* Whether the plugin is configured and requirements are met.
* @copyright 2017 Andrew nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class core_file_converter_type_supported_test extends core_file_converter_requirements_test_base {
+class core_file_converter_type_supported extends core_file_converter_requirements_base {
/**
* Whether the plugin is configured and requirements are met.
* @copyright 2017 Andrew nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class core_file_converter_type_successful extends core_file_converter_requirements_test_base {
+class core_file_converter_type_successful extends core_file_converter_requirements_base {
/**
* Convert a document to a new format and return a conversion object relating to the conversion in progress.
* @copyright 2017 Andrew nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class core_file_converter_type_failed extends core_file_converter_requirements_test_base {
+class core_file_converter_type_failed extends core_file_converter_requirements_base {
/**
* Whether the plugin is configured and requirements are met.
}
if ($errorstr) {
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$user = $DB->get_record('user', array('id' => $userid), 'id' . $userfieldsapi->get_sql()->selects);
$gradestr = new stdClass();
$gradestr->username = fullname($user);
}
if ($errorstr) {
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$userfields = 'id, ' . $userfieldsapi->get_sql('', false, '', '', false)->selects;
$user = $DB->get_record('user', array('id' => $userid), $userfields);
$gradestr = new stdClass();
// Fields we need from the user table.
// TODO Does not support custom user profile fields (MDL-70456).
- $userfieldsapi = \core\user_fields::for_identity($this->context, false)->with_userpic();
+ $userfieldsapi = \core_user\fields::for_identity($this->context, false)->with_userpic();
$userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
// We want to query both the current context and parent contexts.
$strgrade = $this->get_lang_string('grade');
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = \core\user_fields::get_identity_fields($this->context, false);
+ $extrafields = \core_user\fields::get_identity_fields($this->context, false);
$arrows = $this->get_sort_arrows($extrafields);
}
$arrows['studentname'] = '';
- $requirednames = order_in_string(\core\user_fields::get_name_fields(), $nameformat);
+ $requirednames = order_in_string(\core_user\fields::get_name_fields(), $nameformat);
if (!empty($requirednames)) {
foreach ($requirednames as $name) {
$arrows['studentname'] .= html_writer::link(
foreach ($extrafields as $field) {
$fieldlink = html_writer::link(new moodle_url($this->baseurl,
- array('sortitemid' => $field)), \core\user_fields::get_display_name($field));
+ array('sortitemid' => $field)), \core_user\fields::get_display_name($field));
$arrows[$field] = $fieldlink;
if ($field == $this->sortitemid) {
// Fields we need from the user table.
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = \core\user_fields::get_identity_fields($context, false);
+ $extrafields = \core_user\fields::get_identity_fields($context, false);
$params = array();
if (!empty($search)) {
list($filtersql, $params) = users_search_sql($search, 'u', true, $extrafields);
$filtersql = '';
}
- $userfieldsapi = \core\user_fields::for_userpic()->including(...(array_merge($extrafields, ['username'])));
+ $userfieldsapi = \core_user\fields::for_userpic()->including(...(array_merge($extrafields, ['username'])));
$ufields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
if ($count) {
$select = "SELECT COUNT(DISTINCT u.id) ";
$groupwheresql = " AND gm.groupid $insql ";
}
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$ufields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$sql = "SELECT u.id, $ufields
FROM {user} u
*/
protected function define_table_columns() {
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = \core\user_fields::get_identity_fields($this->context, false);
+ $extrafields = \core_user\fields::get_identity_fields($this->context, false);
// Define headers and columns.
$cols = array(
// Add extra user fields that we need for the graded user.
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = \core\user_fields::get_identity_fields($this->context, false);
+ $extrafields = \core_user\fields::get_identity_fields($this->context, false);
foreach ($extrafields as $field) {
$fields .= 'u.' . $field . ', ';
}
- $userfieldsapi = \core\user_fields::for_name();
+ $userfieldsapi = \core_user\fields::for_name();
$gradeduserfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$fields .= $gradeduserfields . ', ';
$groupby = $fields;
$outcome->response['totalusers'] = \gradereport_history\helper::get_users_count($context, $search);;
// TODO Does not support custom user profile fields (MDL-70456).
-$extrafields = \core\user_fields::get_identity_fields($context, false);
+$extrafields = \core_user\fields::get_identity_fields($context, false);
$useroptions = array('link' => false, 'visibletoscreenreaders' => false);
// Format the user record.
/**
* A test class used to test grade_report, the abstract grade report parent class
*/
-class grade_report_test extends grade_report {
+class grade_report_mock extends grade_report {
public function __construct($courseid, $gpr, $context, $user) {
parent::__construct($courseid, $gpr, $context);
$this->user = $user;
/**
* Tests grade_report, the parent class for all grade reports.
*/
-class core_grade_reportlib_testcase extends advanced_testcase {
+class reportlib_test extends advanced_testcase {
/**
* Tests grade_report::blank_hidden_total_and_adjust_bounds()
set_coursemodule_visible($datacm->id, 0);
$gpr = new grade_plugin_return(array('type' => 'report', 'courseid' => $course->id));
- $report = new grade_report_test($course->id, $gpr, $coursecontext, $student);
+ $report = new grade_report_mock($course->id, $gpr, $coursecontext, $student);
// Should return the supplied student total grade regardless of hiding.
$report->showtotalsifcontainhidden = array($course->id => GRADE_REPORT_SHOW_REAL_TOTAL_IF_CONTAINS_HIDDEN);
set_coursemodule_visible($forumcm->id, 0);
$gpr = new grade_plugin_return(array('type' => 'report', 'courseid' => $course->id));
- $report = new grade_report_test($course->id, $gpr, $coursecontext, $student);
+ $report = new grade_report_mock($course->id, $gpr, $coursecontext, $student);
// Should return the supplied student total grade regardless of hiding.
$report->showtotalsifcontainhidden = array($course->id => GRADE_REPORT_SHOW_REAL_TOTAL_IF_CONTAINS_HIDDEN);
$onlyactive = !empty($data->includeonlyactiveenrol) || !has_capability('moodle/course:viewsuspendedusers', $context);
// TODO Does not support custom user profile fields (MDL-70456).
- $extrafields = \core\user_fields::get_identity_fields($context, false);
+ $extrafields = \core_user\fields::get_identity_fields($context, false);
$users = groups_get_potential_members($data->courseid, $data->roleid, $source, $orderby, !empty($data->notingroup),
$onlyactive, $extrafields);
$usercnt = count($users);
$roles = array();
// TODO Does not support custom user profile fields (MDL-70456).
- $userfieldsapi = \core\user_fields::for_identity($context, false)->with_userpic();
+ $userfieldsapi = \core_user\fields::for_identity($context, false)->with_userpic();
$userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
- $extrafields = $userfieldsapi->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]);
+ $extrafields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]);
if ($groupmemberroles = groups_get_members_by_role($groupids[0], $courseid,
'u.id, ' . $userfields)) {
$members = array();
if ($singlegroup) {
// TODO Does not support custom user profile fields (MDL-70456).
- $userfieldsapi = \core\user_fields::for_identity($context, false)->with_userpic();
+ $userfieldsapi = \core_user\fields::for_identity($context, false)->with_userpic();
$userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
- $extrafields = $userfieldsapi->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]);
+ $extrafields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]);
if ($groupmemberroles = groups_get_members_by_role(reset($groupids), $courseid,
'u.id, ' . $userfields)) {
}
}
- $userfieldsapi = \core\user_fields::for_userpic()->including(...$extrafields);
+ $userfieldsapi = \core_user\fields::for_userpic()->including(...$extrafields);
$allusernamefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
$sql = "SELECT DISTINCT u.id, u.username, $allusernamefields, u.idnumber
FROM {user} u
list($sort, $sortparams) = users_order_by_sql('u');
// TODO Does not support custom user profile fields (MDL-70456).
-$userfieldsapi = \core\user_fields::for_identity($context, false)->with_userpic();
+$userfieldsapi = \core_user\fields::for_identity($context, false)->with_userpic();
$userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
-$extrafields = $userfieldsapi->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]);
+$extrafields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]);
$allnames = 'u.id, ' . $userfields;
$sql = "SELECT g.id AS groupid, gg.groupingid, u.id AS userid, $allnames, u.idnumber, u.username
defined('MOODLE_INTERNAL') || die();
$string['clianswerno'] = 'n';
-$string['cliansweryes'] = 'w';
-$string['cliyesnoprompt'] = 'Tape w (pou wi) oswa n (pou non)';
+$string['cliansweryes'] = 'y';
+$string['cliincorrectvalueerror'] = 'Erè, valè kòrèk "{$ a-> valè}" pou "{$ a-> opsyon}"';
+$string['cliincorrectvalueretry'] = 'Valè ki pa kòrèk, tanpri eseye ankò';
+$string['clitypevalue'] = 'valè kalite';
+$string['clitypevaluedefault'] = 'tape valè, peze Antre pou itilize valè default ({$ a})';
+$string['cliunknowoption'] = 'Opsyon ki pa rekonèt:
+ {$ a}
+Tanpri itilize opsyon --help.';
+$string['cliyesnoprompt'] = 'tape y (vle di wi) oswa n (vle di non)';
+$string['environmentrequireinstall'] = 'dwe enstale ak pèmèt';
+$string['environmentrequireversion'] = 'vèsyon {$ a-> bezwen} obligatwa epi w ap kouri {$ a-> current}';
+$string['upgradekeyset'] = 'Mizajou kle (kite vid pou pa mete li)';
defined('MOODLE_INTERNAL') || die();
-$string['parentlanguage'] = 'he';
+$string['parentlanguage'] = '';
$string['thisdirection'] = 'rtl';
$string['thislanguage'] = 'עברית';