From 9e95193e9fd0ac9a69ce6c7573c97017e6565034 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 4 Oct 2018 15:57:04 +0800 Subject: [PATCH] MDL-63352 block_timeline: Persist user preference on timeline * Persist sort preference to the user table * Persist filter preferences to the user table * Add behat tests to test persistence * Add the gdpr exports for the user preferences as well as unit tests for the same * Updated view_nav.min --- blocks/timeline/amd/build/view_nav.min.js | 2 +- blocks/timeline/amd/src/view_nav.js | 48 +++++++- blocks/timeline/block_timeline.php | 5 +- blocks/timeline/classes/output/main.php | 94 ++++++++++++++- blocks/timeline/classes/privacy/provider.php | 38 +++++- blocks/timeline/lang/en/block_timeline.php | 4 +- blocks/timeline/lib.php | 70 +++++++++++ .../templates/nav-day-filter.mustache | 18 ++- .../templates/nav-view-selector.mustache | 12 +- blocks/timeline/templates/view.mustache | 29 ++++- .../behat/block_timeline_courses.feature | 19 +++ .../tests/behat/block_timeline_dates.feature | 33 ++++++ blocks/timeline/tests/privacy_test.php | 110 ++++++++++++++++++ .../block_timeline/nav-day-filter.mustache | 12 +- .../block_timeline/nav-view-selector.mustache | 4 +- .../templates/block_timeline/view.mustache | 29 ++++- 16 files changed, 488 insertions(+), 39 deletions(-) create mode 100644 blocks/timeline/lib.php create mode 100644 blocks/timeline/tests/privacy_test.php diff --git a/blocks/timeline/amd/build/view_nav.min.js b/blocks/timeline/amd/build/view_nav.min.js index c66ad2fab55..09fbfd94004 100644 --- a/blocks/timeline/amd/build/view_nav.min.js +++ b/blocks/timeline/amd/build/view_nav.min.js @@ -1 +1 @@ -define(["jquery","core/custom_interaction_events","block_timeline/view"],function(a,b,c){var d={TIMELINE_DAY_FILTER:'[data-region="day-filter"]',TIMELINE_DAY_FILTER_OPTION:"[data-from]",TIMELINE_VIEW_SELECTOR:'[data-region="view-selector"]',DATA_DAYS_OFFSET:"[data-days-offset]",DATA_DAYS_LIMIT:"[data-days-limit]"},e=function(e,f){var g=e.find(d.TIMELINE_DAY_FILTER);b.define(g,[b.events.activate]),g.on(b.events.activate,d.TIMELINE_DAY_FILTER_OPTION,function(b,g){var h=a(b.target).closest(d.TIMELINE_DAY_FILTER_OPTION);if(!h.hasClass("active")){var i=h.attr("data-from"),j=h.attr("data-to"),k=e.find(d.DATA_DAYS_OFFSET);k.attr("data-days-offset",i),void 0!=j?k.attr("data-days-limit",j):k.removeAttr("data-days-limit"),c.reset(f),g.originalEvent.preventDefault()}})},f=function(a,b){a.find(d.TIMELINE_VIEW_SELECTOR).on("shown shown.bs.tab",function(){c.shown(b)})},g=function(b,c){b=a(b),e(b,c),f(b,c)};return{init:g}}); \ No newline at end of file +define(["jquery","core/custom_interaction_events","block_timeline/view","core/ajax","core/notification"],function(a,b,c,d,e){var f={TIMELINE_DAY_FILTER:'[data-region="day-filter"]',TIMELINE_DAY_FILTER_OPTION:"[data-from]",TIMELINE_VIEW_SELECTOR:'[data-region="view-selector"]',DATA_DAYS_OFFSET:"[data-days-offset]",DATA_DAYS_LIMIT:"[data-days-limit]"},g=function(a,b){var c={methodname:"core_user_update_user_preferences",args:{preferences:[{type:a,value:b}]}};d.call([c])[0].fail(e.exception)},h=function(d,e){var h=d.find(f.TIMELINE_DAY_FILTER);b.define(h,[b.events.activate]),h.on(b.events.activate,f.TIMELINE_DAY_FILTER_OPTION,function(b,h){var i=a(b.currentTarget).data("filtername"),j="block_timeline_user_filter_preference";g(j,i);var k=a(b.target).closest(f.TIMELINE_DAY_FILTER_OPTION);if(!k.hasClass("active")){var l=k.attr("data-from"),m=k.attr("data-to"),n=d.find(f.DATA_DAYS_OFFSET);n.attr("data-days-offset",l),void 0!=m?n.attr("data-days-limit",m):n.removeAttr("data-days-limit"),c.reset(e),h.originalEvent.preventDefault()}})},i=function(d,e){var h=d.find(f.TIMELINE_VIEW_SELECTOR);h.on("shown shown.bs.tab",function(){c.shown(e)}),b.define(h,[b.events.activate]),h.on(b.events.activate,"[data-toggle='tab']",function(b){var c=a(b.currentTarget).data("filtername"),d="block_timeline_user_sort_preference";g(d,c)})},j=function(b,c){b=a(b),h(b,c),i(b,c)};return{init:j}}); \ No newline at end of file diff --git a/blocks/timeline/amd/src/view_nav.js b/blocks/timeline/amd/src/view_nav.js index 092dcbd93fe..6fd32a6f7e4 100644 --- a/blocks/timeline/amd/src/view_nav.js +++ b/blocks/timeline/amd/src/view_nav.js @@ -25,12 +25,16 @@ define( [ 'jquery', 'core/custom_interaction_events', - 'block_timeline/view' + 'block_timeline/view', + 'core/ajax', + 'core/notification' ], function( $, CustomEvents, - View + View, + Ajax, + Notification ) { var SELECTORS = { @@ -41,6 +45,29 @@ function( DATA_DAYS_LIMIT: '[data-days-limit]', }; + /** + * Generic handler to persist user preferences + * + * @param {string} type The name of the attribute you're updating + * @param {string} value The value of the attribute you're updating + */ + var updateUserPreferences = function(type, value) { + var request = { + methodname: 'core_user_update_user_preferences', + args: { + preferences: [ + { + type: type, + value: value + } + ] + } + }; + + Ajax.call([request])[0] + .fail(Notification.exception); + }; + /** * Event listener for the day selector ("Next 7 days", "Next 30 days", etc). * @@ -55,6 +82,11 @@ function( CustomEvents.events.activate, SELECTORS.TIMELINE_DAY_FILTER_OPTION, function(e, data) { + // Update the user preference + var filtername = $(e.currentTarget).data('filtername'); + var type = 'block_timeline_user_filter_preference'; + updateUserPreferences(type, filtername); + var option = $(e.target).closest(SELECTORS.TIMELINE_DAY_FILTER_OPTION); if (option.hasClass('active')) { @@ -94,11 +126,21 @@ function( * @param {object} timelineViewRoot The root element for the timeline view */ var registerViewSelector = function(root, timelineViewRoot) { + var viewSelector = root.find(SELECTORS.TIMELINE_VIEW_SELECTOR); + // Listen for when the user changes tab so that we can show the first set of courses // and load their events when they request the sort by courses view for the first time. - root.find(SELECTORS.TIMELINE_VIEW_SELECTOR).on('shown shown.bs.tab', function() { + viewSelector.on('shown shown.bs.tab', function() { View.shown(timelineViewRoot); }); + + // Event selector for user_sort + CustomEvents.define(viewSelector, [CustomEvents.events.activate]); + viewSelector.on(CustomEvents.events.activate, "[data-toggle='tab']", function(e) { + var filtername = $(e.currentTarget).data('filtername'); + var type = 'block_timeline_user_sort_preference'; + updateUserPreferences(type, filtername); + }); }; /** diff --git a/blocks/timeline/block_timeline.php b/blocks/timeline/block_timeline.php index 5a24f9ca98f..03e72303328 100644 --- a/blocks/timeline/block_timeline.php +++ b/blocks/timeline/block_timeline.php @@ -50,7 +50,10 @@ class block_timeline extends block_base { return $this->content; } - $renderable = new \block_timeline\output\main(); + $sort = get_user_preferences('block_timeline_user_sort_preference'); + $filter = get_user_preferences('block_timeline_user_filter_preference'); + + $renderable = new \block_timeline\output\main($sort, $filter); $renderer = $this->page->get_renderer('block_timeline'); $this->content = (object) [ diff --git a/blocks/timeline/classes/output/main.php b/blocks/timeline/classes/output/main.php index b5e96097b22..218eb46f194 100644 --- a/blocks/timeline/classes/output/main.php +++ b/blocks/timeline/classes/output/main.php @@ -30,6 +30,7 @@ use templatable; use core_course\external\course_summary_exporter; require_once($CFG->dirroot . '/course/lib.php'); +require_once($CFG->dirroot . '/blocks/timeline/lib.php'); require_once($CFG->libdir . '/completionlib.php'); /** @@ -43,6 +44,86 @@ class main implements renderable, templatable { /** Number of courses to load per page */ const COURSES_PER_PAGE = 2; + /** + * @var string The current filter preference + */ + public $filter; + + /** + * @var string The current sort/order preference + */ + public $order; + + /** + * main constructor. + * + * @param string $order Constant sort value from ../timeline/lib.php + * @param string $filter Constant sort value from ../timeline/lib.php + */ + public function __construct($order, $filter) { + $this->order = $order ? $order : BLOCK_TIMELINE_SORT_BY_DATES; + $this->filter = $filter ? $filter : BLOCK_TIMELINE_FILTER_BY_7_DAYS; + } + + /** + * Test the available filters with the current user preference and return an array with + * bool flags corresponding to which is active + * + * @return array + */ + protected function get_filters_as_booleans() { + $filters = [ + BLOCK_TIMELINE_FILTER_BY_NONE => false, + BLOCK_TIMELINE_FILTER_BY_OVERDUE => false, + BLOCK_TIMELINE_FILTER_BY_7_DAYS => false, + BLOCK_TIMELINE_FILTER_BY_30_DAYS => false, + BLOCK_TIMELINE_FILTER_BY_3_MONTHS => false, + BLOCK_TIMELINE_FILTER_BY_6_MONTHS => false + ]; + + // Set the selected filter to true. + $filters[$this->filter] = true; + + return $filters; + } + + /** + * Get the offset/limit values corresponding to $this->filter + * which are used to send through to the context as default values + * + * @return array + */ + private function get_filter_offsets() { + + $limit = false; + if (in_array($this->filter, [BLOCK_TIMELINE_FILTER_BY_NONE, BLOCK_TIMELINE_FILTER_BY_OVERDUE])) { + $offset = -14; + if ($this->filter == BLOCK_TIMELINE_FILTER_BY_OVERDUE) { + $limit = 0; + } + } else { + $offset = 0; + $limit = 7; + + switch($this->filter) { + case BLOCK_TIMELINE_FILTER_BY_30_DAYS: + $limit = 30; + break; + case BLOCK_TIMELINE_FILTER_BY_3_MONTHS: + $limit = 90; + break; + case BLOCK_TIMELINE_FILTER_BY_6_MONTHS: + $limit = 180; + break; + } + } + + return [ + 'daysoffset' => $offset, + 'dayslimit' => $limit + ]; + } + /** * Export this data so it can be used as the context for a mustache template. * @@ -69,13 +150,22 @@ class main implements renderable, templatable { return $exporter->export($output); }, $inprogresscourses); - return [ + $filters = $this->get_filters_as_booleans(); + $offsets = $this->get_filter_offsets(); + $contextvariables = [ 'midnight' => usergetmidnight(time()), 'coursepages' => [$formattedcourses], 'urls' => [ 'nocourses' => $nocoursesurl, 'noevents' => $noeventsurl - ] + ], + 'sorttimelinedates' => $this->order == BLOCK_TIMELINE_SORT_BY_DATES, + 'sorttimelinecourses' => $this->order == BLOCK_TIMELINE_SORT_BY_COURSES, + 'selectedfilter' => $this->filter, + 'hasdaysoffset' => true, + 'hasdayslimit' => $offsets['dayslimit'] !== false , + 'nodayslimit' => $offsets['dayslimit'] === false , ]; + return array_merge($contextvariables, $filters, $offsets); } } diff --git a/blocks/timeline/classes/privacy/provider.php b/blocks/timeline/classes/privacy/provider.php index 02ae526ba74..3be099871b1 100644 --- a/blocks/timeline/classes/privacy/provider.php +++ b/blocks/timeline/classes/privacy/provider.php @@ -25,6 +25,7 @@ namespace block_timeline\privacy; defined('MOODLE_INTERNAL') || die(); +use \core_privacy\local\metadata\collection; /** * Privacy Subsystem for block_timeline. @@ -32,15 +33,40 @@ defined('MOODLE_INTERNAL') || die(); * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class provider implements \core_privacy\local\metadata\null_provider { +class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\user_preference_provider { /** - * Get the language string identifier with the component's language - * file to explain why this plugin stores no data. + * Returns meta-data information about the myoverview block. * - * @return string + * @param \core_privacy\local\metadata\collection $collection A collection of meta-data. + * @return \core_privacy\local\metadata\collection Return the collection of meta-data. */ - public static function get_reason() : string { - return 'privacy:metadata'; + public static function get_metadata(collection $collection) : collection { + $collection->add_user_preference('block_timeline_user_sort_preference', 'privacy:metadata:timelinesortpreference'); + $collection->add_user_preference('block_timeline_user_filter_preference', 'privacy:metadata:timelinefilterpreference'); + return $collection; + } + + /** + * Export all user preferences for the myoverview block + * + * @param int $userid The userid of the user whose data is to be exported. + */ + public static function export_user_preferences(int $userid) { + $preference = get_user_preferences('block_timeline_user_sort_preference', null, $userid); + if (isset($preference)) { + \core_privacy\local\request\writer::export_user_preference('block_timeline', 'block_timeline_user_sort_preference', + get_string($preference, 'block_timeline'), + get_string('privacy:metadata:timelinesortpreference', 'block_timeline') + ); + } + + $preference = get_user_preferences('block_timeline_user_filter_preference', null, $userid); + if (isset($preference)) { + \core_privacy\local\request\writer::export_user_preference('block_timeline', 'block_timeline_user_filter_preference', + get_string($preference, 'block_timeline'), + get_string('privacy:metadata:timelinefilterpreference', 'block_timeline') + ); + } } } diff --git a/blocks/timeline/lang/en/block_timeline.php b/blocks/timeline/lang/en/block_timeline.php index 70f961ce203..d474cc94478 100644 --- a/blocks/timeline/lang/en/block_timeline.php +++ b/blocks/timeline/lang/en/block_timeline.php @@ -41,9 +41,11 @@ $string['next7days'] = 'Next 7 days'; $string['next3months'] = 'Next 3 months'; $string['next6months'] = 'Next 6 months'; $string['overdue'] = 'Overdue'; +$string['all'] = 'All'; $string['pluginname'] = 'Timeline'; $string['sortbycourses'] = 'Sort by courses'; $string['sortbydates'] = 'Sort by dates'; $string['timeline'] = 'Timeline'; $string['viewcourse'] = 'View course'; -$string['privacy:metadata'] = 'The timeline block does not store any personal data.'; +$string['privacy:metadata:timelinesortpreference'] = 'The user sort preference for the timeline block.'; +$string['privacy:metadata:timelinefilterpreference'] = 'The user day filter preference for the timeline block.'; diff --git a/blocks/timeline/lib.php b/blocks/timeline/lib.php new file mode 100644 index 00000000000..26e8783f02a --- /dev/null +++ b/blocks/timeline/lib.php @@ -0,0 +1,70 @@ +. + +/** + * Library functions for timeline + * + * @package block_timeline + * @copyright 2018 Peter Dias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +defined('MOODLE_INTERNAL') || die(); + +/** + * Define constants to store the SORT user preference + */ +define('BLOCK_TIMELINE_SORT_BY_DATES', 'sortbydates'); +define('BLOCK_TIMELINE_SORT_BY_COURSES', 'sortbycourses'); + +/** + * Define constants to store the FILTER user preference + */ +define('BLOCK_TIMELINE_FILTER_BY_NONE', 'all'); +define('BLOCK_TIMELINE_FILTER_BY_OVERDUE', 'overdue'); +define('BLOCK_TIMELINE_FILTER_BY_7_DAYS', 'next7days'); +define('BLOCK_TIMELINE_FILTER_BY_30_DAYS', 'next30days'); +define('BLOCK_TIMELINE_FILTER_BY_3_MONTHS', 'next3months'); +define('BLOCK_TIMELINE_FILTER_BY_6_MONTHS', 'next6months'); + +/** + * Returns the name of the user preferences as well as the details this plugin uses. + * + * @return array + */ +function block_timeline_user_preferences() { + $preferences['block_timeline_user_sort_preference'] = array( + 'null' => NULL_NOT_ALLOWED, + 'default' => BLOCK_TIMELINE_SORT_BY_DATES, + 'type' => PARAM_ALPHA, + 'choices' => array(BLOCK_TIMELINE_SORT_BY_DATES, BLOCK_TIMELINE_SORT_BY_COURSES) + ); + + $preferences['block_timeline_user_filter_preference'] = array( + 'null' => NULL_NOT_ALLOWED, + 'default' => BLOCK_TIMELINE_FILTER_BY_30_DAYS, + 'type' => PARAM_ALPHANUM, + 'choices' => array( + BLOCK_TIMELINE_FILTER_BY_NONE, + BLOCK_TIMELINE_FILTER_BY_OVERDUE, + BLOCK_TIMELINE_FILTER_BY_7_DAYS, + BLOCK_TIMELINE_FILTER_BY_30_DAYS, + BLOCK_TIMELINE_FILTER_BY_3_MONTHS, + BLOCK_TIMELINE_FILTER_BY_6_MONTHS + ) + ); + + return $preferences; +} diff --git a/blocks/timeline/templates/nav-day-filter.mustache b/blocks/timeline/templates/nav-day-filter.mustache index 90afd627173..938763bf18f 100644 --- a/blocks/timeline/templates/nav-day-filter.mustache +++ b/blocks/timeline/templates/nav-day-filter.mustache @@ -32,18 +32,20 @@