From 8f0d491b4c6a8f11ef9165a2ff9a5e44a25b49cd Mon Sep 17 00:00:00 2001 From: Odei Alba Date: Fri, 26 Jan 2024 18:24:59 +0100 Subject: [PATCH] SV-26 Set up the plugin to work on the mobile app --- .github/workflows/moodle-ci.yml | 1 + classes/output/mobile.php | 118 ++++++++++++++++++++ classes/output/sort_voting_results.php | 2 +- db/mobile.php | 54 +++++++++ db/services.php | 1 + lib.php | 2 +- mobile/js/init.js | 86 ++++++++++++++ mobile/js/mobile_sortvoting.js | 117 ++++++++++++++++++++ style/mobile.css | 17 +++ templates/mobile_sort_voting_view.mustache | 123 +++++++++++++++++++++ templates/sort_voting_results.mustache | 2 +- version.php | 2 +- 12 files changed, 521 insertions(+), 4 deletions(-) create mode 100644 classes/output/mobile.php create mode 100644 db/mobile.php create mode 100644 mobile/js/init.js create mode 100644 mobile/js/mobile_sortvoting.js create mode 100644 style/mobile.css create mode 100644 templates/mobile_sort_voting_view.mustache diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml index b34074e..2789d36 100644 --- a/.github/workflows/moodle-ci.yml +++ b/.github/workflows/moodle-ci.yml @@ -116,6 +116,7 @@ jobs: env: DB: ${{ matrix.database }} MOODLE_BRANCH: ${{ matrix.moodle-branch }} + MUSTACHE_IGNORE_NAMES: 'mobile*.mustache' - name: PHP Lint if: ${{ always() }} diff --git a/classes/output/mobile.php b/classes/output/mobile.php new file mode 100644 index 0000000..64fab4d --- /dev/null +++ b/classes/output/mobile.php @@ -0,0 +1,118 @@ +. + +namespace mod_sortvoting\output; +use context_module; + +defined('MOODLE_INTERNAL') || die(); +require_once($CFG->dirroot . '/mod/sortvoting/lib.php'); + +/** + * Class mobile + * + * @package mod_sortvoting + * @copyright 2024 Odei Alba + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mobile { + /** + * Returns the javascript needed to initialize SortVoting in the app. + * + * @param array $args Arguments from tool_mobile_get_content WS + * @return array javascript + */ + public static function mobile_init($args) { + global $CFG; + + return [ + 'templates' => [], + 'javascript' => file_get_contents($CFG->dirroot . "/mod/sortvoting/mobile/js/init.js"), + ]; + } + + /** + * Returns the SortVoting form view for the mobile app. + * + * @param mixed $args + * @return array HTML, javascript and other data. + */ + public static function mobile_sort_voting_view($args): array { + global $OUTPUT, $DB, $CFG; + $args = (object) $args; + + $cm = get_coursemodule_from_id('sortvoting', $args->cmid); + $sortvotingid = $cm->instance; + $userid = $args->userid; + + $sortvoting = $DB->get_record("sortvoting", ["id" => $sortvotingid]); + $options = $DB->get_records('sortvoting_options', ['sortvotingid' => $sortvotingid], 'id ASC'); + $existingvotes = $DB->get_records_menu( + 'sortvoting_answers', + [ + 'sortvotingid' => $sortvotingid, + 'userid' => $userid, + ], + 'id ASC', + 'optionid, position' + ); + + $allowupdate = true; + if (!$sortvoting->allowupdate && count($existingvotes) === count($options)) { + $allowupdate = false; + } + + $defaultposition = (count($existingvotes) > 0) ? (count($existingvotes) + 1) : 1; + $optionsclean = []; + foreach ($options as $option) { + $position = isset($existingvotes[$option->id]) ? $existingvotes[$option->id] : $defaultposition++; + $optionsclean[] = [ + 'id' => $option->id, + 'text' => $option->text, + 'position' => $position, + ]; + } + + // Sort $optionsclean by position. + usort($optionsclean, function ($a, $b) { + return $a['position'] <=> $b['position']; + }); + + $canseeresults = \mod_sortvoting\permission::can_see_results($sortvoting, context_module::instance($cm->id)); + + // Result of existing votes. + $existingvotes = $canseeresults ? sortvoting_get_response_data($sortvoting) : []; + + $data = [ + 'description' => html_to_text($sortvoting->intro), + 'allowupdate' => $allowupdate, + 'options' => $optionsclean, + 'max' => count($options), + 'canseeresults' => $canseeresults, + 'votes' => $existingvotes, + ]; + + return [ + 'templates' => [ + [ + 'id' => 'main', + 'html' => $OUTPUT->render_from_template('mod_sortvoting/mobile_sort_voting_view', $data), + ], + ], + 'javascript' => file_get_contents($CFG->dirroot . "/mod/sortvoting/mobile/js/mobile_sortvoting.js"), + 'otherdata' => '', + ]; + } +} diff --git a/classes/output/sort_voting_results.php b/classes/output/sort_voting_results.php index bae614c..4e4bb9a 100644 --- a/classes/output/sort_voting_results.php +++ b/classes/output/sort_voting_results.php @@ -47,7 +47,7 @@ public function __construct(\stdClass $sortvoting) { */ public function export_for_template(renderer_base $output): array { $existingvotes = sortvoting_get_response_data($this->sortvoting); - $maxvotescount = (int) max(array_column($existingvotes, 'votescount')); + $maxvotescount = empty($existingvotes) ? 0 : (int) max(array_column($existingvotes, 'votescount')); return ['votes' => $existingvotes, 'maxvotescount' => $maxvotescount]; } diff --git a/db/mobile.php b/db/mobile.php new file mode 100644 index 0000000..acd44b3 --- /dev/null +++ b/db/mobile.php @@ -0,0 +1,54 @@ +. + +/** + * Mobile app areas for Preference Sort Voting + * + * @package mod_sortvoting + * @copyright 2024 Odei Alba + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$addons = [ + 'mod_sortvoting' => [ + "handlers" => [ // Different places where the add-on will display content. + 'coursesortvotingview' => [ // Handler unique name (can be anything). + 'displaydata' => [ + 'title' => 'pluginname', + 'icon' => $CFG->wwwroot . '/mod/sortvoting/pix/monologo.png', + 'class' => '', + ], + 'delegate' => 'CoreCourseModuleDelegate', // Delegate (where to display the link to the add-on). + 'method' => 'mobile_sort_voting_view', // Main function in \mod_sortvoting\output\mobile. + 'init' => 'mobile_init', + 'styles' => [ + 'url' => $CFG->wwwroot . '/mod/sortvoting/style/mobile.css', + 'version' => '0.1', + ], + ], + ], + 'lang' => [ + ['pluginname', 'mod_sortvoting'], + ['instructions', 'mod_sortvoting'], + ['votesuccess', 'mod_sortvoting'], + ['position', 'mod_sortvoting'], + ['option', 'mod_sortvoting'], + ['save', 'core'], + ], + ], +]; diff --git a/db/services.php b/db/services.php index 227f712..a020d6a 100644 --- a/db/services.php +++ b/db/services.php @@ -32,5 +32,6 @@ 'type' => 'write', 'capabilities' => 'mod/sortvoting:vote', 'ajax' => true, + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE, 'local_mobile'], ], ]; diff --git a/lib.php b/lib.php index 8123fd9..60a1e3e 100644 --- a/lib.php +++ b/lib.php @@ -386,7 +386,7 @@ function sortvoting_get_response_data(stdClass $sortvoting, bool $onlyactive = t $position = 1; $previousvote = null; - $maxvotescount = (int) max(array_column($existingvotes, 'votescount')); + $maxvotescount = empty($existingvotes) ? 0 : (int) max(array_column($existingvotes, 'votescount')); foreach ($existingvotes as $key => $vote) { if ($previousvote !== null && $previousvote->avg !== $vote->avg) { $position++; diff --git a/mobile/js/init.js b/mobile/js/init.js new file mode 100644 index 0000000..d1deeab --- /dev/null +++ b/mobile/js/init.js @@ -0,0 +1,86 @@ +// This file is part of the mod_sortvoting plugin for 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 . + +/** + * AMD module used when saving a new sort voting on mobile. + * + * @copyright 2024 Odei Alba + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +const context = this; + +/** + * Class to handle sort votings. + */ +class AddonModSortVotingProvider { + /** + * Send the responses to a sort voting. + * + * @param sortvotingid Sort voting ID to submit. + * @param votes The responses to send. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if responses sent to server, false and rejected if failure. + */ + submitResponses(sortvotingid, votes, siteId) { + siteId = siteId || context.CoreSitesProvider.getCurrentSiteId(); + + // TODO: Add offline option. + // Now try to delete the responses in the server. + return this.submitResponsesOnline(sortvotingid, votes, siteId).then(() => { + return true; + }).catch((error) => { + // The WebService has thrown an error, this means that responses cannot be submitted. + return Promise.reject(error); + }); + } + + /** + * Send responses from a sort voting to Moodle. It will fail if offline or cannot connect. + * + * @param sortvotingid Sort voting ID to submit. + * @param votes The responses to send. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + submitResponsesOnline(sortvotingid, votes, siteId) { + return context.CoreSitesProvider.getSite(siteId).then((site) => { + var params = { + sortvotingid: sortvotingid, + votes: votes + }; + + return site.write('mod_sortvoting_save_vote', params).then((response) => { + if (!response || response.success === false) { + // TODO: Add warnings array to save_vote returns. + // Couldn't save the responses. Reject the promise. + var error = response && response.warnings && response.warnings[0] ? + response.warnings[0] : new context.CoreError(''); + + return Promise.reject(error); + } + }); + }); + } + +} + +const sortVotingProvider = new AddonModSortVotingProvider(); + +const result = { + sortVotingProvider: sortVotingProvider, +}; + +result; \ No newline at end of file diff --git a/mobile/js/mobile_sortvoting.js b/mobile/js/mobile_sortvoting.js new file mode 100644 index 0000000..91ef00c --- /dev/null +++ b/mobile/js/mobile_sortvoting.js @@ -0,0 +1,117 @@ +// This file is part of the mod_sortvoting plugin for 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 . + +/** + * AMD module used when saving a new sort voting on mobile. + * + * @copyright 2024 Odei Alba + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +this.submitResponses = () => { + let promise; + promise = Promise.resolve(); + promise.then(() => { + // Show loading modal. + return this.CoreDomUtilsProvider.showModalLoading('core.sending', true); + }).then((modal) => { + var options = document.getElementsByName('option[]'); + + // Build votes and positions arrays for later processing. + var votes = []; + var positions = []; + options.forEach(function(option) { + positions.push(option.value); + votes.push({ + 'position': option.value, + 'optionid': option.getAttribute('data-optionid') + }); + }); + + return this.sortVotingProvider.submitResponses(this.module.instance, votes).then(() => { + // Responses have been sent to server or stored to be sent later. + this.CoreDomUtilsProvider.showToast(this.TranslateService.instant('plugin.mod_sortvoting.votesuccess')); + + // Check completion since it could be configured to complete once the user submits a vote. + this.CoreCourseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); + + // Data has been sent, refresh the content. + return this.refreshContent(true); + }).catch((message) => { + this.CoreDomUtilsProvider.showErrorModalDefault(message, 'Error submitting responses.', true); + }).finally(() => { + modal.dismiss(); + }); + }).catch(() => { + // User cancelled, ignore. + }); +}; + +this.moveUp = (id) => { + var options = document.getElementsByName('option[]'); + + // Change value of the input elements. + var prevId = 0; + var canMove = true; + options.forEach(function (option, index) { + if (option.getAttribute('data-optionid') == id) { + if (parseInt(option.value) == option.getAttribute('min')) { + canMove = false; + return; + } + option.value = parseInt(option.value) - 1; + options[index - 1].value = parseInt(options[index - 1].value) + 1; + prevId = options[index - 1].getAttribute('data-optionid'); + } + }); + + // Move elements order in the DOM. + if (!canMove) { + return; + } + var sortVotingList = document.querySelector("#sortvotinglist"); + var option = document.querySelector("#item-" + id); + var prevOption = document.querySelector("#item-" + prevId); + + sortVotingList.insertBefore(option, prevOption); +} + +this.moveDown = (id) => { + var options = document.getElementsByName('option[]'); + + // Change value of the input elements. + var nextId = 0; + var canMove = true; + options.forEach(function (option, index) { + if (option.getAttribute('data-optionid') == id) { + if (parseInt(option.value) == option.getAttribute('max')) { + canMove = false; + return; + } + option.value = parseInt(option.value) + 1; + options[index + 1].value = parseInt(options[index + 1].value) - 1; + nextId = options[index + 1].getAttribute('data-optionid'); + } + }); + + // Move elements order in the DOM. + if (!canMove) { + return; + } + var sortVotingList = document.querySelector("#sortvotinglist"); + var option = document.querySelector("#item-" + id); + var nextOption = document.querySelector("#item-" + nextId); + + sortVotingList.insertBefore(nextOption, option); +} diff --git a/style/mobile.css b/style/mobile.css new file mode 100644 index 0000000..8363276 --- /dev/null +++ b/style/mobile.css @@ -0,0 +1,17 @@ +.thead { + background-color: #f8f9fa; +} + +.results ion-col { + border: 0.5px solid #dee2e6; +} + +#sortvotinglist { + margin-top: 15px; + margin-bottom: 15px; +} + +#sortvotinglist ion-item:first-child .button-up:first-child, +#sortvotinglist ion-item:last-child .button-down:last-child { + display: none; +} \ No newline at end of file diff --git a/templates/mobile_sort_voting_view.mustache b/templates/mobile_sort_voting_view.mustache new file mode 100644 index 0000000..d5ab7c6 --- /dev/null +++ b/templates/mobile_sort_voting_view.mustache @@ -0,0 +1,123 @@ +{{! + This file is part of the mod_sortvoting plugin for Moodle - http://moodle.org/ + + This program 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. + + This program 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 this program. If not, see . +}} +{{! + @template mod_sortvoting/mobile_sort_voting_view + + Template for showing list of options to sort and the results of the votes on the mobile app. + + Example context (json): + { + "description": "foo", + "allowupdate": true, + "max": 3, + "options": [ + { + "id": 1, + "text": "Option 1", + "position": 1 + }, + { + "id": 2, + "text": "Option 2", + "position": 2 + }, + { + "id": 3, + "text": "Option 3", + "position": 3 + } + ], + "canseeresults": true, + "votes": [ + { + "text": "Option 1", + "position": 1 + }, + { + "text": "Option 2", + "position": 2 + }, + { + "text": "Option 3", + "position": 3 + } + ] + } +}} +{{=<% %>=}} +
+ + <%description%> + + + <%#allowupdate%> + {{ 'plugin.mod_sortvoting.instructions' | translate }} + <%/allowupdate%> + + <%#options%> + + + + + <%#allowupdate%> + + + + + + + + + + + + + <%/allowupdate%> + + + <%text%> + + + + + + <%/options%> + + <%#allowupdate%> + + {{ 'core.save' | translate }} + + <%/allowupdate%> + + + <%#canseeresults%> + + + + {{ 'plugin.mod_sortvoting.position' | translate }} + {{ 'plugin.mod_sortvoting.option' | translate }} + + <%#votes%> + + <%position%> + <%text%> + + <%/votes%> + + + <%/canseeresults%> +
diff --git a/templates/sort_voting_results.mustache b/templates/sort_voting_results.mustache index cba73c7..93709ec 100644 --- a/templates/sort_voting_results.mustache +++ b/templates/sort_voting_results.mustache @@ -17,7 +17,7 @@ {{! @template mod_sortvoting/sort_voting_results - Template for showing list of options to sort. + Template for showing the results of the votes. Example context (json): { diff --git a/version.php b/version.php index 4d4fd50..24c90d8 100644 --- a/version.php +++ b/version.php @@ -26,7 +26,7 @@ $plugin->component = 'mod_sortvoting'; $plugin->release = '1.0.7'; -$plugin->version = 2023111000; +$plugin->version = 2023111002; $plugin->requires = 2022041908; $plugin->maturity = MATURITY_STABLE; $plugin->dependencies = [];